Files
PiAgentUI/docs/superpowers/plans/2026-05-30-piagentui-active-session-switch.md
T
Evandro b3489e1ede
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
2026-06-01 00:50:06 -03:00

532 lines
17 KiB
Markdown

# Plano de Implementação — Troca Correta de Sessões no PiAgentUI
> **Para agentes executores:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para implementar este plano tarefa por tarefa. Os passos usam checkbox (`- [ ]`) para acompanhamento.
**Objetivo:** garantir que uma mensagem enviada no front para a sessão B seja enviada para a sessão real B do Pi, nunca para a sessão que estava ativa quando o Pi abriu.
**Arquitetura:** o backend da extensão PiAgentUI será autoritativo para roteamento de mensagens. Antes de chamar `sendUserMessage`, ele compara `parsed.sessionId` com a sessão real ativa; se divergir, resolve o arquivo `.jsonl`, troca a sessão real do Pi com `switchSession`, e envia dentro do `withSession(replacedCtx)`.
**Tech Stack:** TypeScript, extensão Pi (`ExtensionContext` / `ReplacedSessionContext`), HTTP local Node, Vitest, React frontend sem mudança estrutural.
---
## Estrutura de arquivos
- Modificar: `../pi/packages/coding-agent/src/core/extensions/types.ts`
- Adicionar `switchSession()` em `ExtensionContext`.
- Modificar: `../pi/packages/coding-agent/src/core/extensions/runner.ts`
- Expor `switchSession` também em `createContext()` usando o handler já existente.
- Modificar: `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`
- Atualizar declaração usada pelo typecheck da extensão, caso o build do Pi não regenere dist durante a sessão.
- Modificar: `PiAgentUi/extensions/piagentui-server.ts`
- Resolver arquivo da sessão por ID.
- Enviar mensagem para sessão solicitada com `switchSession(..., { withSession })`.
- Preservar servidor HTTP/SSE em `session_shutdown` causado por `resume` web-iniciado.
- Modificar: `PiAgentUi/extensions/piagentui-server.test.ts`
- Cobrir envio para sessão ativa, envio para sessão diferente, sessão inexistente e switch cancelado.
- Modificar: `PiAgentUi/src/api/message.ts`
- Garantir que erro HTTP de envio não seja engolido; `sendMessageAsync()` já envia `sessionId`.
- Modificar opcionalmente: `PiAgentUi/src/hooks/useChatSession.ts`
- Se necessário, manter erro visível via `handleError('send message', error)` sem mudar UX.
---
## Tarefa 1: Expor switchSession no contexto normal da extensão Pi
**Arquivos:**
- Modificar: `../pi/packages/coding-agent/src/core/extensions/types.ts`
- Modificar: `../pi/packages/coding-agent/src/core/extensions/runner.ts`
- Modificar: `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`
- [ ] **Passo 1: Atualizar o contrato TypeScript do `ExtensionContext`**
Em `../pi/packages/coding-agent/src/core/extensions/types.ts`, dentro de `export interface ExtensionContext`, adicionar logo após `compact(options?: CompactOptions): void;`:
```ts
/**
* Switch to a different session file from long-lived extension services.
* Use `withSession` for work that must run after the replacement context is active.
*/
switchSession(
sessionPath: string,
options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
): Promise<{ cancelled: boolean }>;
```
- [ ] **Passo 2: Expor o método no contexto criado pelo runner**
Em `../pi/packages/coding-agent/src/core/extensions/runner.ts`, dentro de `createContext(): ExtensionContext`, adicionar antes de `getSystemPrompt: () => {`:
```ts
switchSession: (sessionPath, options) => {
runner.assertActive();
return runner.switchSessionHandler(sessionPath, options);
},
```
O trecho final deve ficar assim:
```ts
compact: (options) => {
runner.assertActive();
runner.compactFn(options);
},
switchSession: (sessionPath, options) => {
runner.assertActive();
return runner.switchSessionHandler(sessionPath, options);
},
getSystemPrompt: () => {
runner.assertActive();
return runner.getSystemPromptFn();
},
```
- [ ] **Passo 3: Atualizar a declaração dist se o build do Pi não for executado**
Em `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`, adicionar assinatura equivalente dentro de `ExtensionContext`:
```ts
/**
* Switch to a different session file from long-lived extension services.
* Use `withSession` for work that must run after the replacement context is active.
*/
switchSession(sessionPath: string, options?: {
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
}): Promise<{
cancelled: boolean;
}>;
```
- [ ] **Passo 4: Verificar tipos do Pi core**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/pi/packages/coding-agent
pnpm test -- --runInBand test/suite/regressions/2860-replaced-session-context.test.ts
```
Se esse pacote não usa `pnpm test` com esse formato, executar o comando de teste já documentado no pacote. Resultado esperado: testes existentes passam sem erro de tipo. Se o comando correto não existir, registrar a saída e seguir para o typecheck do PiAgentUI na Tarefa 4.
---
## Tarefa 2: Roteamento de mensagem por sessão no servidor PiAgentUI
**Arquivos:**
- Modificar: `PiAgentUi/extensions/piagentui-server.ts`
- [ ] **Passo 1: Adicionar tipo local para contexto com switch**
Abaixo de `const MIME_TYPES`, adicionar:
```ts
type SwitchableExtensionContext = ExtensionContext & {
switchSession?: (
sessionPath: string,
options?: { withSession?: (ctx: any) => Promise<void> },
) => Promise<{ cancelled: boolean }>
}
```
- [ ] **Passo 2: Trocar o tipo de `latestCtx`**
Substituir:
```ts
let latestCtx: ExtensionContext | null = null
```
por:
```ts
let latestCtx: SwitchableExtensionContext | null = null
```
- [ ] **Passo 3: Criar helper de arquivo de sessão**
Adicionar depois de `getCurrentSessionId()`:
```ts
function getSessionFileForId(sessionId: string): string | null {
const sessionDir = latestCtx?.sessionManager?.getSessionDir()
if (!sessionDir || !fs.existsSync(sessionDir)) return null
const files = fs.readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'))
const matchedFile = files.find(f => f.endsWith(`_${sessionId}.jsonl`))
return matchedFile ? path.join(sessionDir, matchedFile) : null
}
```
- [ ] **Passo 4: Extrair seleção de modelo para helper reutilizável**
Adicionar depois de `getSessionFileForId()`:
```ts
async function setRequestedModel(ctx: SwitchableExtensionContext, model: any) {
if (!model?.providerID || !model?.modelID) return
try {
const targetModel = ctx.modelRegistry?.find(model.providerID, model.modelID)
if (!targetModel) return
const currentModel = ctx.model
if (!currentModel || currentModel.id !== targetModel.id || currentModel.provider !== targetModel.provider) {
console.log(`[PiAgentUi] Switching session model to ${targetModel.provider}:${targetModel.id}`)
await pi.setModel(targetModel)
}
} catch (err) {
console.error('[PiAgentUi] Failed to set model:', err)
}
}
```
- [ ] **Passo 5: Adicionar flag para preservar servidor durante switch web-iniciado**
Perto dos estados do servidor, adicionar:
```ts
let preserveServerForSessionSwitch = false
```
- [ ] **Passo 6: Criar helper de envio correto por sessão**
Adicionar depois de `setRequestedModel()`:
```ts
async function sendUserMessageToRequestedSession(parsed: any): Promise<{ status: number; body: any }> {
const requestedSessionId = parsed.sessionId
const activeSessionId = getCurrentSessionId()
if (!parsed.text) {
return { status: 400, body: { error: 'Missing message text' } }
}
if (!requestedSessionId || requestedSessionId === activeSessionId) {
if (latestCtx) {
await setRequestedModel(latestCtx, parsed.model)
}
const p = pi.sendUserMessage(parsed.text) as any
if (p && typeof p.catch === 'function') {
p.catch((err: any) => {
console.error('[PiAgentUi] Failed to send user message:', err)
})
}
return { status: 202, body: { sessionID: activeSessionId } }
}
const sessionFile = getSessionFileForId(requestedSessionId)
if (!sessionFile) {
return { status: 404, body: { error: `Session not found: ${requestedSessionId}` } }
}
if (!latestCtx?.switchSession) {
return { status: 501, body: { error: 'Pi session switching is not available in this runtime' } }
}
preserveServerForSessionSwitch = true
try {
const result = await latestCtx.switchSession(sessionFile, {
withSession: async replacedCtx => {
latestCtx = replacedCtx as SwitchableExtensionContext
await setRequestedModel(latestCtx, parsed.model)
await replacedCtx.sendUserMessage(parsed.text)
},
})
if (result.cancelled) {
return { status: 409, body: { error: 'Session switch cancelled' } }
}
return { status: 202, body: { sessionID: requestedSessionId } }
} finally {
preserveServerForSessionSwitch = false
}
}
```
- [ ] **Passo 7: Substituir lógica atual de `/api/messages/send`**
Dentro do handler `if (pathParts[1] === 'messages' && pathParts[2] === 'send' && method === 'POST')`, substituir o bloco que faz `setModel`, calcula `activeSessionId`, alerta divergência e chama `pi.sendUserMessage(parsed.text)` por:
```ts
const result = await sendUserMessageToRequestedSession(parsed)
res.writeHead(result.status, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
messageID: 'msg-' + Math.random().toString(36).substring(2) + '-' + Date.now(),
...result.body,
}),
)
```
O `catch` do `JSON.parse` permanece igual.
- [ ] **Passo 8: Preservar servidor em `session_shutdown` de switch interno**
Substituir:
```ts
pi.on('session_shutdown', async () => {
stopServer()
})
```
por:
```ts
pi.on('session_shutdown', async event => {
if (preserveServerForSessionSwitch && event?.reason === 'resume') {
return
}
stopServer()
})
```
- [ ] **Passo 9: Verificar typecheck da extensão**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx tsc --noEmit -p tsconfig.extension.json
```
Resultado esperado: nenhum erro.
---
## Tarefa 3: Testes do servidor de extensão
**Arquivos:**
- Modificar: `PiAgentUi/extensions/piagentui-server.test.ts`
- [ ] **Passo 1: Adicionar sessão alternativa no teste existente**
Depois da criação de `sessionFile`, adicionar:
```ts
const otherSessionId = 'test-session-uuid-other'
const otherSessionFile = path.join(tempSessionDir, `session_${otherSessionId}.jsonl`)
fs.writeFileSync(otherSessionFile, JSON.stringify({
type: 'session',
id: otherSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd'
}) + '\n')
```
- [ ] **Passo 2: Adicionar mocks de switch no contexto**
Antes de `const mockContext = {`, adicionar:
```ts
const replacedSendUserMessage = vi.fn()
const switchSession = vi.fn(async (_sessionPath: string, options?: { withSession?: (ctx: any) => Promise<void> }) => {
await options?.withSession?.({
...mockContext,
sessionManager: {
getSessionDir: () => tempSessionDir,
getSessionId: () => otherSessionId,
},
sendUserMessage: replacedSendUserMessage,
})
return { cancelled: false }
})
```
Dentro de `mockContext`, adicionar:
```ts
switchSession,
```
- [ ] **Passo 3: Testar envio para sessão ativa sem switch**
Manter o teste existente de `/api/messages/send` sem `sessionId` e ajustar expectativa para aceitar qualquer segundo argumento opcional:
```ts
expect(mockPi.sendUserMessage).toHaveBeenCalledWith('hello world')
expect(switchSession).not.toHaveBeenCalled()
```
- [ ] **Passo 4: Testar envio para sessão diferente com switch**
Depois do teste de envio ativo, adicionar:
```ts
const sendOtherRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: otherSessionId,
text: 'hello other session',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' }
})
})
expect(sendOtherRes.status).toBe(202)
expect(switchSession).toHaveBeenCalledWith(otherSessionFile, expect.objectContaining({
withSession: expect.any(Function),
}))
expect(replacedSendUserMessage).toHaveBeenCalledWith('hello other session')
```
- [ ] **Passo 5: Testar sessão inexistente**
Adicionar:
```ts
const missingSessionRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: 'missing-session-id',
text: 'must not be sent',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' }
})
})
expect(missingSessionRes.status).toBe(404)
expect(replacedSendUserMessage).not.toHaveBeenCalledWith('must not be sent')
```
- [ ] **Passo 6: Rodar teste da extensão**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run extensions/piagentui-server.test.ts
```
Resultado esperado: teste passa. Se o teste monolítico ficar instável por porta/servidor, dividir em `describe` separado com porta limpa e `session_shutdown` no final.
---
## Tarefa 4: Garantir que erros de envio sejam visíveis no frontend
**Arquivos:**
- Modificar: `PiAgentUi/src/api/message.ts`
- Testar: `PiAgentUi/src/api/piClient.test.ts` ou criar caso em `PiAgentUi/src/api/message.test.ts`
- [ ] **Passo 1: Confirmar comportamento de `piClient.post`**
Ler `PiAgentUi/src/api/piClient.ts`. Se `piClient.post` já lança erro em status `>=400`, não alterar código. Se não lança, ajustar para lançar com status e body.
O comportamento esperado para `sendMessageAsync()` é:
```ts
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
await piClient.post('/api/messages/send', {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
}
```
- [ ] **Passo 2: Adicionar teste de erro HTTP se necessário**
Se `piClient` não tiver cobertura de erro, adicionar teste que simula `fetch` retornando `404` para `/api/messages/send` e espera rejeição.
Código esperado do teste:
```ts
it('rejects sendMessageAsync when the server rejects the target session', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})))
await expect(sendMessageAsync({
sessionId: 'missing-session',
text: 'hello',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
directory: '/mock/cwd',
})).rejects.toThrow()
})
```
- [ ] **Passo 3: Rodar testes de API relevantes**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run src/api/piClient.test.ts src/api/events.test.ts src/store/messageStore.test.ts
```
Resultado esperado: todos passam.
---
## Tarefa 5: Verificação integrada
**Arquivos:**
- Nenhum arquivo novo obrigatório.
- [ ] **Passo 1: Rodar typecheck completo do app**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx tsc --noEmit
npx tsc --noEmit -p tsconfig.extension.json
```
Resultado esperado: ambos sem erro.
- [ ] **Passo 2: Rodar testes focados**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run extensions/piagentui-server.test.ts src/api/events.test.ts src/store/messageStore.test.ts
```
Resultado esperado: todos passam.
- [ ] **Passo 3: Rodar build**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npm run build
```
Resultado esperado: build passa. Avisos de chunk grande do Vite são aceitáveis se não forem novos erros.
- [ ] **Passo 4: Verificação manual no navegador**
1. Reiniciar o Pi Agent para carregar a extensão atualizada.
2. Abrir PiAgentUI em `http://127.0.0.1:<porta>`.
3. Abrir sessão A e anotar o arquivo `.jsonl` dela.
4. Selecionar sessão B no sidebar.
5. Enviar mensagem `teste roteamento sessao B`.
6. Confirmar visualmente que user + assistant aparecem em B sem F5.
7. Confirmar que o arquivo `.jsonl` da sessão A não recebeu essa mensagem.
8. Confirmar que o arquivo `.jsonl` da sessão B recebeu essa mensagem.
9. Confirmar que `/global/event` continua com uma conexão viva e sem reconectar em loop.
- [ ] **Passo 5: Commit final**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
git status --short
git add extensions/piagentui-server.ts extensions/piagentui-server.test.ts src/api/message.ts src/api/message.test.ts docs/superpowers/plans/2026-05-30-piagentui-active-session-switch.md
git commit -m "fix: route messages to selected Pi session"
```
Se a implementação modificar arquivos em `../pi`, fazer um commit separado dentro do repositório/árvore correspondente apenas se ele também for um repositório Git local. Como `D:/Documents/Github/PiAgentUI` não é repo e `PiAgentUi` é o repo local atual, registrar explicitamente os arquivos de Pi alterados no resumo final.
---
## Auto-revisão do plano
- Cobertura da spec: o plano cobre exposição de `switchSession`, roteamento backend, preservação do servidor, testes de erro e verificação manual.
- Placeholders: nenhum `TBD`, `TODO`, `implementar depois` ou instrução genérica sem ação concreta.
- Consistência de tipos: `SwitchableExtensionContext`, `switchSession`, `withSession`, `replacedCtx.sendUserMessage` e `sessionID` são usados consistentemente.
- Escopo: limitado ao sistema de sessão/roteamento; não redesenha UI nem lista de sessões.