diff --git a/.gitignore b/.gitignore index 1ab099e26..7c12eb736 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ cli/npm/main/ test-results/ playwright-report/ e2e-output/ +.xyz-harness +.agents/ +.pi/ diff --git a/bun.lock b/bun.lock index 8400dfe30..7a40e4390 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-o4O/q+vvVrOt4kLy2uBcR/ubQChQeDvq1TtybGkyPq9u1Y4LZkBbM36++TBzAXXaCNn86hQDOUjZs9seXoi18A=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/package.json b/cli/package.json index ebfd92278..0cfc66022 100644 --- a/cli/package.json +++ b/cli/package.json @@ -83,4 +83,4 @@ "@types/parse-path": "7.0.3" }, "packageManager": "bun@1.3.14" -} +} \ No newline at end of file diff --git a/cli/src/agent/localHandoff.test.ts b/cli/src/agent/localHandoff.test.ts index 1405a9441..d1c1f4be1 100644 --- a/cli/src/agent/localHandoff.test.ts +++ b/cli/src/agent/localHandoff.test.ts @@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => { const lifecycle = { setArchiveReason: vi.fn(), setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false), cleanupAndExit: vi.fn(async () => {}) } diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts new file mode 100644 index 000000000..ec9c3d99c --- /dev/null +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRunnerLifecycle } from './runnerLifecycle'; +import type { RunnerLifecycle } from './runnerLifecycle'; + +// Mock heavy deps +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + getLogPath: vi.fn(() => '/tmp/test.log'), + }, +})); + +vi.mock('@/ui/terminalState', () => ({ + restoreTerminalState: vi.fn(), +})); + +function createMockApiSession() { + return { + updateMetadata: vi.fn(), + sendSessionDeath: vi.fn(), + flush: vi.fn(), + close: vi.fn(), + } as unknown as Parameters[0]['session']; +} + +describe('createRunnerLifecycle', () => { + let lifecycle: RunnerLifecycle; + + beforeEach(() => { + vi.clearAllMocks(); + lifecycle = createRunnerLifecycle({ + session: createMockApiSession(), + logTag: 'test', + }); + }); + + // --- D-9: hasExplicitSessionEndReason --- + + describe('hasExplicitSessionEndReason', () => { + it('returns false initially', () => { + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('returns true after setSessionEndReason is called', () => { + lifecycle.setSessionEndReason('completed'); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + + it('returns false after markCrash — markCrash does NOT set explicit flag', () => { + lifecycle.markCrash(new Error('boom')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('stays true once set — subsequent markCrash does not clear it', () => { + lifecycle.setSessionEndReason('handoff'); + lifecycle.markCrash(new Error('late crash')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + }); + + // --- markCrash sets reason to 'error' but not explicit --- + + describe('markCrash', () => { + it('sets sessionEndReason to error via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.markCrash(new Error('fatal')); + + // cleanup triggers sendSessionDeath — verify 'error' reason + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('error'); + }); + }); + + // --- setSessionEndReason + cleanup propagates correct reason --- + + describe('setSessionEndReason + cleanup', () => { + it('sends explicit reason via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.setSessionEndReason('completed'); + + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('completed'); + }); + }); +}); diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index 0ae8faa9e..d632c17a0 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -15,6 +15,7 @@ export type RunnerLifecycle = { setExitCode: (code: number) => void setArchiveReason: (reason: string) => void setSessionEndReason: (reason: SessionEndReason) => void + hasExplicitSessionEndReason: () => boolean markCrash: (error: unknown) => void cleanup: () => Promise cleanupAndExit: (codeOverride?: number) => Promise @@ -25,6 +26,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi let exitCode = 0 let archiveReason = 'User terminated' let sessionEndReason: SessionEndReason = 'terminated' + let sessionEndReasonExplicit = false let cleanupStarted = false let cleanupPromise: Promise | null = null @@ -95,8 +97,11 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi const setSessionEndReason = (reason: SessionEndReason) => { sessionEndReason = reason + sessionEndReasonExplicit = true } + const hasExplicitSessionEndReason = () => sessionEndReasonExplicit + const markCrash = (error: unknown) => { logger.debug(`${logPrefix} Unhandled error:`, error) exitCode = 1 @@ -128,6 +133,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi setExitCode, setArchiveReason, setSessionEndReason, + hasExplicitSessionEndReason, markCrash, cleanup, cleanupAndExit, diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index c8e72e795..6795e5e74 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -31,10 +31,22 @@ export function resolveSessionConfigPermissionMode 0) { + return modelObj.modelId.trim() + } + throw new Error('Invalid model') + } if (typeof value !== 'string' || value.trim().length === 0) { throw new Error('Invalid model') } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c6e264312..fcc262964 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -100,9 +100,15 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId + if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree + // Preserve cached Pi model list so the web can show models immediately + // on inactive-session view without waiting for an RPC round-trip. + if (metadata.piAvailableModels !== undefined) preserved.piAvailableModels = metadata.piAvailableModels + // Preserve provider-qualified Pi model selection (disambiguates duplicate modelIds). + if (metadata.piSelectedModel !== undefined) preserved.piSelectedModel = metadata.piSelectedModel return preserved } diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 956039411..5af3186ce 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -135,7 +135,9 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { availableModels: [{ modelId: 'x/y' }], currentModelId: 'x/y' }) - expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot) + // The handler realpaths the cwd (security: prevents symlink escape), + // so on macOS /var/folders/... resolves to /private/var/folders/... + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot)) } finally { rmSync(secondWorkspaceRoot, { recursive: true, force: true }) client.shutdown() diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index f0f8510fa..0aa2e72bf 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; import type { CollaborationModeListResponse, @@ -69,10 +70,9 @@ function createAbortError(): Error { return error; } -export class CodexAppServerClient { +export class CodexAppServerClient extends JsonLineParser { private process: ChildProcessWithoutNullStreams | null = null; private connected = false; - private buffer = ''; private nextId = 1; private readonly pending = new Map(); private readonly requestHandlers = new Map(); @@ -103,7 +103,7 @@ export class CodexAppServerClient { }); this.process.stdout.setEncoding('utf8'); - this.process.stdout.on('data', (chunk) => this.handleStdout(chunk)); + this.process.stdout.on('data', (chunk) => this.feed(chunk)); this.process.stderr.setEncoding('utf8'); this.process.stderr.on('data', (chunk) => { @@ -354,23 +354,7 @@ export class CodexAppServerClient { this.writePayload(payload); } - private handleStdout(chunk: string): void { - this.buffer += chunk; - let newlineIndex = this.buffer.indexOf('\n'); - - while (newlineIndex >= 0) { - const line = this.buffer.slice(0, newlineIndex).trim(); - this.buffer = this.buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - this.handleLine(line); - } - - newlineIndex = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string): void { + protected handleLine(line: string): void { if (this.protocolError) { return; } @@ -482,7 +466,7 @@ export class CodexAppServerClient { } private resetParserState(): void { - this.buffer = ''; + this.reset(); this.protocolError = null; } diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 621119aef..458e2c65a 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -56,7 +56,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })) vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/commands/agentCommandOptions.test.ts b/cli/src/commands/agentCommandOptions.test.ts index 7561774f2..f70c3e4ad 100644 --- a/cli/src/commands/agentCommandOptions.test.ts +++ b/cli/src/commands/agentCommandOptions.test.ts @@ -69,3 +69,111 @@ describe('parseRemoteAgentCommandOptions', () => { expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value') }) }) + +describe('parseRemoteAgentCommandOptions — pi flavor', () => { + // Pi RPC mode has no permission switching, so the command passes an empty + // allow-list. These tests cover the non-permission flags using a non-empty + // allow-list purely as a parser fixture — the parser's behavior is + // independent of the modes' contents. + const ALLOWED = OPENCODE_PERMISSION_MODES + + it('accepts --model and stores it on options', () => { + const result = parseRemoteAgentCommandOptions( + ['--model', 'claude-sonnet-4-5'], + ALLOWED + ) + expect(result.model).toBe('claude-sonnet-4-5') + }) + + it('--session-id stores the value as resumeSessionId (Pi-specific flag)', () => { + // Pi uses --session-id for exact session resume (RPC mode), not the + // generic --resume that other flavors use. + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'pi-sess-123'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('pi-sess-123') + }) + + it('--resume is also accepted as an alias for session resume', () => { + // Some flavor paths pass --resume; the parser should accept it + // uniformly so callers do not need to branch on flavor. + const result = parseRemoteAgentCommandOptions( + ['--resume', 'sess-id'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('sess-id') + }) + + it('a later --resume overrides a prior --session-id (last-write-wins)', () => { + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'first', '--resume', 'second'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('second') + }) + + it('rejects --session-id with no value', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--session-id'], + ALLOWED + )).toThrow('Missing --session-id value') + }) + + it('parses --started-by runner', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'runner'], + ALLOWED + ) + expect(result.startedBy).toBe('runner') + }) + + it('parses --started-by terminal', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'terminal'], + ALLOWED + ) + expect(result.startedBy).toBe('terminal') + }) + + it('parses --hapi-starting-mode remote', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'remote'], + ALLOWED + ) + expect(result.startingMode).toBe('remote') + }) + + it('parses --hapi-starting-mode local', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'local'], + ALLOWED + ) + expect(result.startingMode).toBe('local') + }) + + it('rejects invalid --hapi-starting-mode', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'invalid'], + ALLOWED + )).toThrow('Invalid --hapi-starting-mode') + }) + + it('handles a full pi invocation end-to-end', () => { + const result = parseRemoteAgentCommandOptions( + [ + '--started-by', 'runner', + '--hapi-starting-mode', 'remote', + '--model', 'claude-sonnet-4-5', + '--session-id', 'pi-sess-full', + ], + ALLOWED + ) + expect(result).toEqual({ + startedBy: 'runner', + startingMode: 'remote', + model: 'claude-sonnet-4-5', + resumeSessionId: 'pi-sess-full', + }) + }) +}) diff --git a/cli/src/commands/agentCommandOptions.ts b/cli/src/commands/agentCommandOptions.ts index 0e2e271b8..f7e8b29c5 100644 --- a/cli/src/commands/agentCommandOptions.ts +++ b/cli/src/commands/agentCommandOptions.ts @@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions = startingMode?: 'local' | 'remote' permissionMode?: TPermissionMode model?: string + effort?: string modelReasoningEffort?: string resumeSessionId?: string } @@ -42,12 +43,25 @@ export function parseRemoteAgentCommandOptions { + try { + // Pi RPC mode has no runtime permission switching; pass an empty + // allow-list so --permission-mode is rejected and no permissionMode + // leaks into the session state. + const options = parseRemoteAgentCommandOptions(commandArgs, []) + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runPi } = await import('@/pi/runPi') + await runPi(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 6ff36916a..7e93ca4fc 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -9,6 +9,7 @@ import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' +import { piCommand } from './pi' import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' import { notifyCommand } from './notify' @@ -23,6 +24,7 @@ const COMMANDS: CommandDefinition[] = [ geminiCommand, kimiCommand, opencodeCommand, + piCommand, mcpCommand, hubCommand, { ...hubCommand, name: 'server' }, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index fa0f254d7..73b0de0a2 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -10,6 +10,7 @@ const { renderMock, runCodexMock, runClaudeMock, + runPiMock, assertCodexLocalSupportedMock, existsSyncMock } = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ const { renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), + runPiMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) })) @@ -44,6 +46,7 @@ vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) +vi.mock('@/pi/runPi', () => ({ runPi: runPiMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) @@ -72,6 +75,7 @@ describe('resumeCommand', () => { }) runCodexMock.mockClear() runClaudeMock.mockClear() + runPiMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) }) @@ -247,6 +251,36 @@ describe('resumeCommand', () => { } }) + it('resumes a Pi target with effort', async () => { + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-pi', + flavor: 'pi', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'pi-session-123', + model: 'deepseek-v3', + effort: 'high', + permissionMode: 'yolo' + }) + + await resumeCommand.run(createContext(['hapi-session-pi'])) + + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(runPiMock).toHaveBeenCalledWith({ + existingSessionId: 'hapi-session-pi', + workingDirectory: '/tmp/project', + resumeSessionId: 'pi-session-123', + startedBy: 'terminal', + // Pi has no local TUI input path, so resume defaults to remote control. + startingMode: 'remote', + model: 'deepseek-v3', + effort: 'high' + }) + }) + it('keeps the non-TTY fallback and asks for an explicit session id', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 353f7cab5..cf2af593f 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -145,6 +145,23 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'pi') { + const { runPi } = await import('@/pi/runPi') + await runPi({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + // Pi runs as `pi --mode rpc` with piped stdio and no local TUI input + // path, so 'local' would advertise local-control that cannot be used + // and hide/reject remote-only controls until a web switch. + startingMode: 'remote', + model: target.model ?? undefined, + effort: target.effort ?? undefined, + }) + return + } + const { runCursor } = await import('@/cursor/runCursor') await runCursor({ existingSessionId: base.existingSessionId, diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts index 3cc404c40..7a7d7bb97 100644 --- a/cli/src/commands/runCli.ts +++ b/cli/src/commands/runCli.ts @@ -1,5 +1,4 @@ import packageJson from '../../package.json' -import { ensureRuntimeAssets } from '@/runtime/assets' import { isBunCompiled } from '@/projectPath' import { logger } from '@/ui/logger' import { getCliArgs } from '@/utils/cliArgs' @@ -23,6 +22,7 @@ export async function runCli(): Promise { const { command, context } = resolveCommand(args) if (command.requiresRuntimeAssets) { + const { ensureRuntimeAssets } = await import('@/runtime/assets') await ensureRuntimeAssets() logger.debug('Starting hapi CLI with args: ', process.argv) } diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index b6ef8e58b..9b9b3be4b 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -54,7 +54,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 41ef11adc..4537ca0a0 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -58,7 +58,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/pi/loop.test.ts b/cli/src/pi/loop.test.ts new file mode 100644 index 000000000..626abcd0c --- /dev/null +++ b/cli/src/pi/loop.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import type { PiResponseEvent } from './types'; +import { PiSession } from './session'; +import { PiTransport } from './piTransport'; +import type { PiThinkingLevel } from './types'; + +// Mock logger +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +// Mock message converter chain +vi.mock('@/agent/messageConverter', () => ({ + convertAgentMessage: vi.fn((msg) => msg), +})); + +vi.mock('./PiEventConverter', () => ({ + convertPiEvent: vi.fn(() => []), +})); + +vi.mock('./piMessageAccumulator', () => { + return { + PiMessageAccumulator: class { + handleEvent = vi.fn(() => []); + }, + }; +}); + +function createMockSession(): PiSession { + return new PiSession({ + api: {} as any, + client: { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + emitMessagesConsumed: vi.fn(), + sendSessionEvent: vi.fn(), + } as any, + path: '/tmp/test', + logPath: '/tmp/test.log', + startedBy: 'terminal', + startingMode: 'local', + }); +} + +// --- parsePiModels --- + +describe('parsePiModels', () => { + it('returns empty for non-array input', () => { + expect(parsePiModels(null)).toEqual([]); + expect(parsePiModels({})).toEqual([]); + expect(parsePiModels('not array')).toEqual([]); + }); + + it('parses valid model list', () => { + const data = { + models: [ + { id: 'gpt-4o', provider: 'openai', name: 'GPT-4o', contextWindow: 128000 }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { provider: 'openai', modelId: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('parses reasoning and thinkingLevelMap', () => { + const data = { + models: [ + { + id: 'claude-sonnet-4', + provider: 'anthropic', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { id: 'gpt-4o', provider: 'openai', reasoning: false }, + { id: 'deepseek-r1', provider: 'deepseek', thinkingLevelMap: {} }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { + provider: 'anthropic', + modelId: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { provider: 'openai', modelId: 'gpt-4o', reasoning: false }, + { provider: 'deepseek', modelId: 'deepseek-r1' }, + ]); + }); + + it('ignores non-boolean reasoning and invalid thinkingLevelMap', () => { + const data = { + models: [ + { id: 'm1', reasoning: 'yes', thinkingLevelMap: 'not-an-object' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); + + it('filters out models with empty id', () => { + const data = { + models: [ + { id: '', provider: 'openai' }, + { id: 'gpt-4o', provider: 'openai' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + ]); + }); + + it('defaults unknown provider', () => { + const data = { models: [{ id: 'model-1' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'model-1' }, + ]); + }); + + it('skips non-object entries', () => { + const data = { models: [null, 'string', 42, { id: 'valid' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'valid' }, + ]); + }); + + it('ignores non-string name and non-number contextWindow', () => { + const data = { + models: [ + { id: 'm1', name: 123, contextWindow: 'big' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); +}); + +// --- parsePiCommands --- + +describe('parsePiCommands', () => { + it('returns empty for non-array input', () => { + expect(parsePiCommands(null)).toEqual([]); + expect(parsePiCommands({})).toEqual([]); + }); + + it('parses valid command list', () => { + const data = { + commands: [ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ], + }; + const result = parsePiCommands(data); + expect(result).toEqual([ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ]); + }); + + it('defaults unknown source to skill', () => { + const data = { commands: [{ name: 'cmd', source: 'unknown_source' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'cmd', source: 'skill' }, + ]); + }); + + it('filters out commands with empty name', () => { + const data = { commands: [{ name: '', source: 'skill' }, { name: 'valid', source: 'skill' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'valid', source: 'skill' }, + ]); + }); + + it('omits non-string description', () => { + const data = { commands: [{ name: 'cmd', description: 123 }] }; + expect(parsePiCommands(data)).toEqual([{ name: 'cmd', source: 'skill' }]); + }); +}); + +// --- wireTransportEvents (integration) --- + +describe('wireTransportEvents', () => { + let session: PiSession; + let eventHandlers: Map void>; + + function createMockTransport(): PiTransport { + eventHandlers = new Map(); + return { + onEvent: vi.fn((handler) => { eventHandlers.set('event', handler); }), + send: vi.fn(), + } as unknown as PiTransport; + } + + beforeEach(() => { + vi.clearAllMocks(); + session = createMockSession(); + }); + + function emitEvent(event: Record): void { + const handler = eventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!(event); + } + + it('handles get_state response — updates model, provider, thinkingLevel', () => { + const transport = createMockTransport(); + const pendingLocalIds: string[] = []; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ + type: 'response', + command: 'get_state', + success: true, + data: { + model: { modelId: 'gpt-4o', provider: 'openai' }, + sessionId: 'pi-session-1', + thinkingLevel: 'high', + steeringMode: 'one-at-a-time', + }, + }); + + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + expect(session.currentThinkingLevel).toBe('high'); + expect(session.currentSteeringMode).toBe('one-at-a-time'); + expect(session.client.updateMetadata).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('handles error response — sends session event', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'prompt', + success: false, + error: 'Pi crashed', + }); + + expect(session.client.sendSessionEvent).toHaveBeenCalledWith({ + type: 'message', + message: 'Pi crashed', + }); + }); + + it('handles agent_start — sets thinking state, does NOT drain pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-1', 'id-2']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + + // agent_start precedes turn_start in a real Pi turn; draining here + // would double-pop the FIFO (see regression test below). + expect(pendingLocalIds).toEqual(['id-1', 'id-2']); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalled(); + }); + + it('handles turn_start — pops pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-turn-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['id-turn-1'], undefined); + }); + + it('regression: agent_start + turn_start in one turn drains exactly one localId', () => { + // Pi emits agent_start then turn_start back-to-back per prompt. + // Only turn_start should drain — agent_start must not. + const transport = createMockTransport(); + const pendingLocalIds = ['prompt-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + // Exactly one drain call with a real id — never an undefined. + expect(session.client.emitMessagesConsumed).toHaveBeenCalledTimes(1); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['prompt-1'], undefined); + }); + + it('handles turn_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'turn_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles agent_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'agent_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles get_available_models response — caches models', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_available_models', + success: true, + data: { + models: [ + { id: 'gpt-4o', provider: 'openai' }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }, + }); + + expect(session.cachedPiModels).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('handles get_commands response — caches commands', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_commands', + success: true, + data: { + commands: [ + { name: 'analyze', source: 'skill' }, + ], + }, + }); + + expect(session.cachedPiCommands).toEqual([ + { name: 'analyze', source: 'skill' }, + ]); + }); + + it('handles keep_alive — no side effects', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = false; + emitEvent({ type: 'keep_alive' }); + + // keep_alive should not trigger any session mutations + expect(session.client.sendAgentMessage).not.toHaveBeenCalled(); + expect(session.piIsStreaming).toBe(false); + }); + + it('handles set_model response — updates model and provider', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'set_model', + success: true, + data: { modelId: 'new-model', provider: 'new-provider' }, + }); + + expect(session.currentModel).toBe('new-model'); + expect(session.currentProvider).toBe('new-provider'); + }); +}); + +// --- sendPiRpcAndWait (contract: await <-> resolve symmetry) --- +// +// SetSessionConfig awaits set_model and set_thinking_level. Fix #9 was caused +// by a switch branch that updated state but never resolved the pending RPC - +// the promise hit the 10s timeout and /sessions/:id/model returned 409 even +// though Pi accepted the change. These tests pin the contract: every awaited +// command must resolve before the timeout when Pi emits a success response. + +describe('sendPiRpcAndWait', () => { + it('throws synchronously when resolver not initialized', () => { + // sendPiRpcAndWait is a sync wrapper (not async), so the guard at + // loop.ts throws before a promise is created — assert with toThrow, + // not rejects. + const mockTransport = { send: vi.fn(), onEvent: vi.fn() } as unknown as PiTransport; + const session = createMockSession(); + // No wireTransportEvents -> resolver is null + expect(() => sendPiRpcAndWait(session, mockTransport, { type: 'test' }, 100)) + .toThrow('Pi RPC resolver not initialized'); + }); + + // Helper: a transport whose send() captures the outgoing id so the test can + // emit the matching response, simulating Pi's reply. + function recordingTransport(onEventHandlers: Map void>) { + const sent: Array> = []; + return { + transport: { + onEvent: vi.fn((handler) => { onEventHandlers.set('event', handler); }), + send: vi.fn((msg: Record) => { sent.push(msg); }), + } as unknown as PiTransport, + sent, + // Emit the Pi response for the last sent command, echoing its id. + reply(response: { command: string; success: boolean; data?: unknown; error?: string }) { + const last = sent[sent.length - 1]; + const handler = onEventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!({ type: 'response', id: last.id, ...response }); + }, + }; + } + + it('set_model response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'openai', modelId: 'gpt-4o', + }, 10_000); + + // Simulate Pi confirming the model change. + reply({ command: 'set_model', success: true, data: { modelId: 'gpt-4o', provider: 'openai' } }); + + // Must resolve (not reject with 'timed out') - the contract Fix #9 restored. + await expect(promise).resolves.toEqual({ modelId: 'gpt-4o', provider: 'openai' }); + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + }); + + it('set_thinking_level response resolves the awaited promise before timeout', async () => { + // Fix #9 symmetry: set_thinking_level is awaited by SetSessionConfig. + // Without an explicit resolve it fell to the `default` branch; if anyone + // later adds business logic to a new case without resolving first, the + // effort switch would time out and /sessions/:id/effort would 409. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_thinking_level', level: 'high', + }, 10_000); + + reply({ command: 'set_thinking_level', success: true }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('get_available_models response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { type: 'get_available_models' }, 10_000); + + reply({ command: 'get_available_models', success: true, data: { models: [{ id: 'gpt-4o', provider: 'openai' }] } }); + + await expect(promise).resolves.toEqual({ models: [{ id: 'gpt-4o', provider: 'openai' }] }); + }); + + it('Pi error response rejects the awaited promise', async () => { + // SetSessionConfig awaits so a rejected set_model bubbles up to the web + // request (409) instead of reporting success while Pi kept old state. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'bad', modelId: 'nope', + }, 10_000); + + reply({ command: 'set_model', success: false, error: 'Unknown provider: bad' }); + + await expect(promise).rejects.toThrow('Unknown provider: bad'); + }); + + it('rejects with timeout when Pi never responds', async () => { + const handlers = new Map void>(); + const { transport } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + // No reply emitted -> must time out (guards against hangs). + await expect(sendPiRpcAndWait(session, transport, { type: 'test' }, 100)) + .rejects.toThrow('timed out'); + }); +}); diff --git a/cli/src/pi/loop.ts b/cli/src/pi/loop.ts new file mode 100644 index 000000000..d4f48256f --- /dev/null +++ b/cli/src/pi/loop.ts @@ -0,0 +1,312 @@ +import { logger } from '@/ui/logger'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { PiTransport } from './piTransport'; +import { convertPiEvent } from './piEventConverter'; +import { PiMessageAccumulator } from './piMessageAccumulator'; +import { parsePiModels, parsePiCommands, PiResponseEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas'; +import type { PiResponseEvent, PiRpcCommand, PiThinkingLevel } from './types'; +import type { PiSession } from './session'; + +// --- Response parsers: re-exported from schemas.ts --- +export { parsePiModels, parsePiCommands } from './schemas'; + +// --- Pending RPC resolver --- +// Instance-scoped: created once by wireTransportEvents, stored on PiSession. +export class PiRpcResolver { + private idCounter = 0; + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + + sendAndWait(transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + const id = ++this.idCounter; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${command.type} (id=${id}) timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (data) => { clearTimeout(timer); this.pending.delete(id); resolve(data); }, + reject: (error) => { clearTimeout(timer); this.pending.delete(id); reject(error); }, + }); + + transport.send({ ...command, id: String(id) } as unknown as PiRpcCommand); + }); + } + + resolveResponse(raw: unknown): void { + const parsed = PiResponseEventSchema.safeParse(raw); + if (!parsed.success) return; + const response = parsed.data; + const rawId = response.id; + if (rawId !== undefined) { + const numericId = Number(rawId); + if (!Number.isNaN(numericId)) { + const resolver = this.pending.get(numericId); + if (resolver) { + if (response.success) { + resolver.resolve(response.data); + } else { + resolver.reject(new Error(response.error ?? 'Unknown error')); + } + } + } + } + } +} + +export function sendPiRpcAndWait(session: PiSession, transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + if (!session.rpcResolver) throw new Error('Pi RPC resolver not initialized'); + return session.rpcResolver.sendAndWait(transport, command, timeoutMs); +} + +function resolvePendingRpc(resolver: PiRpcResolver, response: PiResponseEvent): void { + resolver.resolveResponse(response); +} + +// Mirror the web picker's provider-qualified selection into metadata so the hub +// and web can disambiguate duplicate modelId values across providers. The web +// /sessions/:id/model path already writes piSelectedModel via persistPiSelectedModel; +// these runtime paths (get_state, startup set_model, successful set_model response) +// previously only keepAlive'd the bare modelId, so a Pi session on Pi's default model +// or started with --model could render/filter against the wrong provider. +function persistSelectedPiModel(session: PiSession): void { + const modelId = session.currentModel; + const provider = session.currentProvider; + if (!modelId || !provider) return; + session.updateMetadata((meta) => ({ + ...meta, + piSelectedModel: { provider, modelId }, + })); +} + +// --- Response handler --- + +function handleGetState( + rawData: unknown, + session: PiSession, +): void { + const parsed = PiStateDataSchema.safeParse(rawData); + if (!parsed.success) return; + const data = parsed.data; + + if (data.model) { + // Pi returns model.id (not modelId). Fallback to modelId for forward compat. + const newModel = data.model.id ?? data.model.modelId ?? session.currentModel; + if (data.model.provider && data.model.provider.length > 0) { + session.currentProvider = data.model.provider; + } + // Do NOT overwrite currentModel with the unconfirmed startup model here. + // The requested startup model is applied (and committed) only after + // get_available_models confirms it exists and Pi accepts set_model; + // reporting Pi's actual current model until then keeps the hub in sync + // if the requested model is unavailable or rejected. + session.currentModel = newModel ?? session.currentModel; + if (session.initialModel) { + logger.debug(`[pi] Startup model requested: ${session.initialModel} (will apply once available models arrive); Pi default model: ${newModel ?? 'unknown'}`); + } else if (newModel) { + logger.debug(`[pi] Initial model: ${newModel} (provider=${session.currentProvider ?? 'unknown'})`); + } + // Pi reported its actual model+provider; persist the provider-qualified + // selection so the web can disambiguate (a startup --model overrides this + // once get_available_models confirms and applies it below). + persistSelectedPiModel(session); + } + + if (data.sessionId) { + session.updateMetadata((meta) => ({ ...meta, piSessionId: data.sessionId })); + logger.debug(`[pi] Session ID persisted to metadata: ${data.sessionId}`); + } + + if (data.thinkingLevel) { + session.currentThinkingLevel = data.thinkingLevel as PiThinkingLevel; + logger.debug(`[pi] Initial thinking level: ${data.thinkingLevel}`); + } + + if (data.steeringMode) { + session.currentSteeringMode = data.steeringMode; + } +} + +function handleResponse( + response: PiResponseEvent, + session: PiSession, + pendingLocalIds: string[], + transport?: PiTransport, +): void { + const { command, success } = response; + const resolver = session.rpcResolver!; + + if (!success) { + const error = response.error ?? 'Unknown Pi error'; + logger.debug(`[pi] RPC error for ${command}: ${error}`); + resolvePendingRpc(resolver, response); + session.sendSessionEvent({ type: 'message', message: error }); + if (command === 'prompt' && pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true }); + } + return; + } + + switch (command) { + case 'get_state': { + handleGetState(response.data, session); + break; + } + case 'set_model': { + const parsed = PiSetModelDataSchema.safeParse(response.data); + if (parsed.success) { + const data = parsed.data; + const modelId = data.id ?? data.modelId; + if (modelId) { + session.currentModel = modelId; + } + if (data.provider && data.provider.length > 0) { + session.currentProvider = data.provider; + } + persistSelectedPiModel(session); + logger.debug(`[pi] Model changed to: ${modelId ?? session.currentModel}`); + } + // set_model is awaited by SetSessionConfig (Fix #9); without this + // the awaited RPC would time out and /sessions/:id/model return 409. + resolvePendingRpc(resolver, response); + break; + } + case 'set_thinking_level': { + // Awaited by SetSessionConfig (Fix #9 symmetry with set_model). + // currentThinkingLevel is maintained by the SetSessionConfig + // handler, so this branch only resolves the pending RPC — without + // it the awaited call times out and /sessions/:id/effort returns 409. + resolvePendingRpc(resolver, response); + break; + } + case 'get_available_models': { + const models = parsePiModels(response.data); + if (models.length > 0) { + session.cachedPiModels = models; + logger.debug(`[pi] Available models: ${models.map((m) => m.modelId).join(', ')}`); + session.updateMetadata((meta) => ({ + ...meta, + piAvailableModels: models, + })); + + // Apply the requested startup model only after confirming it exists + // in Pi's available models and Pi accepts set_model. Commit + // currentModel/currentProvider only on success so the hub does not + // persist a model Pi rejected or never had. Fire-and-forget the + // await so resolving the get_available_models RPC itself is not + // blocked (it may be awaited by ListPiModels). + if (session.initialModel && transport) { + const match = models.find((m) => m.modelId === session.initialModel); + if (match) { + void (async () => { + try { + await sendPiRpcAndWait(session, transport, { + type: 'set_model', + provider: match.provider, + modelId: match.modelId, + }); + session.currentModel = match.modelId; + session.currentProvider = match.provider; + persistSelectedPiModel(session); + logger.debug(`[pi] Startup model applied: ${match.provider}/${match.modelId}`); + } catch (error) { + logger.debug(`[pi] Startup model set_model rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } else { + logger.debug(`[pi] Startup model not found in available models: ${session.initialModel}`); + } + } + } + resolvePendingRpc(resolver, response); + break; + } + case 'get_commands': { + const commands = parsePiCommands(response.data); + if (commands.length > 0) { + session.cachedPiCommands = commands; + logger.debug(`[pi] Available commands: ${commands.map((c) => c.name).join(', ')}`); + } + resolvePendingRpc(resolver, response); + break; + } + case 'new_session': + logger.debug('[pi] Pi session initialized'); + break; + case 'abort': + logger.debug('[pi] Abort confirmed'); + break; + case 'prompt': + logger.debug('[pi] Prompt accepted'); + break; + default: + logger.debug(`[pi] Response for ${command}`); + resolvePendingRpc(resolver, response); + break; + } +} + +// --- Wire transport events to session --- + +export function wireTransportEvents( + transport: PiTransport, + session: PiSession, + pendingLocalIds: string[], +): void { + session.rpcResolver = new PiRpcResolver(); + const assistantMessageAccumulator = new PiMessageAccumulator(); + + transport.onEvent((event) => { + // Debug: log all event types to diagnose missing Pi output + if (event.type !== 'keep_alive') { + logger.debug(`[pi][event] ${event.type}`); + } + if (event.type === 'response') { + handleResponse(event as unknown as PiResponseEvent, session, pendingLocalIds, transport); + return; + } + + // Accumulate text/thinking deltas into snapshots, flush on message_end + const accumulated = assistantMessageAccumulator.handleEvent(event); + if (accumulated.length > 0) { + for (const msg of accumulated) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // message_start/update/end handled by accumulator — skip converter + if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') { + const messages = convertPiEvent(event); + for (const msg of messages) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // Keep-alive + streaming state tracking + // + // Pi emits agent_start and turn_start back-to-back for each prompt. + // Only turn_start marks "my prompt was accepted and a turn began", so + // the pending localId is drained there. Draining on both would pop the + // FIFO twice per prompt — once with the real id, then once with + // undefined — and ship a garbage localId to the hub. + if (event.type === 'agent_start') { + session.updateThinkingState(true); + } else if (event.type === 'turn_start') { + session.updateThinkingState(true); + if (pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId]); + } + } else if (event.type === 'turn_end') { + session.updateThinkingState(false); + } else if (event.type === 'agent_end') { + session.piIsStreaming = false; + } + }); +} diff --git a/cli/src/pi/piEventConverter.test.ts b/cli/src/pi/piEventConverter.test.ts new file mode 100644 index 000000000..aaa0a5d08 --- /dev/null +++ b/cli/src/pi/piEventConverter.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { convertPiEvent } from './piEventConverter'; +import type { PiAgentEvent } from './types'; + +describe('convertPiEvent', () => { + it('should return empty for message_update with text_delta (accumulated in runPi)', () => { + // The converter intentionally emits nothing for message_update + // — runPi accumulates text/thinking deltas and flushes a single + // snapshot on `message_end`. This avoids the web UI rendering + // every delta as a separate block (character-by-character column) + // and the reducer's per-content streamId dedup showing only the + // last delta as the whole reasoning. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello world' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with thinking_delta (accumulated in runPi)', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me think...' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with start sub-type', () => { + // text_start/thinking_start carry the full partial state and + // would cause the web UI to render the same text multiple + // times. The accumulator only listens to deltas. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with start sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with done sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'done', reason: 'stop' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update without assistantMessageEvent', () => { + const result = convertPiEvent({ type: 'message_update' }); + expect(result).toEqual([]); + }); + + it('should convert tool_execution_start to tool_call AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_start', + toolCallId: 'tc-1', + toolName: 'read_file', + args: { path: '/foo.ts' } + }); + expect(result).toEqual([{ + type: 'tool_call', + id: 'tc-1', + name: 'read_file', + input: { path: '/foo.ts' }, + status: 'in_progress' + }]); + }); + + it('should convert tool_execution_end (success) to tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file content', + isError: false + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file content', + status: 'completed' + }]); + }); + + it('should convert tool_execution_end (error) to failed tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file not found', + isError: true + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file not found', + status: 'failed' + }]); + }); + + it('should handle tool_execution_end with missing result', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + isError: false + } as any); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: undefined, + status: 'completed' + }]); + }); + + it('should handle tool_execution_end with missing toolCallId', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolName: 'read_file', + result: 'ok', + isError: false + } as any); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_result'); + expect((result[0] as any).id).toBeUndefined(); + }); + + it('should convert turn_end to usage + turn_complete (2 messages)', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { + input: 100, + output: 200, + cacheRead: 10, + cacheWrite: 5, + totalTokens: 315 + }, + stopReason: 'stop' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'usage', + inputTokens: 100, + outputTokens: 200, + totalTokens: 315, + cacheReadTokens: 10 + }); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should convert turn_end with toolUse stopReason', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { input: 50, output: 100, cacheRead: 0, cacheWrite: 0, totalTokens: 150 }, + stopReason: 'toolUse' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'toolUse' + }); + }); + + it('should convert turn_end without usage data', () => { + const result = convertPiEvent({ + type: 'turn_end' + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should return empty array for agent_start', () => { + expect(convertPiEvent({ type: 'agent_start' })).toEqual([]); + }); + + it('should return empty array for agent_end', () => { + expect(convertPiEvent({ type: 'agent_end', messages: [] })).toEqual([]); + }); + + it('should return empty array for response events', () => { + // Response events use a different type, but we handle gracefully + expect(convertPiEvent({ type: 'response', command: 'prompt', success: true } as unknown as PiAgentEvent)).toEqual([]); + }); + + it('should return empty array for turn_start', () => { + expect(convertPiEvent({ type: 'turn_start' })).toEqual([]); + }); + + it('should return empty array for unknown event types', () => { + expect(convertPiEvent({ type: 'something_else' })).toEqual([]); + }); + + it('should not crash on unexpected data structure (safety net)', () => { + // Simulate a malformed event that somehow passes through + const weird = Object.create(null); + weird.type = 'message_update'; + weird.assistantMessageEvent = undefined; + // Should not throw + expect(() => convertPiEvent(weird as unknown as PiAgentEvent)).not.toThrow(); + expect(convertPiEvent(weird as unknown as PiAgentEvent)).toEqual([]); + }); +}); diff --git a/cli/src/pi/piEventConverter.ts b/cli/src/pi/piEventConverter.ts new file mode 100644 index 000000000..360b83a9b --- /dev/null +++ b/cli/src/pi/piEventConverter.ts @@ -0,0 +1,88 @@ +import { logger } from '@/ui/logger'; +import type { AgentMessage } from '@/agent/types'; +import type { + PiAgentEvent, + PiToolExecutionStartEvent, + PiToolExecutionEndEvent, + PiTurnEndEvent +} from './types'; + +/** + * Converts Pi AgentEvent to HAPI AgentMessage array. + * + * Pi events come from `pi --mode rpc` stdout as JSONL. + * Not all Pi events map to HAPI AgentMessages — response/ack events + * are handled directly by the runner, not by this converter. + */ +export function convertPiEvent(event: PiAgentEvent): AgentMessage[] { + try { + switch (event.type) { + case 'tool_execution_start': { + const e = event as PiToolExecutionStartEvent; + return [{ + type: 'tool_call', + id: e.toolCallId, + name: e.toolName, + input: e.args, + status: 'in_progress' + }]; + } + + case 'tool_execution_end': { + const e = event as PiToolExecutionEndEvent; + return [{ + type: 'tool_result', + id: e.toolCallId, + output: e.result, + status: e.isError ? 'failed' : 'completed' + }]; + } + + case 'turn_end': { + const e = event as PiTurnEndEvent; + const messages: AgentMessage[] = []; + const usage = e.message?.usage; + + if (usage) { + messages.push({ + type: 'usage', + inputTokens: usage.input ?? 0, + outputTokens: usage.output ?? 0, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheRead + }); + } + + messages.push({ + type: 'turn_complete', + stopReason: e.message?.stopReason ?? 'stop' + }); + + return messages; + } + + // Lifecycle and other events — not converted to AgentMessage. + // message_start/update/end are handled by PiMessageAccumulator + // in loop.ts before this converter is called — they never reach here, + // but are listed for exhaustive matching. + case 'agent_start': + case 'agent_end': + case 'turn_start': + case 'message_start': + case 'message_update': + case 'message_end': + case 'tool_execution_update': + case 'extension_ui_request': + case 'keep_alive': + case 'response': + return []; + + default: + logger.debug(`[pi] Unknown event type: ${event.type}`); + return []; + } + } catch (err) { + logger.debug(`[pi] convertPiEvent failed for type=${event.type}: ${err}`); + return []; + } +} diff --git a/cli/src/pi/piMessageAccumulator.test.ts b/cli/src/pi/piMessageAccumulator.test.ts new file mode 100644 index 000000000..1631599af --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { PiMessageAccumulator } from './piMessageAccumulator'; + +describe('PiMessageAccumulator', () => { + function makeEvent(type: string, extra: Record = {}): any { + return { type, ...extra }; + } + + it('returns empty for events that are not handled', () => { + const acc = new PiMessageAccumulator(); + expect(acc.handleEvent(makeEvent('agent_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_end'))).toEqual([]); + expect(acc.handleEvent(makeEvent('agent_end'))).toEqual([]); + }); + + it('accumulates text deltas and flushes one text message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'hello ' } + }))).toEqual([]); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'world' } + }))).toEqual([]); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'hello world' } + ]); + }); + + it('accumulates thinking deltas and flushes one reasoning message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me ' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'think...' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'let me think...', id: 'pi-stream' } + ]); + }); + + it('flushes both reasoning and text in order on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'reply' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'thinking', id: 'pi-stream' }, + { type: 'text', text: 'reply' } + ]); + }); + + it('skips empty content on flush', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'only text' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'only text' } + ]); + }); + + it('drops empty/missing deltas silently', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: '' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: ' ' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: ' ', id: 'pi-stream' } + ]); + }); + + it('uses contentIndex as streamId when provided', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'x', contentIndex: 2 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'x' } + ]); + }); + + it('updates streamId from later deltas', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'a' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'b', contentIndex: 7 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'ab' } + ]); + }); + + it('resets state on the next message_start', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'first' } + })); + acc.handleEvent(makeEvent('message_end', { message: {} })); + + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'second' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'second' } + ]); + }); + + it('flushes on turn_end as a safety net (no message_end received)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'incomplete' } + })); + // No message_end — older Pi builds, partial streams, etc. + const flushed = acc.handleEvent(makeEvent('turn_end', { + message: { usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3 } } + })); + expect(flushed).toEqual([ + { type: 'text', text: 'incomplete' } + ]); + }); + + it('does not flush on turn_end if no message_start was seen', () => { + const acc = new PiMessageAccumulator(); + const flushed = acc.handleEvent(makeEvent('turn_end', { message: {} })); + expect(flushed).toEqual([]); + }); + + it('does not flush twice on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'once' } + })); + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([ + { type: 'text', text: 'once' } + ]); + // Second message_end with no content buffered — must be empty, + // not a duplicate. + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([]); + }); + + it('ignores text_start / thinking_start / text_end / thinking_end (full snapshots cause duplicates)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'real content' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'real content' } + ]); + }); + + it('handles message_update without assistantMessageEvent', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(() => acc.handleEvent(makeEvent('message_update'))).not.toThrow(); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([]); + }); +}); diff --git a/cli/src/pi/piMessageAccumulator.ts b/cli/src/pi/piMessageAccumulator.ts new file mode 100644 index 000000000..c8ffc1898 --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.ts @@ -0,0 +1,96 @@ +import type { AgentMessage } from '@/agent/types' +import type { PiAgentEvent, PiAssistantMessageEvent } from './types' +import { PiAssistantMessageEventSchema } from './schemas' + +/** + * Accumulates Pi assistant-message text/thinking deltas into a single + * snapshot, flushed on `message_end` (with a `turn_end` safety net). + * + * Without this, every delta would become a separate hub message, and + * the web's reducer would render the last delta as the whole reasoning + * block (the per-message content-array dedup by streamId would only + * see one snapshot) while stacking every text delta as a new agent-text + * block, producing a character-by-character column. + * + * Mirrors codex's `ReasoningProcessor`: accumulate deltas locally, + * emit one reasoning + one text message per assistant message. + */ +export class PiMessageAccumulator { + private active = false + private text = '' + private reasoning = '' + private streamId = 'pi-stream' + + /** + * Apply a Pi event to the accumulator. + * + * @returns AgentMessages to forward to the hub, if this event + * represents a flush point (`message_end` or `turn_end` with + * pending content). Returns an empty array otherwise. + */ + handleEvent(event: PiAgentEvent): AgentMessage[] { + if (event.type === 'message_start') { + this.active = true + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + return [] + } + + if (event.type === 'message_update') { + const updateEvent = event as { assistantMessageEvent?: PiAssistantMessageEvent } + const rawAme = updateEvent.assistantMessageEvent + if (!rawAme) return [] + const ameResult = PiAssistantMessageEventSchema.safeParse(rawAme) + if (!ameResult.success) return [] + const ame = ameResult.data + const streamId = ame.contentIndex?.toString() ?? 'pi-stream' + this.streamId = streamId + if (ame.type === 'text_delta' && ame.delta) { + this.text += ame.delta + } else if (ame.type === 'thinking_delta' && ame.delta) { + this.reasoning += ame.delta + } + // Other assistant message events (text_start/thinking_start/ + // text_end/thinking_end) carry the full partial state — we + // already have the deltas, so we ignore them. + return [] + } + + if (event.type === 'message_end') { + if (this.active) return this.flush() + return [] + } + + // Safety net: turn_end with pending content means the assistant + // message ended without a clean `message_end` (older Pi builds, + // partial streams, or a stream that crashed mid-flight). + if (event.type === 'turn_end' && this.active) { + return this.flush() + } + + return [] + } + + private flush(): AgentMessage[] { + const streamId = this.streamId + const reasoning = this.reasoning + const text = this.text + this.active = false + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + + const out: AgentMessage[] = [] + // Reasoning comes before text in the Pi event sequence, so emit + // in that order. Empty content is dropped so the web doesn't + // render empty bubbles. + if (reasoning) { + out.push({ type: 'reasoning', text: reasoning, id: streamId }) + } + if (text) { + out.push({ type: 'text', text }) + } + return out + } +} diff --git a/cli/src/pi/piTransport.test.ts b/cli/src/pi/piTransport.test.ts new file mode 100644 index 000000000..be9c8c0b2 --- /dev/null +++ b/cli/src/pi/piTransport.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +const mockSpawn = vi.fn(); +vi.mock('node:child_process', () => ({ + get spawn() { return mockSpawn; } +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn() + } +})); + +function createMockProcess(): ChildProcessWithoutNullStreams & EventEmitter { + const emitter = new EventEmitter() as ChildProcessWithoutNullStreams & EventEmitter; + const stdin = new EventEmitter() as any; + stdin.write = vi.fn().mockReturnValue(true); + stdin.end = vi.fn(); + const stdout = new EventEmitter() as any; + stdout.setEncoding = vi.fn(); + const stderr = new EventEmitter() as any; + stderr.setEncoding = vi.fn(); + + emitter.stdin = stdin; + emitter.stdout = stdout; + emitter.stderr = stderr; + emitter.kill = vi.fn().mockReturnValue(true); + // pid is read-only in ChildProcess, use type assertion for mock + (emitter as any).pid = 12345; + + return emitter; +} + +const { PiTransport } = await import('./piTransport'); + +describe('PiTransport', () => { + let mockProcess: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + }); + + describe('start()', () => { + it('should spawn pi with correct args', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith('pi', ['--mode', 'rpc'], expect.objectContaining({ + cwd: '/work', + stdio: ['pipe', 'pipe', 'pipe'] + })); + }); + + it('should emit error event on ENOENT', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const errorSpy = vi.fn(); + transport.onError(errorSpy); + + const spawnError = new Error('spawn pi ENOENT') as NodeJS.ErrnoException; + spawnError.code = 'ENOENT'; + mockProcess.emit('error', spawnError); + + expect(errorSpy).toHaveBeenCalledWith(expect.any(Error)); + expect(errorSpy.mock.calls[0][0].message).toContain('not found'); + }); + + it('should ignore double-start call', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + }); + + describe('send()', () => { + it('should write JSON to stdin', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.send({ type: 'prompt', message: 'hello' }); + expect(mockProcess.stdin.write).toHaveBeenCalledWith( + JSON.stringify({ type: 'prompt', message: 'hello' }) + '\n' + ); + }); + + it('should handle EPIPE gracefully without throwing', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + mockProcess.stdin.write = vi.fn().mockImplementation(() => { + const err = new Error('write EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + throw err; + }); + + expect(() => transport.send({ type: 'prompt', message: 'test' })).not.toThrow(); + }); + }); + + describe('onEvent()', () => { + it('should parse valid JSONL from stdout and call handler', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + mockProcess.stdout.emit('data', JSON.stringify(event) + '\n'); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should skip malformed JSON and not crash', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + mockProcess.stdout.emit('data', 'not-json\n'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should handle multiple JSONL lines in one chunk', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event1 = { type: 'turn_start' }; + const event2 = { type: 'turn_end', message: {} }; + mockProcess.stdout.emit('data', JSON.stringify(event1) + '\n' + JSON.stringify(event2) + '\n'); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(event1); + expect(handler).toHaveBeenCalledWith(event2); + }); + + it('should buffer and reassemble split JSONL across chunks', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + const fullLine = JSON.stringify(event) + '\n'; + + // Split the line into two chunks — no newline in first chunk + mockProcess.stdout.emit('data', fullLine.slice(0, 20)); + expect(handler).not.toHaveBeenCalled(); + + // Send the rest with newline + mockProcess.stdout.emit('data', fullLine.slice(20)); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('kill()', () => { + it('should send SIGTERM to the process', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.kill(); + expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should be a no-op when process is not running', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + expect(() => transport.kill()).not.toThrow(); + }); + }); + + describe('onClose()', () => { + it('should call handler when subprocess exits', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', 1, null); + expect(closeHandler).toHaveBeenCalledWith(1, null); + }); + + it('should call handler with signal when killed by signal', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', null, 'SIGTERM'); + expect(closeHandler).toHaveBeenCalledWith(null, 'SIGTERM'); + }); + }); +}); diff --git a/cli/src/pi/piTransport.ts b/cli/src/pi/piTransport.ts new file mode 100644 index 000000000..8d4a99ce7 --- /dev/null +++ b/cli/src/pi/piTransport.ts @@ -0,0 +1,123 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; +import { PiAgentEventSchema } from './schemas'; +import type { PiAgentEvent, PiRpcCommand } from './types'; + +export interface PiTransportOptions { + command: string; + args: string[]; + cwd: string; +} + +export class PiTransport extends JsonLineParser { + private process: ChildProcessWithoutNullStreams | null = null; + private eventHandler: ((event: PiAgentEvent) => void) | null = null; + private closeHandler: ((code: number | null, signal: string | null) => void) | null = null; + private errorHandler: ((error: Error) => void) | null = null; + private killed = false; + private started = false; + private exited = false; + private readonly options: PiTransportOptions; + + constructor(options: PiTransportOptions) { + super(); + this.options = options; + } + + start(): void { + if (this.started) { + logger.warn('[pi] PiTransport.start() called twice — ignoring'); + return; + } + this.started = true; + + logger.debug(`[pi] Starting Pi process: ${this.options.command} ${this.options.args.join(' ')}`); + + this.process = spawn(this.options.command, this.options.args, { + cwd: this.options.cwd, + stdio: ['pipe', 'pipe', 'pipe'] + }) as ChildProcessWithoutNullStreams; + + this.process.stdout.setEncoding('utf8'); + this.process.stdout.on('data', (chunk: string) => this.feed(chunk)); + this.process.stdout.on('end', () => { + if (!this.exited && !this.killed) { + logger.debug('[pi] stdout ended before process close — treating as exit'); + this.exited = true; + this.closeHandler?.(null, null); + } + }); + + this.process.stderr.setEncoding('utf8'); + this.process.stderr.on('data', (chunk: string) => { + logger.debug(`[pi][stderr] ${chunk.toString().trim()}`); + }); + + this.process.on('close', (code, signal) => { + logger.debug(`[pi] Process exited (code=${code}, signal=${signal})`); + this.exited = true; + this.closeHandler?.(code, signal); + }); + + this.process.on('error', (err) => { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ENOENT') { + this.errorHandler?.(new Error( + `Pi was not found on PATH. Please install Pi and retry.` + )); + } else { + this.errorHandler?.(new Error( + `Failed to start Pi: ${nodeErr.message}` + )); + } + }); + } + + send(message: PiRpcCommand): void { + if (!this.process || this.killed) { + logger.debug('[pi] Dropping message: transport not running'); + return; + } + try { + this.process.stdin.write(JSON.stringify(message) + '\n'); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EPIPE') { + logger.debug('[pi] EPIPE on write — process likely exited'); + } else { + throw err; + } + } + } + + onEvent(handler: (event: PiAgentEvent) => void): void { + this.eventHandler = handler; + } + + onClose(handler: (code: number | null, signal: string | null) => void): void { + this.closeHandler = handler; + } + + onError(handler: (error: Error) => void): void { + this.errorHandler = handler; + } + + kill(): void { + if (!this.process || this.killed) return; + this.killed = true; + this.process.kill('SIGTERM'); + } + + protected handleLine(line: string): void { + try { + const parsed = JSON.parse(line); + const result = PiAgentEventSchema.safeParse(parsed); + if (result.success) { + this.eventHandler?.(result.data as PiAgentEvent); + } + } catch { + logger.debug(`[pi] Skipping malformed JSON: ${line.slice(0, 100)}`); + } + } +} diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts new file mode 100644 index 000000000..430a04790 --- /dev/null +++ b/cli/src/pi/runPi.ts @@ -0,0 +1,403 @@ +import { logger } from '@/ui/logger'; +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { registerLocalHandoffHandler } from '@/agent/localHandoff'; +import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { PiTransport } from './piTransport'; +import { PiSession } from './session'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import { PiThinkingLevelSchema, SetSessionConfigPayloadSchema } from './schemas'; +import type { PiThinkingLevel } from './types'; +import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes'; +import type { ListPiModelsResponse } from '@hapi/protocol/apiTypes'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; + +export async function runPi(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + model?: string; + effort?: string; + resumeSessionId?: string; + existingSessionId?: string; + workingDirectory?: string; +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); + const startedBy = opts.startedBy ?? 'terminal'; + // Pi only runs as `pi --mode rpc` with piped stdio — there is no local + // terminal/TUI input path (unlike Claude/Codex). Defaulting a terminal + // launch to 'local' would mark the session local-controlled while the user + // cannot drive it from the terminal, leaving it stuck until a web switch. + // Default to 'remote' so the session is immediately drivable from the web; + // an explicit opts.startingMode (e.g. runner) still takes precedence. + const startingMode: 'local' | 'remote' = opts.startingMode ?? 'remote'; + + logger.debug(`[pi] Starting with options: startedBy=${startedBy}, startingMode=${startingMode}`); + + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'pi', + startedBy, + workingDirectory, + }) + : await bootstrapSession({ + flavor: 'pi', + startedBy, + workingDirectory, + // Do not seed the hub session model from opts.model: it is unconfirmed + // until get_available_models/set_model accept it. The hub's + // handleSessionAlive persists every non-undefined keepAlive model, so + // passing it here would store/show a model Pi may reject. PiSession + // carries opts.model as initialModel and applies it once confirmed. + model: undefined + }); + const { session: apiSession } = bootstrap; + + setControlledByUser(apiSession, startingMode); + + const piSession = new PiSession({ + api: bootstrap.api, + client: apiSession, + path: workingDirectory, + logPath: logger.getLogPath(), + startedBy, + startingMode, + model: opts.model, + }); + + const transportArgs = ['--mode', 'rpc']; + if (opts.resumeSessionId) { + transportArgs.push('--session-id', opts.resumeSessionId); + } + const transport = new PiTransport({ command: 'pi', args: transportArgs, cwd: workingDirectory }); + + piSession.startKeepAlive(); + + let killedByCleanup = false; + const lifecycle = createRunnerLifecycle({ + session: apiSession, + logTag: 'pi', + stopKeepAlive: () => piSession.stopKeepAlive(), + onAfterClose: () => { + piSession.stopKeepAlive(); + killedByCleanup = true; + transport.kill(); + } + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle.cleanupAndExit); + registerLocalHandoffHandler(apiSession.rpcHandlerManager, lifecycle); + + let cleanupInitiated = false; + const safeCleanup = async () => { + if (cleanupInitiated) return; + cleanupInitiated = true; + await lifecycle.cleanupAndExit(); + }; + + // Pending user-message localIds in FIFO order + const pendingLocalIds: string[] = []; + + // --- Transport error/close handlers --- + transport.onError((error) => { + logger.debug(`[pi] Transport error: ${error.message}`); + lifecycle.markCrash(error); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(error.message.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + transport.onClose((code, signal) => { + if (killedByCleanup) { + logger.debug(`[pi] Pi process closed during lifecycle cleanup (code=${code}, signal=${signal})`); + void safeCleanup(); + return; + } + const reason = signal + ? `Pi process killed by signal ${signal}` + : `Pi process exited with code ${code ?? 'null'}`; + logger.debug(`[pi] ${reason}`); + lifecycle.markCrash(new Error(reason)); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(reason.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + // --- Wire transport events to session --- + // Capture the requested startup effort WITHOUT mutating currentThinkingLevel. + // It is applied (and committed) only after Pi confirms set_thinking_level, + // mirroring the startup-model contract; seeding it here would leak an + // unconfirmed/rejected value via the first keepAlive (pushKeepAlive persists + // effort) before the RPC runs. get_state's thinkingLevel is the authoritative + // source until set_thinking_level succeeds. + let startupThinkingLevel: PiThinkingLevel | null = null; + if (opts.effort) { + const result = PiThinkingLevelSchema.safeParse(opts.effort.trim().toLowerCase()); + if (result.success) { + startupThinkingLevel = result.data; + } else { + logger.debug(`[pi] Ignoring invalid effort value on resume: ${opts.effort}`); + } + } + + wireTransportEvents(transport, piSession, pendingLocalIds); + + // --- Session config RPC --- + // + // Pi manually registers SetSessionConfig instead of using + // registerSessionConfigRpc() because Pi's wire protocol requires + // separate provider + modelId fields (transport.send({ type: + // 'set_model', provider, modelId })), while registerSessionConfigRpc + // only handles model as a simple string. The hub sends model as + // { provider, modelId } for Pi sessions. + + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (rawPayload: unknown) => { + const parsed = SetSessionConfigPayloadSchema.safeParse(rawPayload); + if (!parsed.success) { + throw new Error('Invalid session config payload'); + } + const config = parsed.data; + logger.debug(`[pi] SetSessionConfig received: ${JSON.stringify(config)}`); + + // Resolve requested values WITHOUT mutating PiSession yet. Commit them + // only after Pi confirms via sendPiRpcAndWait, otherwise a rejected + // set_model/set_thinking_level would leave PiSession holding unconfirmed + // values that the 2s keepalive reports back to the hub, persisting a + // model/effort Pi never accepted. + let requestedModel: { modelId: string | null; provider: string | null } | undefined; + if (config.model !== undefined) { + const modelValue = config.model; + logger.debug(`[pi] SetSessionConfig model: ${JSON.stringify(modelValue)}`); + + if (modelValue === null) { + requestedModel = { modelId: null, provider: null }; + } else if (typeof modelValue === 'string') { + const trimmed = modelValue.trim(); + if (!trimmed) throw new Error('Invalid model'); + // Fallback: search cached models for provider + const cached = piSession.cachedPiModels.find(m => m.modelId === trimmed); + requestedModel = { modelId: trimmed, provider: cached?.provider ?? null }; + } else { + // { provider, modelId } form + requestedModel = { modelId: modelValue.modelId, provider: modelValue.provider }; + } + logger.debug(`[pi] SetSessionConfig resolved: model=${requestedModel.modelId}, provider=${requestedModel.provider}`); + } + let requestedThinkingLevel: PiThinkingLevel | null | undefined; + if (config.effort !== undefined) { + if (config.effort === null) { + requestedThinkingLevel = null; + } else { + const result = PiThinkingLevelSchema.safeParse( + typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort, + ); + if (!result.success) throw new Error('Invalid effort'); + requestedThinkingLevel = result.data; + } + } + + // Forward changes to Pi process — wait for Pi to confirm before + // committing to PiSession or reporting applied, so the hub does not + // persist a model/effort that Pi rejected (e.g. invalid provider/model + // or thinking level) or that the RPC timed out on. + if (requestedModel) { + if (requestedModel.modelId && requestedModel.provider) { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_model', + provider: requestedModel.provider, + modelId: requestedModel.modelId, + }); + piSession.currentModel = requestedModel.modelId; + piSession.currentProvider = requestedModel.provider; + } else if (requestedModel.modelId && !requestedModel.provider) { + // Provider is unknown until get_state/get_available_models resolve. + // Committing now would persist piSelectedModel while Pi never received + // set_model — contradicting the "await Pi confirmation" contract above. + // Throw so the hub returns 409 and the web client can retry once the + // provider is known. + logger.debug('[pi] set_model suppressed: provider unknown until get_state'); + throw new Error('Model cannot be applied yet: provider is not yet known'); + } else if (requestedModel.modelId === null) { + // Clearing the model needs no Pi RPC (nothing to confirm), so commit + // immediately. This path is not reachable from the web Pi picker today. + piSession.currentModel = null; + piSession.currentProvider = null; + } + } + if (requestedThinkingLevel !== undefined) { + const level = requestedThinkingLevel ?? 'off'; + await sendPiRpcAndWait(piSession, transport, { type: 'set_thinking_level', level }); + piSession.currentThinkingLevel = requestedThinkingLevel; + } + piSession.pushKeepAlive(); + + // Return provider-qualified model so the hub persists piSelectedModel. + // A bare modelId string would make applySessionConfig clear the + // provider metadata (object check fails), defeating Fix #3. + const appliedModel = piSession.currentModel && piSession.currentProvider + ? { provider: piSession.currentProvider, modelId: piSession.currentModel } + : piSession.currentModel; + + return { + applied: { + model: appliedModel, + effort: piSession.currentThinkingLevel, + }, + }; + }); + + // --- Pi model discovery RPC --- + apiSession.rpcHandlerManager.registerHandler, ListPiModelsResponse>( + RPC_METHODS.ListPiModels, + async () => { + if (piSession.cachedPiModels.length > 0) { + return { + success: true, + availableModels: piSession.cachedPiModels, + currentModelId: piSession.currentModel, + }; + } + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_available_models' }); + const models = parsePiModels(data); + if (models.length > 0) { + piSession.cachedPiModels = models; + piSession.updateMetadata(meta => ({ ...meta, piAvailableModels: models })); + } + return { success: true, availableModels: models, currentModelId: piSession.currentModel }; + } catch (error) { + logger.debug('[pi] ListPiModels RPC failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list Pi models', + }; + } + } + ); + + // --- Slash commands (Pi skills/commands) --- + apiSession.rpcHandlerManager.registerHandler<{ agent?: string }, SlashCommandsResponse>( + RPC_METHODS.ListSlashCommands, + async () => { + let commands = piSession.cachedPiCommands; + if (commands.length === 0) { + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' }); + commands = parsePiCommands(data); + if (commands.length > 0) { + piSession.cachedPiCommands = commands; + } + } catch { + // Fall through to return empty + } + } + return { + success: true, + commands: commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: cmd.source === 'skill' ? 'plugin' as const + : cmd.source === 'prompt' ? 'user' as const + : 'plugin' as const, + })), + }; + } + ); + + // --- User message handler --- + apiSession.onUserMessage((message, localId) => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + if (piSession.piIsStreaming) { + // Steer does not start a new turn, so the localId would never be + // drained by turn_start. Mark it consumed immediately so it does + // not poison the FIFO for the next real prompt. + transport.send({ type: 'steer', message: formattedText }); + if (localId) piSession.emitMessagesConsumed([localId]); + } else { + if (localId) pendingLocalIds.push(localId); + transport.send({ type: 'prompt', message: formattedText }); + } + }); + + // --- Abort handler --- + // Only cancel the current turn, keep session alive for next prompt. + // Pi's `abort` command cancels the active turn but the process stays in RPC mode. + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { + transport.send({ type: 'abort' }); + piSession.piIsStreaming = false; + piSession.updateThinkingState(false); + if (pendingLocalIds.length > 0) { + piSession.emitMessagesConsumed([pendingLocalIds.shift()!]); + } + return { success: true }; + }); + + // --- Switch handler --- + // Unlike Claude/Codex (which use BaseLocalLauncher's restart loop), Pi runs + // as a single long-lived subprocess. Switching mode should change control + // ownership without killing the process or archiving the session. + const handleModeChange = createModeChangeHandler(apiSession); + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Switch, async (payload: { to?: 'local' | 'remote' } = {}) => { + const mode = payload.to ?? 'remote'; + piSession.setMode(mode); + handleModeChange(mode); + return { success: true }; + }); + + // --- Run --- + let crashed = false; + try { + transport.start(); + transport.send({ type: 'new_session' }); + transport.send({ type: 'get_state' }); + transport.send({ type: 'get_available_models' }); + transport.send({ type: 'get_commands' }); + + // Apply the requested startup effort only after Pi confirms + // set_thinking_level. Commit currentThinkingLevel on success and push a + // keepAlive so the hub sees the accepted value; on rejection keep Pi's + // default (already reported by get_state). Detached so the run loop is + // not blocked; sent after get_state so the authoritative baseline lands + // first and a late get_state response does not clobber the confirmed + // value (get_state runs on the wire before this await resolves). + if (startupThinkingLevel) { + void (async () => { + try { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_thinking_level', + level: startupThinkingLevel, + }); + piSession.currentThinkingLevel = startupThinkingLevel; + piSession.pushKeepAlive(); + logger.debug(`[pi] Startup effort applied: ${startupThinkingLevel}`); + } catch (error) { + logger.debug(`[pi] Startup effort rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } + + // Block until cleanup is triggered by error/close handler + await new Promise((resolve) => { + const origCleanup = lifecycle.cleanupAndExit.bind(lifecycle); + lifecycle.cleanupAndExit = async (codeOverride?: number) => { + resolve(); + await origCleanup(codeOverride); + }; + }); + } catch (error) { + crashed = true; + lifecycle.markCrash(error); + lifecycle.setSessionEndReason('error'); + logger.debug('[pi] Loop error:', error); + } finally { + if (!crashed && !lifecycle.hasExplicitSessionEndReason()) { + lifecycle.setSessionEndReason('completed'); + } + await safeCleanup(); + } +} diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts new file mode 100644 index 000000000..3532265af --- /dev/null +++ b/cli/src/pi/schemas.ts @@ -0,0 +1,203 @@ +/** + * Zod schemas for Pi RPC protocol parsing. + * + * All unknown→typed conversions happen here via Zod schemas, + * so downstream code works with validated data only. + * + * Pi 协议无版本保证 — 字段级容错策略: + * 用 z.unknown().transform() / .catch() 确保非法类型字段静默丢弃, + * 而非拒绝整个对象。 + */ + +import { z } from 'zod'; +import { PI_THINKING_LEVELS } from '@hapi/protocol'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; + +// ============================================================================ +// 字段级容错 schema +// ============================================================================ + +/** 提取 string 值,非 string 返回 undefined */ +const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); + +/** 提取 number 值,非 number 返回 undefined */ +const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); + +/** 提取 boolean 值,非 boolean 返回 undefined */ +const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); + +/** 提取 string 值,非 string 返回指定默认值 */ +const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); + +/** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ +const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { + if (typeof v !== 'object' || v === null) return undefined; + const map: Record = {}; + for (const [key, val] of Object.entries(v as Record)) { + if (typeof val === 'string') map[key] = val; + else if (val === null) map[key] = null; + } + return Object.keys(map).length > 0 ? map : undefined; +}); + +// ============================================================================ +// Pi Agent Event (stdin JSONL → event) +// ============================================================================ + +/** Minimal shape: must be an object with a string `type` field. */ +export const PiAgentEventSchema = z.object({ + type: z.string(), +}).passthrough(); + +// ============================================================================ +// Pi Response Event (stdout response) +// ============================================================================ + +export const PiResponseEventSchema = z.object({ + type: z.literal('response'), + command: z.string(), + success: z.boolean(), + error: z.string().optional(), + data: z.unknown().optional(), + // RPC correlation id (sent by PiRpcResolver as string) + id: z.string().optional(), +}); + +// ============================================================================ +// Pi Command Summary +// ============================================================================ + +const VALID_COMMAND_SOURCES = ['extension', 'prompt', 'skill'] as const; +type PiCommandSource = (typeof VALID_COMMAND_SOURCES)[number]; + +const PiCommandSummarySchema = z.object({ + name: z.string(), + description: z.string().optional(), + source: z.enum(VALID_COMMAND_SOURCES), +}); + +/** 单条 command 的容错 schema:非法字段静默修正,空 name 返回 null */ +const PiCommandEntrySchema = z.object({ + name: asStrOrDef(''), + description: asOptStr, + source: z.unknown().transform(v => + VALID_COMMAND_SOURCES.includes(v as PiCommandSource) + ? (v as PiCommandSource) + : ('skill' as const), + ), +}).passthrough().transform((c) => { + if (!c.name) return null; + const entry: { name: string; description?: string; source: PiCommandSource } = { + name: c.name, + source: c.source, + }; + if (c.description !== undefined) entry.description = c.description; + return entry; +}); + +const PiCommandsResponseDataSchema = z.object({ + commands: z.array(z.unknown()).default([]), +}).transform(data => + data.commands + .map(c => PiCommandEntrySchema.safeParse(c)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi Model Summary +// ============================================================================ + +/** 单条 model 的容错 schema:非法字段静默丢弃,空 id 返回 null */ +const PiModelEntrySchema = z.object({ + id: asStrOrDef(''), + provider: asStrOrDef('unknown'), + name: asOptStr, + contextWindow: asOptNum, + reasoning: asOptBool, + thinkingLevelMap: asOptThinkingLevelMap, +}).passthrough().transform((m): PiModelSummary | null => { + if (!m.id) return null; + const entry: PiModelSummary = { provider: m.provider, modelId: m.id }; + if (m.name !== undefined) entry.name = m.name; + if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow; + if (m.reasoning !== undefined) entry.reasoning = m.reasoning; + if (m.thinkingLevelMap !== undefined) entry.thinkingLevelMap = m.thinkingLevelMap; + return entry; +}); + +const PiModelsResponseDataSchema = z.object({ + models: z.array(z.unknown()).default([]), +}).transform(data => + data.models + .map(m => PiModelEntrySchema.safeParse(m)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi State (get_state response data) +// ============================================================================ + +export const PiStateDataSchema = z.object({ + model: z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), + }).passthrough().optional(), + sessionId: z.string().optional(), + thinkingLevel: z.string().optional(), + steeringMode: z.enum(['all', 'one-at-a-time']).optional(), +}).passthrough(); + +// ============================================================================ +// Pi set_model response data +// ============================================================================ + +export const PiSetModelDataSchema = z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), +}).passthrough(); + +// ============================================================================ +// SetSessionConfig RPC payload +// ============================================================================ + +export const SetSessionConfigPayloadSchema = z.object({ + permissionMode: z.unknown().optional(), + model: z.union([ + z.string(), + z.object({ provider: z.string(), modelId: z.string() }), + z.null(), + ]).optional(), + effort: z.unknown().optional(), +}).passthrough(); + +// ============================================================================ +// Pi thinking level — enum sourced from @hapi/protocol (single definition) +// ============================================================================ + +export const PiThinkingLevelSchema = z.enum(PI_THINKING_LEVELS); + +// ============================================================================ +// message_update assistant message event — delta extraction +// ============================================================================ + +export const PiAssistantMessageEventSchema = z.object({ + type: z.string(), + delta: z.string().optional(), + contentIndex: z.number().optional(), +}).passthrough(); + +// ============================================================================ +// Parse helpers — replace hand-written type guards in loop.ts +// ============================================================================ + +export function parsePiCommands(data: unknown) { + return PiCommandsResponseDataSchema.safeParse(data).data ?? []; +} + +export function parsePiModels(data: unknown) { + return PiModelsResponseDataSchema.safeParse(data).data ?? []; +} diff --git a/cli/src/pi/session.ts b/cli/src/pi/session.ts new file mode 100644 index 000000000..e5740fe32 --- /dev/null +++ b/cli/src/pi/session.ts @@ -0,0 +1,127 @@ +import type { ApiClient, ApiSessionClient } from '@/lib'; +import type { Metadata } from '@/api/types'; +import type { PiCommandSummary, PiThinkingLevel } from './types'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; +import type { PiRpcResolver } from './loop'; + +/** + * Pi session state and hub communication wrapper. + * + * Unlike other agents that extend AgentSessionBase (which requires MessageQueue2), + * Pi sends messages directly via PiTransport RPC — no queue needed. + * This class manages Pi-specific runtime state and hub keepAlive. + */ +export class PiSession { + readonly api: ApiClient; + readonly client: ApiSessionClient; + readonly path: string; + readonly logPath: string; + readonly startedBy: 'runner' | 'terminal'; + // Mutable mode — updated by setMode() when the hub switches control + // (local ↔ remote). keepAlive reads this so the reported mode does not + // revert to the constructor-time startingMode every 2s tick. + mode: 'local' | 'remote'; + + // Config state — synced to hub via keepAlive. + // `undefined` means "not yet known" and is OMITTED from keepAlive so the hub + // does not clear a persisted value; `null` is an explicit clear. A value is + // only assigned once Pi confirms it (get_state / successful set_model / + // successful set_thinking_level). + currentModel: string | null | undefined; + currentThinkingLevel: PiThinkingLevel | null | undefined; + // Pi's set_model requires provider + modelId; learned from get_state + currentProvider: string | null = null; + // Startup model from opts.model — prevents get_state from overwriting it + // with Pi's default. Applied once when get_available_models returns. + readonly initialModel: string | null; + + // Streaming state + piIsStreaming = false; + currentSteeringMode: 'all' | 'one-at-a-time' = 'all'; + + // Cached data from Pi + cachedPiModels: PiModelSummary[] = []; + cachedPiCommands: PiCommandSummary[] = []; + + // RPC resolver — initialized by wireTransportEvents, session-scoped + rpcResolver: PiRpcResolver | null = null; + + private keepAliveInterval: NodeJS.Timeout | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + model?: string | null; + }) { + this.api = opts.api; + this.client = opts.client; + this.path = opts.path; + this.logPath = opts.logPath; + this.startedBy = opts.startedBy; + this.mode = opts.startingMode; + // currentModel/currentThinkingLevel start undefined ("not yet known") + // and are set only from Pi's confirmed state (get_state) or a successful + // set_model/set_thinking_level. Seeding from opts.model/opts.effort here + // would leak unconfirmed values via the first keepAlive; they are captured + // as initialModel/startupThinkingLevel and applied once Pi accepts them. + // undefined is distinct from null (explicit clear): keepAlive omits + // undefined fields so the hub does not wipe a persisted model/effort on + // resume before Pi reports its real state. + this.currentModel = undefined; + this.initialModel = opts.model?.trim() || null; + this.currentThinkingLevel = undefined; + } + + startKeepAlive(): void { + this.pushKeepAlive(); + this.keepAliveInterval = setInterval(() => this.pushKeepAlive(), 2000); + } + + stopKeepAlive(): void { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + this.keepAliveInterval = null; + } + } + + private getKeepAliveRuntime(): Parameters[2] { + const runtime: NonNullable[2]> = {}; + if (this.currentModel !== undefined) runtime.model = this.currentModel; + if (this.currentThinkingLevel !== undefined) runtime.effort = this.currentThinkingLevel; + return Object.keys(runtime).length > 0 ? runtime : undefined; + } + + pushKeepAlive(): void { + this.client.keepAlive(this.piIsStreaming, this.mode, this.getKeepAliveRuntime()); + } + + updateThinkingState(thinking: boolean): void { + this.piIsStreaming = thinking; + this.client.keepAlive(thinking, this.mode, this.getKeepAliveRuntime()); + } + + setMode(mode: 'local' | 'remote'): void { + this.mode = mode; + this.pushKeepAlive(); + } + + updateMetadata(updater: (meta: Metadata) => Metadata): void { + this.client.updateMetadata(updater); + } + + sendAgentMessage(message: unknown): void { + this.client.sendAgentMessage(message); + } + + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { + this.client.emitMessagesConsumed(localIds, options); + } + + sendSessionEvent(event: Parameters[0]): void { + this.client.sendSessionEvent(event); + } +} diff --git a/cli/src/pi/types.ts b/cli/src/pi/types.ts new file mode 100644 index 000000000..ded9607ba --- /dev/null +++ b/cli/src/pi/types.ts @@ -0,0 +1,119 @@ +/** + * Pi RPC protocol type definitions. + * + * Commands are sent as JSON lines on stdin. + * Responses and events are emitted as JSON lines on stdout. + * Based on Pi coding-agent's rpc-types.ts and agent/types.ts. + */ + +// ============================================================================ +// Pi Agent Events (stdout) — discriminated union on `type` +// ============================================================================ + +export interface PiTextDeltaEvent { + type: 'text_delta'; + delta: string; +} + +export interface PiThinkingDeltaEvent { + type: 'thinking_delta'; + delta: string; +} + +export type PiAssistantMessageEvent = + | PiTextDeltaEvent + | PiThinkingDeltaEvent + | { type: 'start' } + | { type: 'done'; reason: string } + | { type: 'error'; reason: string; error: unknown } + // Catch-all for text_start, text_end, thinking_start, thinking_end, toolcall_* etc. + | { type: string; [key: string]: unknown }; + +export interface PiUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; +} + +// Individual event types for proper type narrowing +export interface PiAgentStartEvent { type: 'agent_start' } +export interface PiAgentEndEvent { type: 'agent_end'; messages: unknown[] } +export interface PiTurnStartEvent { type: 'turn_start' } +export interface PiTurnEndEvent { + type: 'turn_end'; + message?: { usage?: PiUsage; stopReason?: string }; + toolResults?: unknown[]; +} +export interface PiMessageStartEvent { type: 'message_start'; message: unknown } +export interface PiMessageUpdateEvent { + type: 'message_update'; + assistantMessageEvent?: PiAssistantMessageEvent; + message?: unknown; +} +export interface PiMessageEndEvent { type: 'message_end'; message: unknown } +export interface PiToolExecutionStartEvent { + type: 'tool_execution_start'; + toolCallId: string; + toolName: string; + args: unknown; +} +export interface PiToolExecutionUpdateEvent { + type: 'tool_execution_update'; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; +} +export interface PiToolExecutionEndEvent { + type: 'tool_execution_end'; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; +} + +export type PiAgentEvent = + | PiAgentStartEvent + | PiAgentEndEvent + | PiTurnStartEvent + | PiTurnEndEvent + | PiMessageStartEvent + | PiMessageUpdateEvent + | PiMessageEndEvent + | PiToolExecutionStartEvent + | PiToolExecutionUpdateEvent + | PiToolExecutionEndEvent + | { type: string }; // fallback for unknown events + +// ============================================================================ +// Pi RPC Commands (stdin) +// ============================================================================ + +import type { PiThinkingLevel } from '@hapi/protocol' +import type { PiCommandSummary } from '@hapi/protocol/apiTypes' +export type { PiThinkingLevel, PiCommandSummary } + +export type PiRpcCommand = + | { type: 'prompt'; message: string } + | { type: 'steer'; message: string } + | { type: 'abort' } + | { type: 'new_session' } + | { type: 'get_state' } + | { type: 'set_model'; provider: string; modelId: string } + | { type: 'get_available_models' } + | { type: 'set_thinking_level'; level: PiThinkingLevel } + | { type: 'get_commands' }; + +// ============================================================================ +// Pi RPC Responses (stdout) +// ============================================================================ + +export interface PiResponseEvent { + type: 'response'; + command: string; + success: boolean; + error?: string; + data?: unknown; +} diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 6d809b11b..4d1882da8 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -81,4 +81,44 @@ describe('buildCliArgs', () => { expect(args).toContain(mode) } }) + + it('uses --session-id for pi resume (not --resume)', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + resumeSessionId: 'some-pi-session-id', + }) + expect(args).not.toContain('--resume') + expect(args).toContain('--session-id') + expect(args).toContain('some-pi-session-id') + expect(args[0]).toBe('pi') + }) + + it('still passes --resume for claude when resumeSessionId is provided', () => { + // Guard against accidentally swallowing claude's --resume when + // the pi branch was added. + const args = buildCliArgs('claude', { + directory: '/tmp', + resumeSessionId: 'some-claude-session-id', + }) + expect(args).toContain('--resume') + expect(args).toContain('some-claude-session-id') + }) + + it('passes --effort for pi agent', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) + + it('passes --effort for claude agent', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index f49d3b15b..dbf99e356 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1101,13 +1101,18 @@ export function buildCliArgs( ? 'kimi' : agent === 'opencode' ? 'opencode' - : 'claude'; + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); } else if (agent === 'cursor') { args.push('--resume', options.resumeSessionId); + } else if (agent === 'pi') { + // Pi uses --session-id for exact session resume (RPC mode) + args.push('--session-id', options.resumeSessionId); } else { args.push('--resume', options.resumeSessionId); } @@ -1116,16 +1121,20 @@ export function buildCliArgs( if (options.model) { args.push('--model', options.model); } - if (options.effort && agent === 'claude') { + if (options.effort && (agent === 'claude' || agent === 'pi')) { args.push('--effort', options.effort); } if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { args.push('--model-reasoning-effort', options.modelReasoningEffort); } - if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { - args.push('--permission-mode', options.permissionMode); - } else if (yolo) { - args.push('--yolo'); + // Pi RPC mode has no permission switching; never pass these flags to it + // (the Pi parser rejects --permission-mode and ignores --yolo). + if (agent !== 'pi') { + if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { + args.push('--permission-mode', options.permissionMode); + } else if (yolo) { + args.push('--yolo'); + } } return args; } diff --git a/cli/src/utils/jsonLineParser.ts b/cli/src/utils/jsonLineParser.ts new file mode 100644 index 000000000..c4b08d294 --- /dev/null +++ b/cli/src/utils/jsonLineParser.ts @@ -0,0 +1,35 @@ +/** + * JSONL line parser — shared by all stdio-based agent transports. + * + * Buffers raw stdout chunks, splits on newlines, and emits complete lines. + * Each transport provides its own `handleLine` to parse the JSON and + * dispatch domain-specific events. + */ +export abstract class JsonLineParser { + private buffer = ''; + + /** Feed a raw stdout chunk into the parser. */ + feed(chunk: string): void { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf('\n'); + + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + this.handleLine(line); + } + + newlineIndex = this.buffer.indexOf('\n'); + } + } + + /** Reset internal buffer (e.g. on process restart). */ + reset(): void { + this.buffer = ''; + } + + /** Override to parse a complete JSON line and dispatch events. */ + protected abstract handleLine(line: string): void; +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 8e0389401..2920c0500 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -89,7 +89,7 @@ export class RpcGateway { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode @@ -260,6 +260,12 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + /** Generic Pi RPC call — routes all Pi-specific session RPCs through + * a single entry point instead of per-method wrappers. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.sessionRpc(sessionId, method, params ?? {}, timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS) as T + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ecfabd7e3..d0c05f66c 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -404,7 +404,7 @@ export class SessionCache { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode @@ -422,15 +422,27 @@ export class SessionCache { this.markRuntimeConfigUpdated(sessionId, 'permissionMode', appliedAt) } if (config.model !== undefined) { - if (config.model !== session.model) { - const updated = this.store.sessions.setSessionModel(sessionId, config.model, session.namespace, { + const modelValue = config.model + // Normalize object form { provider, modelId } to plain string for DB storage + const piModelObject = modelValue !== null && typeof modelValue === 'object' + ? modelValue + : null + const normalizedModel: string | null = piModelObject ? piModelObject.modelId : modelValue as string | null + if (normalizedModel !== session.model) { + const updated = this.store.sessions.setSessionModel(sessionId, normalizedModel, session.namespace, { touchUpdatedAt: false }) if (!updated) { throw new Error('Failed to update session model') } } - session.model = config.model + session.model = normalizedModel + // Pi requires provider + modelId to uniquely identify a model. + // Persist the provider-qualified form in metadata so web can + // resolve the exact model even when two providers share a modelId. + if (session.metadata?.flavor === 'pi') { + this.persistPiSelectedModel(session, piModelObject) + } this.markRuntimeConfigUpdated(sessionId, 'model', appliedAt) } if (config.modelReasoningEffort !== undefined) { @@ -888,6 +900,34 @@ export class SessionCache { session.metadataVersion = result.version } + private persistPiSelectedModel(session: Session, piSelected: { provider: string; modelId: string } | null): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.piSelectedModel === piSelected) { + return + } + + const nextMetadata = { ...currentMetadata, piSelectedModel: piSelected } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } + private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null { if (oldState === null) return newState if (newState === null) return oldState @@ -913,12 +953,13 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId } if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId } if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId } if (metadata.opencodeSessionId) return { field: 'opencodeSessionId', value: metadata.opencodeSessionId } if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId } + if (metadata.piSessionId) return { field: 'piSessionId', value: metadata.piSessionId } return null } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4ec57f5a1..7991db767 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1805,6 +1805,62 @@ describe('session model', () => { // completedRequests has req-1 expect(state.completedRequests?.['req-1']).toBeDefined() }) + + it('merges duplicate when piSessionId collides', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1') + + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + expect(s1.id).not.toBe(s2.id) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeUndefined() + expect(cache.getSession(s2.id)).toBeDefined() + + const messages = store.messages.getMessages(s2.id, 100) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('preserves sessions with different piSessionId', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-A' }, + null, + 'default' + ) + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-B' }, + null, + 'default' + ) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeDefined() + expect(cache.getSession(s2.id)).toBeDefined() + }) }) describe('clearSessionArchiveMetadata', () => { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a7f88c032..3c20a2c55 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -617,7 +617,7 @@ export class SyncEngine { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode @@ -632,7 +632,7 @@ export class SyncEngine { return } - const result = await this.rpcGateway.requestSessionConfig(sessionId, config) + const result = await this.rpcGateway.requestSessionConfig(sessionId, config) as Record if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } @@ -651,7 +651,7 @@ export class SyncEngine { } const applied = obj.applied if (!applied || typeof applied !== 'object') { - throw new Error('Missing applied session config') + throw new Error(`Missing applied session config, got: ${JSON.stringify(result)}`) } const requestedKeys = Object.keys(config) as Array @@ -709,6 +709,7 @@ export class SyncEngine { if (flavor === 'opencode') return metadata.opencodeSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null + if (flavor === 'pi') return metadata.piSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } @@ -1399,6 +1400,8 @@ export class SyncEngine { && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) + && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) + && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) } private triggerDedupIfNeeded(sessionId: string): void { @@ -1514,6 +1517,11 @@ export class SyncEngine { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId) } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 6ec0be834..cc1469710 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -510,7 +510,7 @@ describe('sessions routes', () => { expect(response.status).toBe(400) expect(await response.json()).toEqual({ - error: 'Effort selection is only supported for Claude sessions' + error: 'Effort selection is not supported for this session type' }) expect(applySessionConfigCalls).toEqual([]) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 3725a3bd1..e4886b5f6 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -11,11 +11,13 @@ import { SessionModelRequestSchema, SessionPermissionModeRequestSchema, supportsModelChange, + supportsEffort, toSessionSummary, UploadFileRequestSchema } from '@hapi/protocol' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { SlashCommand } from '@hapi/protocol/apiTypes' -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import type { SyncEngine, Session } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' @@ -535,8 +537,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude') { - return c.json({ error: 'Effort selection is only supported for Claude sessions' }, 400) + if (!supportsEffort(flavor)) { + return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } try { @@ -797,5 +799,38 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + // Helper: guard + flavor check + error handling for Pi session endpoints + async function withPiSession( + c: Context, + handler: (ctx: { sessionId: string; engine: SyncEngine }) => Promise + ): Promise { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'pi') { + return c.json({ success: false, error: 'Not a Pi session' }, 400) + } + + try { + return await handler({ sessionId: sessionResult.sessionId, engine }) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Internal error' + }, 500) + } + } + + // --- Pi models --- + app.get('/sessions/:id/pi-models', (c) => + withPiSession(c, async ({ sessionId, engine }) => + c.json(await engine.callPiRpc(sessionId, RPC_METHODS.ListPiModels, {}, 120_000)) + ) + ) + return app } diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 604e1a901..f3877a078 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -124,7 +124,13 @@ export const SessionCollaborationModeRequestSchema = z.object({ export type SessionCollaborationModeRequest = z.infer export const SessionModelRequestSchema = z.object({ - model: z.string().trim().min(1).nullable() + model: z.union([ + z.string().trim().min(1), + z.object({ + provider: z.string().trim().min(1), + modelId: z.string().trim().min(1), + }), + ]).nullable() }) export type SessionModelRequest = z.infer @@ -374,6 +380,41 @@ export type CursorModelsResponse = OpencodeModelsResponse export type ListCursorModelsResponse = CursorModelsResponse +/** Maps thinking levels to provider-specific values. null = unsupported. */ +export type PiThinkingLevelMap = Partial> + +export type PiModelSummary = { + provider: string + modelId: string + name?: string + contextWindow?: number + /** Whether the model supports reasoning/thinking */ + reasoning?: boolean + /** Maps Pi thinking levels to provider values; null = unsupported level */ + thinkingLevelMap?: PiThinkingLevelMap +} + +export type PiModelsResponse = { + success: boolean + availableModels?: PiModelSummary[] + currentModelId?: string | null + error?: string +} + +export type ListPiModelsResponse = PiModelsResponse + +export type PiCommandSummary = { + name: string + description?: string + source: 'extension' | 'prompt' | 'skill' +} + +export type PiCommandsResponse = { + success: boolean + commands?: PiCommandSummary[] + error?: string +} + export type SlashCommand = { name: string description?: string diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a92595f19..0d7459500 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -37,6 +37,16 @@ describe('hasCapability', () => { expect(hasCapability('opencode', Capabilities.Effort)).toBe(false) }) + test('pi supports model-change and effort', () => { + expect(hasCapability('pi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('pi', Capabilities.Effort)).toBe(true) + }) + + test('kimi supports model-change but not effort', () => { + expect(hasCapability('kimi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) + }) + test('unknown flavor returns false', () => { expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false) }) @@ -54,6 +64,8 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('codex')).toBe('Codex') expect(getFlavorLabel('cursor')).toBe('Cursor') expect(getFlavorLabel('opencode')).toBe('OpenCode') + expect(getFlavorLabel('pi')).toBe('Pi') + expect(getFlavorLabel('kimi')).toBe('Kimi') }) test('unknown flavor returns Unknown', () => { @@ -73,6 +85,8 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('codex')).toBe(true) expect(isKnownFlavor('cursor')).toBe(true) expect(isKnownFlavor('opencode')).toBe(true) + expect(isKnownFlavor('pi')).toBe(true) + expect(isKnownFlavor('kimi')).toBe(true) }) test('returns false for unknown/null/undefined', () => { @@ -89,6 +103,8 @@ describe('convenience functions', () => { expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('opencode')).toBe(true) expect(supportsModelChange('cursor')).toBe(true) + expect(supportsModelChange('pi')).toBe(true) + expect(supportsModelChange('kimi')).toBe(true) expect(supportsModelChange(null)).toBe(false) }) @@ -96,6 +112,8 @@ describe('convenience functions', () => { expect(supportsEffort('claude')).toBe(true) expect(supportsEffort('codex')).toBe(false) expect(supportsEffort('gemini')).toBe(false) + expect(supportsEffort('pi')).toBe(true) + expect(supportsEffort('kimi')).toBe(false) expect(supportsEffort(null)).toBe(false) }) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index a4832e93c..15c59df38 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -16,6 +16,7 @@ const FLAVOR_CAPS: Record> = { codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), + pi: new Set([Capabilities.ModelChange, Capabilities.Effort]), } // --- Flavor display names --- @@ -26,6 +27,7 @@ const FLAVOR_LABELS: Record = { codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', + pi: 'Pi', } // --- Query functions --- diff --git a/shared/src/index.ts b/shared/src/index.ts index b8f5e291d..0aaf85f2a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './rpcMethods' export * from './socket' export * from './sessionSummary' export * from './sessionExport' +export * from './piThinkingLevel' export * from './slashCommands' export * from './utils' export * from './version' diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 0b0a50c13..eb97fe10f 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,10 +1,71 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, test } from 'bun:test' import { getPermissionModeLabel, + getPermissionModeOptionsForFlavor, getPermissionModeTone, - isPermissionModeAllowedForFlavor + getPermissionModesForFlavor, + isPermissionModeAllowedForFlavor, } from './modes' +describe('getPermissionModesForFlavor', () => { + test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { + expect(getPermissionModesForFlavor('pi')).toEqual([]) + }) + + test("returns [] for pi and does not fall back to Claude modes", () => { + // Ensure Pi is opt-in empty, not silently inheriting Claude defaults. + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor('claude')) + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor(null)) + }) + + test("unknown flavors fall back to Claude modes, not Pi's empty list", () => { + expect(getPermissionModesForFlavor(null)).not.toEqual([]) + expect(getPermissionModesForFlavor(undefined)).not.toEqual([]) + expect(getPermissionModesForFlavor('PI')).not.toEqual([]) + expect(getPermissionModesForFlavor('Pi')).not.toEqual([]) + }) +}) + +describe('getPermissionModeOptionsForFlavor', () => { + test("returns [] for pi (no permission options offered)", () => { + expect(getPermissionModeOptionsForFlavor('pi')).toEqual([]) + }) +}) + +describe('isPermissionModeAllowedForFlavor', () => { + test("no mode is allowed for pi", () => { + expect(isPermissionModeAllowedForFlavor('yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('default', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('plan', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('acceptEdits', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('bypassPermissions', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('read-only', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('safe-yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('ask', 'pi')).toBe(false) + }) +}) + +describe('getPermissionModeLabel', () => { + test("yolo label is 'Yolo'", () => { + expect(getPermissionModeLabel('yolo')).toBe('Yolo') + }) + + test("default label is 'Default'", () => { + expect(getPermissionModeLabel('default')).toBe('Default') + }) +}) + +describe('getPermissionModeTone', () => { + test("yolo tone is danger", () => { + expect(getPermissionModeTone('yolo')).toBe('danger') + }) + + test("default tone is neutral", () => { + expect(getPermissionModeTone('default')).toBe('neutral') + }) +}) + describe('claude auto permission mode', () => { it('is allowed for claude only', () => { expect(isPermissionModeAllowedForFlavor('auto', 'claude')).toBe(true) @@ -13,6 +74,7 @@ describe('claude auto permission mode', () => { expect(isPermissionModeAllowedForFlavor('auto', 'cursor')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'opencode')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'kimi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) }) it('has a label and tone', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 06007c5f2..a8d1c6659 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -119,6 +119,11 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'cursor') { return CURSOR_PERMISSION_MODES } + if (flavor === 'pi') { + // Pi RPC mode has no runtime permission switching (always auto-approve); + // no permission modes are offered. + return [] + } return CLAUDE_PERMISSION_MODES } diff --git a/shared/src/piThinkingLevel.ts b/shared/src/piThinkingLevel.ts new file mode 100644 index 000000000..6f70b40cb --- /dev/null +++ b/shared/src/piThinkingLevel.ts @@ -0,0 +1,13 @@ +// Pi thinking levels (from Pi's rpc-types.ts ThinkingLevel) +// Controls how much reasoning/thinking the model performs. +export const PI_THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const +export type PiThinkingLevel = typeof PI_THINKING_LEVELS[number] + +export const PI_THINKING_LEVEL_LABELS: Record = { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh', +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 0ac67facc..c4284451c 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -27,6 +27,7 @@ export const RPC_METHODS = { ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', ListCursorModels: 'listCursorModels', + ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 22a6fd511..dca943017 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -47,6 +47,7 @@ export const MetadataSchema = z.object({ // tiann/hapi#873. cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(), kimiSessionId: z.string().optional(), + piSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), @@ -63,7 +64,15 @@ export const MetadataSchema = z.object({ preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), - worktree: WorktreeMetadataSchema.optional() + worktree: WorktreeMetadataSchema.optional(), + // Cached Pi model list — written by CLI, read by web (inactive session fallback). + // Minimal shape: each entry must have modelId; other fields (provider, name, etc.) pass through. + piAvailableModels: z.array(z.object({ modelId: z.string() }).passthrough()).optional(), + // Pi-selected model with provider identity. The legacy `session.model` + // field stores only modelId (shared across all flavors); this preserves + // the provider so web can resolve the exact model when two providers + // share a modelId. + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() }) export type Metadata = z.infer diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 488e7ea24..af69ebbe1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -497,7 +497,7 @@ export class ApiClient { }) } - async setModel(sessionId: string, model: string | null): Promise { + async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) @@ -627,6 +627,14 @@ export class ApiClient { ) } + /** Generic Pi session endpoint — replaces per-method wrappers. */ + async callPiEndpoint(sessionId: string, path: string, init?: RequestInit): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/pi-${path}`, + init + ) + } + async getMachineCursorModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/cursor-models` diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index a079e030f..65466a54f 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -16,6 +16,11 @@ const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 // Fallback for Codex sessions when the server has not reported an explicit modelContextWindow. // The value matches the context window currently reported by Codex App Server token-count events. const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 258_400 +// Pi supports multiple providers with varying context windows. 200K is a +// conservative default (most Claude/GPT-4 class models). When the server +// reports an explicit modelContextWindow via usage events, that takes +// precedence over this fallback. +const DEFAULT_PI_CONTEXT_WINDOW_TOKENS = 200_000 function parseCursorWireContextWindow(model: string): number | null { const match = model.match(/\[([^\]]+)\]/) @@ -47,6 +52,10 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? return Math.max(1, DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) } + if (flavor === 'pi') { + return Math.max(1, DEFAULT_PI_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) + } + if (flavor === 'cursor') { const trimmedModel = model?.trim() const windowTokens = trimmedModel ? parseCursorWireContextWindow(trimmedModel) : null diff --git a/web/src/components/AgentFlavorIcon.test.tsx b/web/src/components/AgentFlavorIcon.test.tsx new file mode 100644 index 000000000..5dc776cbd --- /dev/null +++ b/web/src/components/AgentFlavorIcon.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/react' +import { AgentFlavorIcon } from './AgentFlavorIcon' + +function getBadge(container: HTMLElement): HTMLElement { + const badge = container.querySelector('span') + if (!badge) throw new Error('AgentFlavorIcon did not render a ') + return badge +} + +describe('AgentFlavorIcon', () => { + it('renders the "Pi" label and purple background for the pi flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Pi') + // The Pi badge uses a specific purple; if the literal ever drifts, + // the test should fail and force an intentional design update. + expect(badge.className).toContain('bg-[#5b21b6]') + expect(badge.className).toContain('text-white') + }) + + it('matches the exact class contract for all known flavors (regression)', () => { + const cases: Array<{ flavor: string; label: string; bg: string }> = [ + { flavor: 'claude', label: 'Cl', bg: 'bg-[#d97706]' }, + { flavor: 'codex', label: 'Cx', bg: 'bg-[#111827]' }, + { flavor: 'cursor', label: 'Cu', bg: 'bg-[#0f766e]' }, + { flavor: 'gemini', label: 'Gm', bg: 'bg-[#2563eb]' }, + { flavor: 'kimi', label: 'Km', bg: 'bg-[#7c3aed]' }, + { flavor: 'pi', label: 'Pi', bg: 'bg-[#5b21b6]' }, + { flavor: 'opencode', label: 'Op', bg: 'bg-[#15803d]' }, + ] + for (const { flavor, label, bg } of cases) { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe(label) + expect(badge.className).toContain(bg) + } + }) + + it('renders the "Un" badge with secondary-bg colors for null flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('renders the "Un" badge for undefined flavor', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for empty string', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for unknown flavor strings', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('normalizes flavor case and whitespace', () => { + // The component lowercases + trims internally so 'PI ', 'Pi', ' pi' + // all resolve to the Pi badge. + for (const flavor of ['PI', 'Pi', ' pi ', 'PI ']) { + const { container } = render() + expect(getBadge(container).textContent).toBe('Pi') + } + }) + + it('does NOT match a flavor when only whitespace is present', () => { + // ' '.trim() === '' so the unknown branch is the only valid one. + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('applies the default size classes when no className is provided', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-4') + expect(badge.className).toContain('w-4') + }) + + it('appends the provided className alongside the badge classes', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-6') + expect(badge.className).toContain('w-6') + // The default size classes must be replaced by the custom className + // (the implementation uses `${className ?? 'h-4 w-4'}`). + expect(badge.className).not.toContain('h-4 w-4') + }) + + it('marks the badge aria-hidden for screen readers (decorative only)', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.getAttribute('aria-hidden')).toBe('true') + }) +}) diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index b88f25181..796bea956 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -19,6 +19,10 @@ const FLAVOR_BADGES: Record = { label: 'Km', colors: 'bg-[#7c3aed] text-white', }, + pi: { + label: 'Pi', + colors: 'bg-[#5b21b6] text-white', + }, opencode: { label: 'Op', colors: 'bg-[#15803d] text-white', diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 5b0325a6f..0bb8484b1 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,6 +8,10 @@ import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' import { useRef, useState } from 'react' +function ChevronIcon() { + return +} + function VoiceAssistantIcon() { return ( void + piThinkingLabel?: string + piThinkingDisabled?: boolean + piThinkingOpen?: boolean + onPiThinkingToggle?: () => void // Scratchlist drawer toggle. When `onScratchlistToggle` is provided, a // notepad icon appears next to the schedule-send icon. Click toggles // composer-send-routing between chat and scratchlist; SessionChat owns @@ -498,6 +511,42 @@ export function ComposerButtons(props: { ) : null} + {props.piModelLabel ? ( + + ) : null} + + {props.piThinkingLabel ? ( + + ) : null} + {props.showTerminalButton ? (
+ { + handleModelChange({ provider: piModel.provider, modelId: piModel.modelId }) + }} + onClose={closeAllPanels} + /> +
+ ) + } + + // Thinking level panel + if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) { + panels.push( +
+ handleEffortChange(level)} + onClose={closeAllPanels} + /> +
+ ) + } + + if (panels.length > 0) return <>{panels} + } + + // Non-Pi flavors: original unified gear menu if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings)) { return (
@@ -742,81 +876,79 @@ export function HappyComposer(props: {
{t('misc.model')}
- {modelOptions.map((option) => { - const isSelected = selectedModelBase !== undefined - ? selectedModelBase === option.value - : model === option.value - return ( - + ))}
- - {option.label} - - - ) - })} - - ) : null} - - {showModelSettings && showModelEffortSettings ? ( -
- ) : null} - - {showModelEffortSettings ? ( -
-
- {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} -
- {modelEffortOptions!.map((option) => ( - - ))} +
+ {isSelected && ( +
+ )} +
+ + {option.label} + + + ) + }) + )}
) : null} @@ -923,6 +1055,12 @@ export function HappyComposer(props: { return null }, [ showSettings, + showPiModelPanel, + showPiThinkingPanel, + agentFlavor, + piModels, + selectedPiModel, + closeAllPanels, showCollaborationSettings, showPermissionSettings, showModelSettings, @@ -1044,6 +1182,14 @@ export function HappyComposer(props: { onSchedule={setPendingSchedule} onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)} hasAttachments={hasAttachments} + piModelLabel={piModelLabel} + piModelDisabled={controlsDisabled || !piHasModels} + piModelOpen={showPiModelPanel} + onPiModelToggle={handlePiModelToggle} + piThinkingLabel={piThinkingLabel} + piThinkingDisabled={controlsDisabled || !piHasModels || !selectedPiModel || selectedPiModel.reasoning === false} + piThinkingOpen={showPiThinkingPanel} + onPiThinkingToggle={handlePiThinkingToggle} scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} diff --git a/web/src/components/AssistantChat/PiModelPanel.tsx b/web/src/components/AssistantChat/PiModelPanel.tsx new file mode 100644 index 000000000..899b2086c --- /dev/null +++ b/web/src/components/AssistantChat/PiModelPanel.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from '@/lib/use-translation' +import type { PiModelSummary } from '@/types/api' +import { groupModelsByProvider } from './piModelGroups' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' + +export function PiModelPanel(props: { + models: PiModelSummary[] + currentModel: { provider: string; modelId: string } | null + controlsDisabled?: boolean + onSelect: (model: PiModelSummary) => void + onClose: () => void +}) { + const { t } = useTranslation() + const groups = groupModelsByProvider(props.models) + const disabled = props.controlsDisabled ?? false + + const isSelected = (piModel: PiModelSummary) => + props.currentModel?.provider === piModel.provider && + props.currentModel?.modelId === piModel.modelId + + return ( + +
+
+ {t('misc.model')} +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.models.map((piModel) => { + const selected = isSelected(piModel) + return ( + + ) + })} +
+ ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx new file mode 100644 index 000000000..940290018 --- /dev/null +++ b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx @@ -0,0 +1,76 @@ +import { PI_THINKING_LEVEL_LABELS } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' +import { isThinkingLevelSupported } from './piThinkingLevelOptions' + +const ALL_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const + +/** + * Determine which thinking levels a model supports. + * - reasoning=false → no levels + * - reasoning=true (or unknown) + thinkingLevelMap → filter by map via isThinkingLevelSupported + * - reasoning=true (or unknown) + no map → all levels except xhigh + */ +function getSupportedLevels( + reasoning?: boolean, + thinkingLevelMap?: PiThinkingLevelMap, +): string[] { + if (reasoning === false) return [] + return ALL_LEVELS.filter((level) => isThinkingLevelSupported(level, thinkingLevelMap)) +} + +export function PiThinkingLevelPanel(props: { + currentLevel: string | null + reasoning?: boolean + thinkingLevelMap?: PiThinkingLevelMap + controlsDisabled?: boolean + onSelect: (level: string | null) => void + onClose: () => void +}) { + const supportedLevels = getSupportedLevels(props.reasoning, props.thinkingLevelMap) + const disabled = props.controlsDisabled ?? false + + if (supportedLevels.length === 0) return null + + return ( + +
+
+ Thinking Level +
+ {supportedLevels.map((level) => ( + + ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index ef1898301..a7900cec3 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -141,6 +141,19 @@ describe('getModelOptionsForFlavor', () => { { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } ]) }) + + it('returns just the auto/default option for pi flavor (no Claude fallback)', () => { + const options = getModelOptionsForFlavor('pi') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('keeps the current pi model in the options list when it is not auto', () => { + const options = getModelOptionsForFlavor('pi', 'claude-sonnet-4-5') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -197,4 +210,82 @@ describe('getNextModelForFlavor', () => { const next = getNextModelForFlavor('cursor', 'composer-2.5') expect(next).toBe('composer-2.5') }) + + it('keeps the current pi model on cycle (no Claude fallback)', () => { + // Pi has no predefined model list — Ctrl/Cmd+M must not cycle + // through Claude presets, which would push sonnet/opus ids into + // a Pi session via set-session-config. + const next = getNextModelForFlavor('pi', 'claude-sonnet-4-5') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('returns null for pi without a current model (no Claude fallback)', () => { + const next = getNextModelForFlavor('pi', null) + expect(next).toBeNull() + }) + + it('treats "auto" as null and returns null for pi (no Claude preset injection)', () => { + // normalizeCurrentModel maps 'auto' to null; a Pi session whose UI + // displays 'Auto' must not be switched to sonnet/opus by the + // cycler shortcut. + const next = getNextModelForFlavor('pi', 'auto') + expect(next).toBeNull() + }) + + it('treats "default" as null and returns null for pi', () => { + const next = getNextModelForFlavor('pi', 'default') + expect(next).toBeNull() + }) + + it('treats empty/whitespace strings as null for pi (no Claude preset injection)', () => { + expect(getNextModelForFlavor('pi', '')).toBeNull() + expect(getNextModelForFlavor('pi', ' ')).toBeNull() + }) + + it('trims surrounding whitespace from the current pi model', () => { + const next = getNextModelForFlavor('pi', ' claude-sonnet-4-5 ') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('keeps a kimi current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('kimi', 'kimi-k2-0711')).toBe('kimi-k2-0711') + expect(getNextModelForFlavor('kimi', null)).toBeNull() + }) + + it('keeps a cursor current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('cursor', 'composer-2.5')).toBe('composer-2.5') + expect(getNextModelForFlavor('cursor', null)).toBeNull() + }) + + it('keeps an opencode current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('opencode', 'ollama/legacy')).toBe('ollama/legacy') + expect(getNextModelForFlavor('opencode', null)).toBeNull() + }) +}) + +describe('getModelOptionsForFlavor — pi normalize filter', () => { + it('drops "auto" and renders just the default option for pi', () => { + // 'auto' should be normalized to null, which equals the auto entry; + // we must not produce a duplicate { value: null, label: 'auto' } row. + const options = getModelOptionsForFlavor('pi', 'auto') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops "default" and renders just the default option for pi', () => { + const options = getModelOptionsForFlavor('pi', 'default') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops empty/whitespace currentModel for pi', () => { + expect(getModelOptionsForFlavor('pi', '')).toEqual([{ value: null, label: 'Default' }]) + expect(getModelOptionsForFlavor('pi', ' ')).toEqual([{ value: null, label: 'Default' }]) + }) + + it('trims whitespace from a real current pi model', () => { + const options = getModelOptionsForFlavor('pi', ' custom-model ') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'custom-model', label: 'custom-model' } + ]) + }) }) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 11a5fea41..9f350ecd1 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -126,6 +126,14 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + // Pi model list is provided dynamically via piModels prop in SessionChat, + // not through this function. Show just the auto/default option here to + // prevent falling through to the Claude preset cycler (which would + // surface unrelated Claude models and let set-session-config push + // `sonnet`/`opus` ids into a Pi session). + if (flavor === 'pi') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } return getClaudeModelOptions(currentModel) } @@ -167,5 +175,10 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + // Pi model list is provided dynamically via piModels prop — pressing + // Ctrl/Cmd+M must not fall through to the Claude preset cycler. + if (flavor === 'pi') { + return normalizeCurrentModel(currentModel) + } return getNextClaudeComposerModel(currentModel) } diff --git a/web/src/components/AssistantChat/piModelGroups.ts b/web/src/components/AssistantChat/piModelGroups.ts new file mode 100644 index 000000000..df0d33c09 --- /dev/null +++ b/web/src/components/AssistantChat/piModelGroups.ts @@ -0,0 +1,35 @@ +import type { PiModelSummary } from '@/types/api' + +type ProviderGroup = { + provider: string + label: string + models: PiModelSummary[] +} + +/** Format provider name for display */ +function formatProviderLabel(provider: string): string { + if (provider === 'unknown') return 'Other' + // Capitalize first letter, keep rest as-is + return provider.charAt(0).toUpperCase() + provider.slice(1) +} + +/** Group Pi models by provider, preserving original order within each group */ +export function groupModelsByProvider(models: PiModelSummary[]): ProviderGroup[] { + const groupOrder: string[] = [] + const groups = new Map() + + for (const model of models) { + const provider = model.provider || 'unknown' + if (!groups.has(provider)) { + groupOrder.push(provider) + groups.set(provider, []) + } + groups.get(provider)!.push(model) + } + + return groupOrder.map((provider) => ({ + provider, + label: formatProviderLabel(provider), + models: groups.get(provider)!, + })) +} diff --git a/web/src/components/AssistantChat/piThinkingLevelOptions.ts b/web/src/components/AssistantChat/piThinkingLevelOptions.ts new file mode 100644 index 000000000..8a9ad3580 --- /dev/null +++ b/web/src/components/AssistantChat/piThinkingLevelOptions.ts @@ -0,0 +1,82 @@ +import { PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS, type PiThinkingLevel } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' + +type PiThinkingLevelOption = { + value: string + label: string +} + +function normalizePiThinkingLevel(level?: string | null): string | null { + const trimmedLevel = level?.trim().toLowerCase() + if (!trimmedLevel || trimmedLevel === 'default' || trimmedLevel === 'auto') { + return null + } + + return trimmedLevel +} + +function formatPiThinkingLevelLabel(level: string): string { + return PI_THINKING_LEVEL_LABELS[level as PiThinkingLevel] + ?? `${level.charAt(0).toUpperCase()}${level.slice(1)}` +} + +/** + * Get thinking level options filtered by the model's thinkingLevelMap. + * Levels mapped to `null` in the map are unsupported and excluded. + * Levels not present in the map are included (treated as supported with default mapping). + */ +export function getPiThinkingLevelOptions( + currentLevel?: string | null, + thinkingLevelMap?: PiThinkingLevelMap +): PiThinkingLevelOption[] { + const normalizedCurrentLevel = normalizePiThinkingLevel(currentLevel) + const options: PiThinkingLevelOption[] = [] + + // Include current level if it's non-standard (custom) + if ( + normalizedCurrentLevel + && !(PI_THINKING_LEVELS as readonly string[]).includes(normalizedCurrentLevel) + && !isLevelExcluded(normalizedCurrentLevel, thinkingLevelMap) + ) { + options.push({ + value: normalizedCurrentLevel, + label: formatPiThinkingLevelLabel(normalizedCurrentLevel) + }) + } + + options.push(...PI_THINKING_LEVELS + .filter((level) => !isLevelExcluded(level, thinkingLevelMap)) + .map((level) => ({ + value: level, + label: PI_THINKING_LEVEL_LABELS[level] + })) + ) + + return options +} + +/** Check whether a thinking level is supported by the model's thinkingLevelMap */ +export function isThinkingLevelSupported(level: string, map?: PiThinkingLevelMap): boolean { + // xhigh requires explicit opt-in via the map + if (level === 'xhigh') { + if (!map || !(level in map)) return false + return map[level] !== null + } + if (!map || !(level in map)) return true + return map[level] !== null +} + +/** A level is excluded if it maps to `null` in the thinkingLevelMap, or xhigh without explicit opt-in */ +function isLevelExcluded(level: string, map?: PiThinkingLevelMap): boolean { + return !isThinkingLevelSupported(level, map) +} + +/** Return the highest supported thinking level, or null if none */ +export function getHighestThinkingLevel(map?: PiThinkingLevelMap): string | null { + for (let i = PI_THINKING_LEVELS.length - 1; i >= 0; i--) { + if (isThinkingLevelSupported(PI_THINKING_LEVELS[i]!, map)) { + return PI_THINKING_LEVELS[i]! + } + } + return null +} diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index eaa3c823d..0da14396e 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -37,6 +37,7 @@ export const MODEL_OPTIONS: Record | null + const piCachedModels = piMetadata?.piAvailableModels as PiModelSummary[] | undefined ?? [] + // Provider-qualified selected model — disambiguates when two providers + // share a modelId (hub persists this alongside the legacy modelId string). + const piSelectedModel = piMetadata?.piSelectedModel as { provider: string; modelId: string } | null | undefined const cursorCatalogReadinessArgs = useMemo(() => ({ sessionLoading: cursorModelsState.isLoading, machineLoading: machineCursorModelsState.isLoading, @@ -551,7 +564,6 @@ function SessionChatInner(props: SessionChatProps) { ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) : null ), [agentFlavor, cursorModelEffortOptions, props.session.model]) - const { abortSession, switchSession, @@ -790,7 +802,7 @@ function SessionChatInner(props: SessionChatProps) { }, [setCollaborationMode, props.onRefresh, haptic]) // Model mode change handler - const handleModelChange = useCallback(async (model: string | null) => { + const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => { try { await setModel(model) haptic.notification('success') @@ -1002,7 +1014,11 @@ function SessionChatInner(props: SessionChatProps) {
0 ? piModelsState.availableModels : piCachedModels) : undefined} + piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} availableModelReasoningEffortOptions={ agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0 ? opencodeReasoningEffortState.options @@ -1140,9 +1164,11 @@ function SessionChatInner(props: SessionChatProps) { && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 - ? handleCursorBaseModelChange + ? ((model) => handleCursorBaseModelChange(typeof model === 'string' ? model : model?.modelId ?? null)) : undefined) - : handleModelChange + : agentFlavor === 'pi' + ? (props.session.active && !piModelsState.error ? handleModelChange : undefined) + : handleModelChange } onModelEffortChange={ agentFlavor === 'cursor' diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index fe96f37b9..3a4735b9f 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -19,7 +19,7 @@ export function useSessionActions( switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise - setModel: (model: string | null) => Promise + setModel: (model: { provider: string; modelId: string } | string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise renameSession: (name: string) => Promise @@ -110,7 +110,7 @@ export function useSessionActions( }) const modelMutation = useMutation({ - mutationFn: async (model: string | null) => { + mutationFn: async (model: { provider: string; modelId: string } | string | null) => { if (!api || !sessionId) { throw new Error('Session unavailable') } diff --git a/web/src/hooks/queries/usePiModels.ts b/web/src/hooks/queries/usePiModels.ts new file mode 100644 index 000000000..61ffd96dd --- /dev/null +++ b/web/src/hooks/queries/usePiModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { PiModelSummary, PiModelsResponse } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function usePiModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: PiModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionPiModels(sessionId) + : ['session-pi-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('Pi models target unavailable') + } + return await api.callPiEndpoint(sessionId, 'models') + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Pi models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Pi models' + : null, + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a0664af7e..e7adcfb30 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -17,6 +17,7 @@ export const queryKeys = { slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const, sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const, + sessionPiModels: (sessionId: string) => ['session-pi-models', sessionId] as const, machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const, sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index 637ae965d..31c5209b7 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -129,3 +129,132 @@ describe('sessionResume', () => { }), 3)).toBe(false) }) }) + +describe('sessionResume — pi flavor', () => { + it('resolveAgentSessionIdFromMetadata returns piSessionId when flavor is pi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-123', + })).toBe('pi-sess-123') + }) + + it('resolveAgentSessionIdFromMetadata returns undefined when flavor is pi but no piSessionId', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids when flavor is pi', () => { + // Stale ids from other flavors must not satisfy a Pi resume — hub + // will reject them and the web layer would otherwise claim the + // session is resumable. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + claudeSessionId: 'claude-stale', + codexSessionId: 'codex-stale', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata prefers piSessionId over other ids when flavor is pi', () => { + // Defensive: even if a stale id slipped in, the pi id should win. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-real', + claudeSessionId: 'claude-stale', + })).toBe('pi-sess-real') + }) + + it('inactiveSessionCanResume allows resume of pi session when piSessionId is present', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + piSessionId: 'pi-sess-abc', + }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume allows fresh pi spawn when path is set and there are no messages', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume rejects inactive pi session with messages but no piSessionId (no Pi recovery fallback)', () => { + // Pi does not have a recover-from-messages path the way Claude does. + // If the cli lost the session id, the user must start a new session + // (or click resume in the cli to re-establish the id). + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume rejects pi session whose only id is a stale cross-flavor id', () => { + // Stale codexSessionId alone does NOT satisfy Pi resume. + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + codexSessionId: 'stale-codex', + }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume allows active pi session unconditionally', () => { + expect(inactiveSessionCanResume(makeSession({ + active: true, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(true) + }) +}) + +describe('sessionResume — regression for all other flavor ids', () => { + // Every flavor-specific id resolver must still work; the switch in + // sessionResume.ts grew a new 'pi' branch and the existing branches + // must not be regressed. + it('codex', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'codex', codexSessionId: 'cx-1', + })).toBe('cx-1') + }) + it('gemini', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'gemini', geminiSessionId: 'gm-1', + })).toBe('gm-1') + }) + it('opencode', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'opencode', opencodeSessionId: 'oc-1', + })).toBe('oc-1') + }) + it('cursor', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'cursor', cursorSessionId: 'cu-1', + })).toBe('cu-1') + }) + it('kimi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'kimi', kimiSessionId: 'ki-1', + })).toBe('ki-1') + }) + it('claude (default branch)', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'claude', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) + it('unknown flavor falls back to claude branch', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'mystery', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) +}) diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts index 061e08e57..5cb9fd587 100644 --- a/web/src/lib/sessionResume.ts +++ b/web/src/lib/sessionResume.ts @@ -3,7 +3,8 @@ import type { Session } from '@/types/api' /** Agent thread id used by hub `resolveAgentResumeId`, flavor-specific. * Mirrors hub: cross-flavor ids are ignored to avoid the web layer claiming a - * session is resumable when the hub will only honor the current flavor's id. */ + * session is resumable when the hub will only honor the current flavor's id. + */ export function resolveAgentSessionIdFromMetadata( metadata: Session['metadata'] | null | undefined, ): string | undefined { @@ -17,6 +18,7 @@ export function resolveAgentSessionIdFromMetadata( case 'opencode': return metadata.opencodeSessionId ?? undefined case 'cursor': return metadata.cursorSessionId ?? undefined case 'kimi': return metadata.kimiSessionId ?? undefined + case 'pi': return metadata.piSessionId ?? undefined default: return metadata.claudeSessionId ?? undefined } } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b9f5d0f76..e3a78c81a 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -28,6 +28,9 @@ export type { OpencodeModelsResponse, OpencodeModelSummary, PathExistsResponse, + PiModelSummary, + PiModelsResponse, + PiThinkingLevelMap, SlashCommand, SlashCommandsResponse, SessionResponse,