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
17 KiB
Plano de Implementação — Troca Correta de Sessões no PiAgentUI
Para agentes executores: SUB-SKILL OBRIGATÓRIA: use
superpowers:subagent-driven-development(recomendado) ousuperpowers:executing-planspara 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()emExtensionContext.
- Adicionar
- Modificar:
../pi/packages/coding-agent/src/core/extensions/runner.ts- Expor
switchSessiontambém emcreateContext()usando o handler já existente.
- Expor
- 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_shutdowncausado porresumeweb-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á enviasessionId.
- Garantir que erro HTTP de envio não seja engolido;
- Modificar opcionalmente:
PiAgentUi/src/hooks/useChatSession.ts- Se necessário, manter erro visível via
handleError('send message', error)sem mudar UX.
- Se necessário, manter erro visível via
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;:
/**
* 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: () => {:
switchSession: (sessionPath, options) => {
runner.assertActive();
return runner.switchSessionHandler(sessionPath, options);
},
O trecho final deve ficar assim:
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:
/**
* 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:
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:
type SwitchableExtensionContext = ExtensionContext & {
switchSession?: (
sessionPath: string,
options?: { withSession?: (ctx: any) => Promise<void> },
) => Promise<{ cancelled: boolean }>
}
- Passo 2: Trocar o tipo de
latestCtx
Substituir:
let latestCtx: ExtensionContext | null = null
por:
let latestCtx: SwitchableExtensionContext | null = null
- Passo 3: Criar helper de arquivo de sessão
Adicionar depois de getCurrentSessionId():
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():
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:
let preserveServerForSessionSwitch = false
- Passo 6: Criar helper de envio correto por sessão
Adicionar depois de setRequestedModel():
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:
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_shutdownde switch interno
Substituir:
pi.on('session_shutdown', async () => {
stopServer()
})
por:
pi.on('session_shutdown', async event => {
if (preserveServerForSessionSwitch && event?.reason === 'resume') {
return
}
stopServer()
})
- Passo 9: Verificar typecheck da extensão
Executar:
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:
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:
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:
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:
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:
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:
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:
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.tsou criar caso emPiAgentUi/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() é:
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:
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:
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
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
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
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
- Reiniciar o Pi Agent para carregar a extensão atualizada.
- Abrir PiAgentUI em
http://127.0.0.1:<porta>. - Abrir sessão A e anotar o arquivo
.jsonldela. - Selecionar sessão B no sidebar.
- Enviar mensagem
teste roteamento sessao B. - Confirmar visualmente que user + assistant aparecem em B sem F5.
- Confirmar que o arquivo
.jsonlda sessão A não recebeu essa mensagem. - Confirmar que o arquivo
.jsonlda sessão B recebeu essa mensagem. - Confirmar que
/global/eventcontinua com uma conexão viva e sem reconectar em loop.
- Passo 5: Commit final
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 depoisou instrução genérica sem ação concreta. - Consistência de tipos:
SwitchableExtensionContext,switchSession,withSession,replacedCtx.sendUserMessageesessionIDsão usados consistentemente. - Escopo: limitado ao sistema de sessão/roteamento; não redesenha UI nem lista de sessões.