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

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) 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;:

	/**
	 * 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_shutdown de 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.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() é:

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
  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
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.