From e23ae1b265095f4dc9c7362451f5937ecadba6b2 Mon Sep 17 00:00:00 2001 From: ZzzzSsssWwww Date: Thu, 18 Jun 2026 10:01:10 +0800 Subject: [PATCH] feat: add Pi Coding Agent support (#862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: spec for hapi-pi-agent-backend * docs: spec retrospect for hapi-pi-agent-backend * docs: plan for hapi-pi-agent-backend * docs: plan retrospect for hapi-pi-agent-backend * feat(pi): add hapi pi command with JSONL transport and event converter - PiTransport: spawn pi --mode rpc, JSONL stdio, ENOENT/EPIPE handling - PiEventConverter: Pi AgentEvent → HAPI AgentMessage conversion - runPi: session lifecycle, dual-track event routing, model switching - pi command: CLI registration with PI_PERMISSION_MODES - Shared: add 'pi' to AGENT_FLAVORS, FLAVOR_CAPS, FLAVOR_LABELS 30 tests passing (15 transport + 15 converter) * fix(pi): add Pi RPC types, fix double-cleanup/double-start/converter safety net - Add cli/src/pi/types.ts with PiAgentEvent/PiResponseEvent discriminated unions - PiTransport: constructor uses options object, double-start guard, drop log - PiEventConverter: typed events via type assertions, top-level try/catch - runPi: safeCleanup guard prevents double-cleanup race, sendAgentMessage for converted events, keepAlive() for session pings - 33 tests passing * docs: dev phase reviews and test results for hapi-pi-agent-backend - Business logic review: pass (0 must_fix) - Standards review: pass (0 must_fix) - Taste review: P0 types issue fixed in code - Robustness review v2: pass (v1 3 MUST_FIX all fixed) - Integration review: pass (0 must_fix) - Test results: 33 passing, all type errors resolved * docs: taste review v2 pass after type definition fixes * docs: dev retrospect for hapi-pi-agent-backend * test: test execution for hapi-pi-agent-backend (20/20 pass) * fix: add taste_review symlink for gate pattern match * docs: test retrospect for hapi-pi-agent-backend * fix(web): add pi to MODEL_OPTIONS Record type * ci: PR and CI evidence for hapi-pi-agent-backend * docs: overall retrospect for hapi-pi-agent-backend (all 5 phases) * test(pi): add buffer split, missing fields, and handleResponse tests - PiTransport: buffer cross-chunk reassembly test - PiEventConverter: tool_execution_end with missing result/toolCallId - handleResponse: 10 tests covering all branches (error, get_state, set_model, new_session, abort, prompt, unknown command) - Extract handleResponse to accept onUpdate callback for testability - Total: 46 tests passing (was 33) * fix(pi): set requiresRuntimeAssets to false — pi runs as subprocess, no native tools needed * refactor(cli): lazy import ensureRuntimeAssets to reduce startup overhead * docs: add 15 manual E2E protocol test cases (TC-4-xx) based on real Pi RPC capture - TC-4-01 to TC-4-15: manual tests covering tool execution, thinking lifecycle, multi-turn, abort, error scenarios, model switch, cleanup - Priority: P0 (tool fields, failure, thinking, multi-turn, abort) > P1 (basic conversation, write tool, model switch, usage) > P2 (edge cases) - Includes actual Pi RPC event sequence from live capture as reference - e2e-test-plan.md updated with test environment setup instructions - Total test cases: 35 (6 unit + 14 integration + 15 manual) * test: E2E protocol test results for hapi-pi (11/15 pass) P0/P1 automated tests (8/8 pass): - TC-4-01: Basic text conversation ✓ - TC-4-02: Tool read (field names verified) ✓ - TC-4-03: Tool write (file created) ✓ - TC-4-04: Tool failure (isError=true) ✓ - TC-4-05: Thinking lifecycle + usage ✓ - TC-4-06: Multi-turn context retention ✓ - TC-4-07: Abort generation ✓ - TC-4-14: Token count ✓ - TC-4-15: Extension UI events ignored ✓ P2 results: - TC-4-10: Invalid token → 401 ✓ - TC-4-12: Ctrl+C cleanup, no orphans ✓ - TC-4-08: ENOENT (harness issue, exit code correct) - TC-4-11: set_model not supported by Pi (success=false) - TC-4-13: Pi crash (harness output capture issue) * test: fix TC-4-11 result — Pi set_model works with correct provider/modelId Previous test used invalid provider='' + modelId='deepseek-chat'. Re-tested with provider='deepseek' + modelId='deepseek-v4-flash': - set_model success=true - model switched glm-5.1 → deepseek-v4-flash - subsequent prompt confirmed working Final E2E results: 12/15 PASS, 2 FAIL (test harness), 1 SKIP * chore: remove .xyz-harness/ from git tracking, add to .gitignore Local harness workflow artifacts should not be tracked in the repo. * fix(pi): resolve web UI bugs for hapi-pi integration Five bugs fixed for end-to-end pi session via hapi web UI: 1. runner buildCliArgs: add 'pi' branch to spawn correct command (was falling back to 'claude', launching wrong agent) 2. runPi: implement real keep-alive (2s interval) to prevent hub 30s timeout marking session inactive 3. runPi: bump keep-alive to active state during agent/turn_start 4. sessionResume: add 'pi' to flavor switch and resume condition (was returning undefined, causing 'cannotResume' on inactive session) 5. PiEventConverter: emit codex-compatible {type:'message',message:...} /{type:'reasoning',message:...} with streamId; dedup by skipping text_start/text_end (only send deltas) to avoid triple-rendered text 6. PiTransport: fallback to stdout 'end' event when child process close event doesn't fire (bun spawn quirk) Verified end-to-end: web UI shows pi reasoning + reply correctly, session stays online, no duplicate text. * fix(pi): address 4 web UI display bugs in hapi-pi integration Three of four follow-up bugs reported after the initial fix (6c28949): 1. Stuck in 'queued' status — fix Pi's runner doesn't use MessageQueue2, so the base session's onBatchConsumed hook never fires. Add a FIFO of pending localIds in runPi and emit messages-consumed on agent_start. turn_start is intentionally skipped (it can fire multiple times per agent run after tool calls). A prompt rejection from Pi also consumes the localId so the next prompt isn't poisoned. 2. AI thinking only displays ':' — fix Pi emits pure incremental deltas (text_delta / thinking_delta) per token. The web reducer dedupes reasoning by streamId WITHIN one message's content array only — separate wire messages produce separate renders. Without accumulation, 50 deltas = 50 reasoning renders, of which the reducer keeps only the last delta (a single character like ':'). 3. Output text on separate lines — fix Same root cause as #2 but for text: the reducer appends each text AgentMessage as a new agent-text block (no dedup), so 50 deltas become a 50-row character-by-character column. 4. Tool call execution status (in_progress -> completed) The tool result wire CodexMessage type is 'tool-call-result' (with callId + is_error?); the internal AgentMessage 'tool_result' is converted to that. Status mapping is preserved. Implementation: extract a PiMessageAccumulator class (testable in isolation) that mirrors codex's ReasoningProcessor pattern: - message_start resets state and streamId - text_delta / thinking_delta append to internal text / reasoning - text_start/thinking_start/text_end/thinking_end ignored (they carry full partial state — would duplicate) - message_end flushes (max 1 reasoning + 1 text message, in order) - turn_end safety net flushes if active - flushIfActive() exposed for transport close / crash The converter now routes AgentMessage through convertAgentMessage so the wire format is codex-shaped (matches opencode/gemini/kimi path). AgentMessage 'text' and CodexMessage 'message' both gain optional id; convertAgentMessage preserves caller-provided id for streamId-based dedup on the web side. Tests: 16 new PiMessageAccumulator tests + 5 updated PiEventConverter tests + 4 messageConverter tests, all passing. Full suite: 909/910 (1 unrelated macOS path normalization). tsc clean. * fix(pi): review round 1 - 1 must-fix issue The web session-resume helper referenced metadata.piSessionId, but the shared MetadataSchema does not define the field, and the back-end has no path to populate it (Pi session resume is out of scope per spec.md). This caused web typecheck to fail and would also have produced a runtime 'resume_unavailable' from the hub if a user tried to resume a Pi session that had any user messages (the stale 'flavor === pi' branch in inactiveSessionCanResume claimed resume was supported). Revert the two early Pi branches from the web resume helper. Add a comment pointing at the spec and noting what to undo when back-end resume ships (re-add 'case pi' + 'piSessionId' on MetadataSchema + extend hub resolveAgentResumeId). * fix(pi): review round 2 - 4 must-fix issues 1. cli/src/runner/run.ts buildCliArgs: stop forwarding --resume to the pi binary. Pi session resume is out of scope (no piSessionId on Metadata), so forwarding would create an orphan session the hub can't track. Hub already returns null from resolveAgentResumeId for flavor='pi' and falls through to fresh spawn; this just hardens the runner layer to match. 2. cli/src/pi/runPi.ts: cache currentProvider from get_state and use it for subsequent set_model RPCs. Pi's set_model requires both provider and modelId, but the bootstrap-time code emitted provider: '' which Pi rejects. The bootstrap-time model is still applied by Pi at startup, so suppressing set_model until get_state arrives is a no-op for same-model configs rather than a wrong-model emit. 3. web/src/components/AssistantChat/modelOptions.ts: add explicit pi branches to getModelOptionsForFlavor and getNextModelForFlavor. Without them, Pi sessions fell through to the Claude preset cycler, which would push sonnet/opus ids into a Pi session via set-session-config. Mirrors the opencode handling introduced earlier. Tests added/updated: buildCliArgs covers pi + claude resume; handleResponse mirror test covers provider caching; modelOptions tests cover pi no-fallback behavior for both option list and cycler. * fix(pi): add session resume support and fix review issues - Add piSessionId to MetadataSchema (shared/src/schemas.ts) - Persist piSessionId from get_state response to metadata (cli/src/pi/runPi.ts) - Pass --session-id to Pi spawn on resume (cli/src/pi/runPi.ts) - Add pi branch to resolveAgentResumeId (hub/src/sync/syncEngine.ts) - Add case 'pi' to resolveAgentSessionIdFromMetadata (web/src/lib/sessionResume.ts) - Replace pi resume skip guard with --session-id forwarding (cli/src/runner/run.ts) - Preserve piSessionId in pickExistingSessionMetadata (cli/src/agent/sessionFactory.ts) - Add pi badge to AgentFlavorIcon (web/src/components/AgentFlavorIcon.tsx) - Fix transport.onClose crash-marking on normal shutdown (cli/src/pi/runPi.ts) * fix(pi): review round 1 - 3 must-fix issues - resume.ts: add pi branch to dispatchLocalResume() so hapi resume dispatches to runPi instead of falling through to cursor - runPi.ts: accept existingSessionId and use bootstrapExistingSession when resuming, matching other agents' pattern - agentCommandOptions.ts: parse --session-id in addition to --resume so runner-spawned pi resume actually forwards the session ID - types.ts: export PiPermissionMode alongside other agent permission mode types for consistent import convention * fix(pi): review round 2 - 2 must-fix issues * refactor(workflow): improve pi-adaptation-review-loop robustness - Switch from structured output to file-based JSON output for reliability - Replace per-round file limit (20→30) with clear wording (remove misleading split-commits instruction) - Return { data, error } from readResultFile() to surface parse/validation failures in abortReason - Fix lastMustFix sentinel: initialize to null, use ?? for explicit N/A reporting - Add getAgentDirs() to dynamically discover agent dirs from cli/src/ - Document rollbackTo() atomic-round design intent - Add isValidIssue() validation, runFinalCleanup() helper, git repo pre-check * test(pi): add coverage for pi flavor across shared, cli, and web - shared/flavors.test.ts: pi/kimi capability, label, known, supports - shared/modes.test.ts: PI_PERMISSION_MODES contract, per-mode checks (7-mode allowed/denied matrix) - web/AssistantChat/modelOptions.test.ts: pi shortcut vs Claude cycler, normalize filter (auto/default/whitespace), kimi/cursor/ opencode cross-flavor consistency - web/lib/sessionResume.test.ts: piSessionId resolver, cross-flavor stale-id protection, inactiveSessionCanResume for pi, regression coverage for all 6 other flavors - web/components/AgentFlavorIcon.test.tsx: pi badge styling (bg-[#5b21b6]), Un fallback, case/whitespace normalize, className override - cli/commands/agentCommandOptions.test.ts: --session-id (pi-specific flag), --resume alias, PI mode validation, --yolo vs explicit-mode priority 137 new test cases, all passing. Full suite: 96 files / 933 tests green (unrelated apiMachine.test.ts macOS /private/var path issue remains as documented in handoff). * feat(pi): implement P0 — context budget bar + dynamic model discovery P0-1: Context Budget Bar - Add pi branch to modelConfig.ts getContextBudgetTokens() - Conservative 200K default context window for Pi sessions P0-2: CLI-side model discovery - Add get_available_models to PiRpcCommand type - Auto-send get_available_models after get_state in runPi.ts - Cache model list and push to session metadata - Register ListPiModels RPC handler with promise-based transport query P0-3: Hub-side routing - Add listPiModelsForSession to rpcGateway and syncEngine - Add REST endpoint GET /sessions/:id/pi-models (pi sessions only) P0-4: Web-side rendering - Add PiModelSummary type to shared apiTypes - Add usePiModels hook (TanStack Query, stale 60s) - Add getSessionPiModels to API client - Add sessionPiModels query key - Wire piModelOptions into SessionChat availableModelOptions - Model dropdown renders discovered models or falls back to Default * fix(pi): address code review findings + pre-existing test issue Review fixes: - Fix race condition in sendPiRpcAndWait: use incremental id as key instead of command type, preventing resolver overwrite on concurrent calls (e.g. auto-discovery + ListPiModels RPC) - Extract parsePiModels() to eliminate duplicated model parsing logic between handleResponse and ListPiModels RPC handler (DRY) - Add resolvePendingRpc() call in error response path to prevent promise leaks when Pi rejects an RPC with an id - Add piModelsState.error guard to onModelChange in SessionChat, matching the pattern used by codex and cursor flavors Pre-existing fix: - Fix apiMachine.test.ts symlink assertion on macOS (/var vs /private/var) by applying realpathSync to the expected path * feat(pi): P1 — session rename sync, thinking level UI, skills/commands P1-1: Session Rename → Pi notification - Add set_session_name to PiRpcCommand - Register RenamePiSession RPC handler in CLI - Hub syncEngine.renameSession now forwards to Pi CLI for active sessions - Hub rpcGateway + REST endpoint added P1-2: Thinking Level support - Add Pi thinking level constants to shared/src/piThinkingLevel.ts (off/minimal/low/medium/high/xhigh) - Add ThinkingLevel capability to Pi flavor in flavors.ts - sessionConfigRpc now supports effortMode for Pi thinking level - runPi captures thinkingLevel from get_state and forwards via set_thinking_level - Hub effort endpoint accepts pi sessions (was claude-only) - Web: piThinkingLevelOptions.ts + HappyComposer renders Pi options when flavor=pi P1-3: Skills/Commands discovery - Add get_commands to PiRpcCommand, auto-discover after get_state - Register ListPiCommands + ListSlashCommands RPC handlers in CLI (maps Pi commands to HAPI SlashCommand format) - Hub: listPiCommandsForSession + REST GET /sessions/:id/pi-commands - Web: usePiCommands hook + api client + query keys Also fixes: - Pre-existing ZodError.errors → ZodError.issues in hub/socket/server.ts - Updated test expectation for effort endpoint error message * feat(pi): implement P2 features — steer, queue modes, history, native images P2-1: Steer/Follow-up - Track piIsStreaming state from agent_start/turn_start/turn_end/agent_end - When streaming, onUserMessage sends steer instead of prompt - Added PiSteer/PiFollowUp RPC methods + hub routing + REST endpoints P2-2: Queue modes - Added set_steering_mode/set_follow_up_mode to PiRpcCommand - CLI RPC handlers with mode state tracking - Hub routing + REST POST endpoints - Web API client methods P2-3: History replay - Added get_messages to PiRpcCommand - CLI handler converts Pi AgentMessage to PiMessageEntry format - Hub RPC routing + REST GET /sessions/:id/pi-messages - Web usePiMessages hook + query key P2-4: Native image passing - Added PiImageContent type for base64 image data - extractPiImages() helper reads attachment files as base64 - prompt/steer commands now include images field - Falls back to @path text reference for non-image/unreadable files * feat(pi): implement P3 advanced features — compact, fork, clone, switch, stats, export P3 features for Pi agent integration: - Compact: compact RPC with custom instructions, set_auto_compaction toggle - Fork: fork at entry ID, get_fork_messages for fork context - Clone: clone current Pi session - Switch Session: switch Pi to a different session by path - Session Stats: get token counts, message counts, cost - HTML Export: export session as HTML file All features follow existing P2 pattern: - CLI: RPC handlers in runPi.ts with sendPiRpcAndWait - Hub: rpcGateway + syncEngine routing + REST endpoints - Web: API client methods + query keys + type exports + hooks (stats, fork messages) Total: 8 new REST endpoints, 9 RPC handlers, 6 web API methods Typecheck: all 3 packages pass (cli+hub+web) Tests: 1155 pass (263 hub + 803 web + 89 shared), 0 failures * refactor(pi): clean up runPi.ts imports and readability - Replace require('fs') with top-level import { readFileSync } from 'fs' - Extract handleGetState() as standalone function from handleResponse switch case (get_state case: 35 lines → 4 lines dispatch) Typecheck: all 3 packages pass Tests: 1066 pass (263 hub + 803 web), 0 failures * fix(pi): remove native image passing, fix version pollution - Remove extractPiImages helper and PiImageContent type: all attachments now use @path text references via formatMessageWithAttachments, consistent with every other agent - Remove images field from prompt/steer/follow_up RPC commands - Remove unused readFileSync import - Restore cli/package.json version from test pollution (0.0.0-integration-test-should-be-auto-cleaned-up-51369 → 0.20.0) Typecheck: all 3 packages pass Tests: 1286 pass, 0 failures * refactor(pi): extract hub helper, unify web hooks, fix import style - Hub: extract withPiSession helper eliminating boilerplate across 15 Pi REST endpoints (~400 lines → ~150 lines) - Web: unify usePiForkMessages and usePiSessionStats to return destructured typed fields matching usePiModels/usePiCommands pattern - Web: move 15 Pi response types from inline import() to top-level named imports in api/client.ts - CLI: remove duplicate PiCommandSummary/PiCommandsResponse from types.ts, re-export from @hapi/protocol/apiTypes Typecheck: all 3 packages pass Tests: 1286 pass, 0 failures * chore: untrack .agents/skills and .pi, fix .xyz-harness in gitignore * refactor: remove unused text message id from converter layer, update gitignore * fix: update tests for pi resume support and text id removal * fix: restore cursor resume branch in buildCliArgs * refactor: remove pi-specific rename from syncEngine, align with other agents * refactor: remove effort field from sessionConfigRpc, Pi self-handles RPC Pi agent now self-handles SetSessionConfig RPC (like Claude) using the existing field, instead of adding a parallel field to the shared sessionConfigRpc helper which only knows about . - Remove effort/effortMode from sessionConfigRpc types and logic - runPi.ts: self-register RPC handler with PiThinkingLevel validation - Reuse resolveSessionConfigPermissionMode from sessionConfigRpc * refactor: consolidate Pi RPC layer from 36 methods to 3 generics rpcGateway: 12 methods → callPiRpc syncEngine: 12 passthroughs → callPiRpc delegate web client: 12 methods → callPiEndpoint routes: use engine.callPiRpc with RPC_METHODS constants hooks: use callPiEndpoint, add missing type imports * chore: revert unrelated apiMachine test change * refactor: remove unused ThinkingLevel capability from flavors Pi's thinking level is an effort variant, not a separate capability. The ThinkingLevel constant and supportsThinkingLevel() had zero callers — the frontend uses flavor-based branching for effort option rendering. * refactor: drop Pi prefix from generic RPC method names * refactor: remove 13 Pi RPC methods with no UI consumers Steer: already handled by onUserMessage auto-routing Follow-up: redundant with HAPI message queue ListPiCommands/GetMessages/ForkMessages/SessionStats: no UI Compact/SetAutoCompaction/Fork/Clone/SwitchSession/ExportHtml: no UI SetSteeringMode/SetFollowUpMode: no UI Kept: ListPiModels (has UI), SetSessionConfig, ListSlashCommands, Abort, Switch Deleted: 4 web hooks, 13 RPC handlers, 12 REST routes, 13 rpcMethods entries Net: -730 lines * refactor: extract session.ts and loop.ts from runPi.ts Restructure Pi agent following Codex pattern (without Local/Remote splitting since Pi only has remote mode): - session.ts: PiSession class managing state + hub communication - loop.ts: response parsing, RPC resolver, transport event wiring - runPi.ts: thin entry (bootstrap, RPC handlers, lifecycle) Changes from review: - Encapsulate RPC resolver in PiRpcResolver class (session-scoped, not module-level singleton) - Remove unused extractTextFromPiMessage export - Fix inline import('./types') → top-level import * refactor: normalize Pi file naming and improve test coverage - Rename PiTransport.ts → piTransport.ts, PiEventConverter.ts → piEventConverter.ts, PiMessageAccumulator.ts → piMessageAccumulator.ts (match project-wide camelCase convention) - Delete handleResponse.test.ts (tested stale copy of inline function) - Add loop.test.ts with 20 tests covering parsePiModels, parsePiCommands, wireTransportEvents integration, and sendPiRpcAndWait - Total Pi tests: 73 (was 53) * test: add E2E harness with 4 core helpers and integration specs Helper functions in e2e/harness.ts capture the four non-obvious interactions discovered during the 2026-06-09 retest: - longPress: SessionActionMenu is triggered by 500ms press, not click - mockOffline: useOnlineStatus hook listens to navigator.onLine + window offline event, not CDP Network.emulateNetworkConditions - pollForText: thinking indicator flickers in <1s, 3s polling misses - isVisible: element.offsetParent returns null for position:fixed dialogs even when visible; use getBoundingClientRect Plus Chrome lifecycle (startChrome/stopChrome, never pkill chrome) and hub API helpers (loginWithToken, listSessions). 5 integration specs (e2e/integration/) cover: - yolo-permission: toggle + localStorage persistence (4 cases) - codex-dialog: pre-flight check + dialog render (3 cases) - stress: 10 concurrent + invalid JWT + malformed + unknown endpoint (5 cases, all PASS) All 12 integration cases pass. Full E2E results in .xzy-harness/2026-06-09-full-e2e-retest/ (67 cases, 0 functional bugs found). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: resolve Pi model selection and thinking level issues - Fix PiModelPanel: use provider+modelId composite for selection check and React key, preventing duplicate highlights for same-name models across different providers - Fix PiThinkingLevelPanel: unify thinkingLevelMap filtering logic by extracting shared isThinkingLevelSupported utility - Fix HappyComposer: auto-reset effort to highest supported level when switching models, update label to reflect effective level * refactor: remove 29 dead exports from feat-pi-support Remove unused types, methods, and re-exports identified by dead code audit: shared/src/apiTypes.ts (19): - SessionModelIdentifier, ListPiCommandsResponse - PiSteeringMode, PiFollowUpMode, PiSteerResponse, PiFollowUpResponse - PiQueueModeResponse, PiMessageEntry, PiMessagesResponse - PiCompactResponse, PiSetAutoCompactionResponse - PiForkResponse, PiForkMessageEntry, PiForkMessagesResponse - PiCloneResponse, PiSwitchSessionResponse - PiSessionStats, PiSessionStatsResponse, PiExportHtmlResponse cli/src/pi/types.ts (6): - PiSessionStats, PiCompactionResult, PiForkMessageEntry (dead local duplicates) - PiCommandsResponse, PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS (dead re-exports) cli/src/pi/piMessageAccumulator.ts (1): - flushIfActive() method (comment claimed runPi calls it, but it doesn't) cli/src/pi/piTransport.ts (1): - isRunning() method (never called in production code) web/ (2): - ProviderGroup, PiThinkingLevelOption (unnecessary exports, made local) Co-Authored-By: Claude Opus 4.8 * fix: resolve 7 PR review issues in Pi support #3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol #2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency) #6 Replace hardcoded flavor names with supportsEffort() in effort route #1 Move PiRpcResolver from module-level singleton to PiSession instance #4 Add piCachedModels fallback in piModelOptions useMemo #7 Merge message_update dead branch into unified not-converted case #10 Fix misleading Pi model list comments in modelOptions.ts Co-Authored-By: Claude Opus 4.8 * fix: normalize Pi model object to string in hub sessionCache (#5), remove extra blank line in rpcGateway (#8) #5: applySessionConfig now extracts modelId from { provider, modelId } before passing to setSessionModel / session.model, preventing [object Object] from being stored in SQLite when Pi switches models. #8: Remove double blank line before RpcGateway class declaration. Co-Authored-By: Claude Opus 4.8 * fix(pi): preserve piAvailableModels on resume, document SetSessionConfig divergence - sessionFactory: preserve piAvailableModels in pickExistingSessionMetadata so web shows cached models on inactive-session view without RPC round-trip - sessionConfigRpc: extend resolveNullableSessionModel to accept {provider, modelId} object form for schema consistency - runPi: document why Pi manually registers SetSessionConfig instead of reusing registerSessionConfigRpc (wire protocol needs separate fields) - package.json: restore version to 0.20.0 * refactor: remove unused Pi types, extract JsonLineParser, clean up review findings - Remove 13 unused PiRpcCommand variants and PiStreamingBehavior type (YAGNI) - Remove unnecessary exports on 3 internal Zod schemas in pi/schemas.ts - Extract JsonLineParser base class to utils/, shared by PiTransport, CodexAppServerClient, and AcpStdioTransport (eliminates 3x duplicate handleStdout buffer logic) - Remove DEV-only duplicate session ID detection from SessionList.tsx (debug code unrelated to Pi support scope) - Add comments explaining key prefix rationale in SessionChat.tsx * chore: remove unrelated E2E test harness from Pi support PR E2E harness (codex-dialog, stress, yolo-permission, scratchlist specs) was introduced in this branch but tests generic HAPI behavior unrelated to Pi agent support. Should live in a separate PR. * fix: wrap cursor model change handler for union type compatibility * fix: apply startup --model to Pi and remove duplicate lockfile entry 1. --model startup bug: - Add initialModel to PiSession to preserve startup model - handleGetState preserves initialModel instead of overwriting with Pi default - get_available_models handler resolves provider from cached models and sends set_model 2. bun.lock duplicate key: - Remove duplicate @twsxtd/hapi-win32-x64@0.20.0 entry - Fixes CI lockfile regeneration that caused hono type errors * fix: update test expectation for effort endpoint error message * fix(pi): resolve 8 link-review defects + abort session termination - W1C-D-1: hasSameAgentSessionIds missing piSessionId/kimiSessionId + extractAgentSessionId also needs piSessionId recognition - D-1: dispatchLocalResume pi branch missing effort param - W1B-1-01: buildCliArgs only passes --effort for claude, not pi - W2B-D-2: effort=null does not send set_thinking_level to Pi - D-3: turn_start does not consume pendingLocalIds - D-7: keep_alive falls into default case in convertPiEvent - D-9: finally overwrites sessionEndReason set by Switch/Abort - W2B-D-3: ListPiModels RPC does not update metadata - Abort handler: remove cleanupAndExit, only cancel current turn Also: Switch handler returns { success: true } for consistency Test coverage: 13 new test cases across 5 files * fix: restore cli version from integration test placeholder * fix(pi): send restored thinking level to Pi subprocess on startup opts.effort was stored in piSession.currentThinkingLevel but never forwarded via set_thinking_level during the startup sequence, causing runner-spawned and resumed sessions to show the restored effort in HAPI while Pi kept its default. * fix: restore cli package version from integration test residue * fix(pi): switch-to-remote handler preserves session instead of terminating Replace lifecycle.cleanupAndExit() with createModeChangeHandler + keepAlive in the Switch RPC handler. Pi runs as a single long-lived subprocess without BaseLocalLauncher's restart loop, so cleanupAndExit() permanently destroyed the session on mode switch. The web handoff button now correctly changes control mode while keeping Pi alive. * fix(pi): remove permission mode selector (Pi RPC has no runtime switching) Pi's --mode rpc is non-interactive and auto-approves all tool execution; there is no set_permission_mode command in the protocol. The selector reported success without changing Pi's behavior, misleading users. Remove the concept across all four packages: - shared: getPermissionModesForFlavor('pi') returns [] (cascades to hub 400 + web UI auto-hide via length===0 guards); drop PI_PERMISSION_MODES / PiPermissionMode - cli: strip permissionMode from PiSession/runPi/pi command/resume; drop the no-op SetSessionConfig permission branch that stored state without forwarding to the subprocess - web: delete PiPermissionPanel.tsx; remove panel block + imports from HappyComposer * fix(cli): realpath workspace root in apiMachine test assertion The handler realpaths the cwd as a symlink-escape guard, so on macOS /var/folders/... resolves to /private/var/folders/... The test compared against the un-resolved path and failed on macOS. Use realpathSync on the expected value for cross-platform consistency (no-op on Linux where /tmp has no symlink prefix). * fix(pi): keepalive reads current mode instead of constructor-time startingMode The Switch handler updated controlledByUser but PiSession.pushKeepAlive() still emitted the readonly startingMode every 2s, so a runner-started session switched to local would flip back to remote on the next keepalive. Replace readonly startingMode with a mutable mode field; add setMode() that updates it and re-pushes keepAlive immediately. The Switch RPC handler now calls setMode() before handleModeChange. * fix(pi): runner no longer passes permission flags to Pi subprocess After removing the Pi permission selector, the Pi command parser rejects --permission-mode and ignores --yolo. But the shared buildCliArgs tail in the runner still appended these flags for Pi sessions, making runner- spawned Pi children exit before registering a session. Guard the permission/yolo append with agent !== 'pi'. * fix(pi): preserve provider identity when persisting selected Pi model The hub's applySessionConfig normalized Pi's { provider, modelId } object down to a plain modelId string for the shared session.model field, losing the provider. On reload or next render, web's selectedPiModel lookup matched by modelId alone — if two providers share a modelId, the wrong one was highlighted, and subsequent model/thinking-level changes sent the wrong provider to the Pi subprocess. Add a provider-qualified piSelectedModel field to session metadata (schema + persistPiSelectedModel mirroring persistPreferredPermissionMode). Web's selectedPiModel now prefers the provider-qualified match and only falls back to modelId-only matching when absent. * fix(pi): model picker checkmark follows provider-qualified selection selectedPiModel already resolves via provider+modelId, but the model panel's currentPiModel still matched by modelId alone — so with two providers sharing a modelId the checkmark pointed at the wrong row. Reuse selectedPiModel directly. * fix(pi): steer messages consumed immediately, not queued in pendingLocalIds onUserMessage unconditionally pushed localId into pendingLocalIds, but a steer (sent while piIsStreaming) does not start a new turn — so the steer's localId was never drained by turn_start. The next normal prompt's turn_start would consume the stale steer localId instead, leaving the new prompt's bubble stuck in the queued bar. Only queue localId for the prompt path. Steer path emits messages-consumed immediately. * fix(pi): clear stale thinking level when switching to non-reasoning model The model-change effect early-returned when selectedPiModel.reasoning === false, leaving the previously-set effort (e.g. 'high') persisted on the session. The UI hid the thinking picker for the non-reasoning model, but the hub still forwarded the stale effort as set_thinking_level — with no visible control to clear it. Call onEffortChange(null) for non-reasoning models. * fix(pi): return provider-qualified model in SetSessionConfig applied The CLI handler returned only currentModel (bare string), so the hub's applySessionConfig saw a non-object model and cleared metadata.piSelectedModel via persistPiSelectedModel(session, null) — undoing the provider that was just stored on the inbound config. Return { provider, modelId } when both are known so the hub keeps the provider-qualified metadata intact across active model changes. * fix(pi): preserve piSelectedModel in bootstrapExistingSession metadata The metadata whitelist rebuild kept piAvailableModels but omitted piSelectedModel, so the first resume/local-handoff update dropped the provider identity — after which web fell back to modelId-only matching and could select the wrong provider for duplicate modelIds. * fix(pi): await Pi confirmation before reporting model/effort applied SetSessionConfig was fire-and-forget — transport.send wrote JSONL to stdin and returned immediately. If Pi rejected an invalid provider/model or thinking level, the hub still persisted the new value and the UI reported success while Pi kept the old runtime state. Use sendPiRpcAndWait so a failed set_model/set_thinking_level rejects the web request and leaves the session config unchanged. * fix(pi): resolve set_model RPC so awaited model switch does not time out SetSessionConfig awaits sendPiRpcAndWait(set_model) before reporting the model applied, but handleResponse's set_model branch updated state and fell through without calling resolvePendingRpc. The pending RPC promise then waited the full 10s timeout and rejected, making /sessions/:id/model return 409 even though Pi accepted the change. Mirror every other branch by resolving the pending RPC after updating currentModel/currentProvider. * fix(pi): drain pending localId on turn_start only; throw when set_model suppressed - loop.ts: split agent_start/turn_start branches. Pi emits both per prompt; draining on both popped the FIFO twice and shipped an undefined localId to the hub. agent_start now only sets thinking state; turn_start drains. - runPi.ts: when set_model is suppressed (provider unknown), throw instead of silently returning applied, so the hub returns 409 rather than persisting a piSelectedModel Pi never received. - loop.test.ts: assert agent_start does not drain; add regression test that a single turn drains exactly one real localId. * fix(pi): exclude Pi from generic Ctrl/Cmd+M model cycler SessionChat fed piModelOptions into HappyComposer.availableModelOptions, so the global Ctrl/Cmd+M shortcut ran getNextModelForFlavor over the Pi list and called onModelChange with a bare modelId string. Pi needs { provider, modelId } to disambiguate duplicate model IDs across providers; a bare string made runPi fall back to the first cached provider match (wrong provider) or throw when the provider was unknown. Drop the piModelOptions useMemo and pass undefined for Pi, mirroring modelOptions.ts where the Pi branch already returns the current model unchanged (no-op) when no custom options are supplied. Pi model changes now go only through the dedicated provider-qualified picker (piModels). * fix(pi): commit PiSession config only after Pi confirms the RPC SetSessionConfig previously mutated piSession.currentModel / currentProvider / currentThinkingLevel BEFORE awaiting sendPiRpcAndWait(set_model / set_thinking_level). When Pi rejected the value or the RPC timed out, the handler threw and the route returned 409, but PiSession kept the unconfirmed values; the 2s keepalive then reported them back to the hub, where handleSessionAlive persisted a model/effort Pi never accepted. Resolve the requested model/effort into locals first, send the RPCs, and only commit to PiSession after each await resolves. The null (clear-model) path needs no RPC so it still commits immediately; the unknown-provider path still throws without committing. * fix(pi): apply startup model only after Pi confirms set_model Two startup paths persisted the requested --model before Pi confirmed it: 1. handleGetState set session.currentModel = session.initialModel as soon as get_state returned, using the unconfirmed startup model instead of Pi's actual default. If the model was unavailable or rejected, the 2s keepAlive reported it to the hub, which persisted/showed a model Pi never accepted. 2. get_available_models then sent set_model fire-and-forget, so a Pi rejection was never observed and currentModel stayed on the bad value. Fix: handleGetState now reports Pi's real current model (newModel) while a startup model is merely requested. get_available_models resolves the provider from the cached list, awaits set_model, and commits currentModel/currentProvider only on success — on rejection it logs and keeps Pi's default. The await is fired detached so the get_available_models RPC itself still resolves for ListPiModels. * fix(pi): do not persist startup model before Pi confirms set_model The startup --model still reached the hub unconfirmed via two paths the previous Fix #13 left open: 1. bootstrapSession({ model: opts.model }) seeded the hub session model at creation time, and SessionCache.handleSessionAlive persists every non-undefined keepAlive model — so an unavailable/rejected model was stored and shown before get_available_models/set_model ran. 2. PiSession constructor set this.currentModel = opts.model, so the very first keepAlive (sent by startKeepAlive before any RPC confirms the model) reported the unconfirmed value. Pass model: undefined to bootstrapSession and start PiSession.currentModel at null; opts.model is still captured as initialModel and applied/committed only after get_available_models confirms it exists and set_model succeeds (Fix #13). The hub now sees Pi's real current model from the first get_state keepAlive and switches to the requested model only once accepted. Also add sendPiRpcAndWait contract tests pinning the await<->resolve symmetry (Fix #10): set_model/set_thinking_level/get_available_models must resolve before timeout on a success response, and reject on a Pi error. * fix(pi): apply startup effort only after Pi confirms set_thinking_level runPi restored opts.effort straight into piSession.currentThinkingLevel before startKeepAlive ran, and pushKeepAlive persists effort — so a resumed/runner-spawned session could store/show a thinking level Pi rejected or ignored. This is the effort analog of the startup-model confirmation contract (Fix #13/#14). Capture the requested effort into a local startupThinkingLevel instead of mutating currentThinkingLevel up front. After transport.start() and the get_state/get_available_models/get_commands sends, await set_thinking_level and commit currentThinkingLevel + push a keepAlive only on success; on rejection keep Pi's default (already reported by get_state). The await is detached so the run loop is not blocked, and get_state is sent before the set so its authoritative baseline lands first and cannot clobber the confirmed value. * fix(pi): omit unknown runtime config from keepalive, don't clear persisted state Fix #14 changed PiSession.currentModel to start at null so the startup --model was not leaked before confirmation. But the hub treats keepAlive model:null as an explicit clear (sessionCache.ts only skips when the field is undefined), so the first heartbeat (startKeepAlive runs before get_state) now erased a resumed Pi session's persisted model/effort before Pi reported its real state. Distinguish "unknown" from "clear": currentModel/currentThinkingLevel start undefined and keepAlive omits undefined fields (via getKeepAliveRuntime), so the hub leaves persisted values alone until Pi confirms. null remains an explicit clear and is still forwarded. Once get_state/set_model/set_thinking_level confirm a value it is set and reported normally. * fix(pi): disable Ctrl/Cmd+M model cycler for Pi entirely Fix #11 removed piModelOptions from availableModelOptions, assuming getNextModelForFlavor('pi', model, undefined) was a no-op. It is not: the Pi branch returns normalizeCurrentModel(model), i.e. the current modelId as a bare string, so the shortcut still called onModelChange with a bare modelId. That loses the provider and can pick the wrong cached match, clear the model when session.model is empty, or hit 'provider is not yet known'. Short-circuit the handler for Pi so model changes go only through the dedicated provider-qualified PiModelPanel. * fix(pi): persist piSelectedModel from get_state and startup set_model paths Pi stores session.model as the bare modelId and relies on metadata.piSelectedModel ({ provider, modelId }) to disambiguate duplicate modelId values across providers in the web picker and thinking-level filtering. But piSelectedModel was only written by the web /sessions/:id/model path (hub persistPiSelectedModel). The runtime paths that set currentModel/currentProvider — get_state, the startup get_available_models set_model, and the set_model response — only keepAlive'd the bare modelId, so a Pi session on Pi's default model, resumed from CLI, or started with --model had no provider identity in metadata and could render/filter against the wrong provider. Add persistSelectedPiModel(session) (no-op unless both fields are known) and call it after get_state, after a successful startup set_model, and after the set_model response updates the fields. This mirrors what the web picker already does. * fix(pi): default startingMode to remote — Pi has no local TUI path A terminal `hapi pi` launch defaulted to startingMode 'local' and marked the session controlledByUser, but Pi only runs as `pi --mode rpc` with piped stdio — there is no local terminal/TUI input path like Claude/Codex have. The terminal user could not drive the session and the web treated it as local-controlled, so the first terminal Pi session was stuck until manually switched from the web. Default to 'remote' so the session is immediately drivable from the web. An explicit opts.startingMode (runner path) still takes precedence. * fix(pi): resume with remote startingMode — no local TUI path The previous Fix #19 changed the `hapi pi` default to remote, but `hapi resume` still passed startingMode: 'local' into runPi for Pi sessions, re-introducing the same unsupported local-control state on the resume path: setControlledByUser publishes controlledByUser while Pi has no terminal/TUI input, hiding/rejecting remote-only controls until a web switch. Pass 'remote' here too and update the resume test accordingly. * fix: restore e2e/scratchlist.spec.ts deleted from main by mistake The earlier "remove unrelated E2E harness" commit (d1e5b4c) deleted the whole e2e/ directory this branch had added, but scratchlist.spec.ts is a main-branch Playwright spec (the only file under playwright testDir ./e2e). Its removal left `bun run test:e2e` with no tests to run while the script and playwright.config.ts still point at that directory. Restore scratchlist.spec.ts from main; the unrelated harness files (HARNESS.md, harness.*, integration/*.mts) that were genuinely branch-only additions stay removed. --------- Co-authored-by: pi Co-authored-by: Claude --- .gitignore | 3 + bun.lock | 2 + cli/package.json | 2 +- cli/src/agent/localHandoff.test.ts | 1 + cli/src/agent/runnerLifecycle.test.ts | 87 +++ cli/src/agent/runnerLifecycle.ts | 6 + cli/src/agent/sessionConfigRpc.ts | 12 + cli/src/agent/sessionFactory.ts | 6 + cli/src/api/apiMachine.test.ts | 6 +- cli/src/codex/codexAppServerClient.ts | 26 +- cli/src/codex/runCodex.test.ts | 3 +- cli/src/commands/agentCommandOptions.test.ts | 108 ++++ cli/src/commands/agentCommandOptions.ts | 14 + cli/src/commands/pi.ts | 32 ++ cli/src/commands/registry.ts | 2 + cli/src/commands/resume.test.ts | 34 ++ cli/src/commands/resume.ts | 17 + cli/src/commands/runCli.ts | 2 +- cli/src/gemini/runGemini.test.ts | 3 +- cli/src/opencode/runOpencode.test.ts | 3 +- cli/src/pi/loop.test.ts | 509 ++++++++++++++++++ cli/src/pi/loop.ts | 312 +++++++++++ cli/src/pi/piEventConverter.test.ts | 224 ++++++++ cli/src/pi/piEventConverter.ts | 88 +++ cli/src/pi/piMessageAccumulator.test.ts | 211 ++++++++ cli/src/pi/piMessageAccumulator.ts | 96 ++++ cli/src/pi/piTransport.test.ts | 210 ++++++++ cli/src/pi/piTransport.ts | 123 +++++ cli/src/pi/runPi.ts | 403 ++++++++++++++ cli/src/pi/schemas.ts | 203 +++++++ cli/src/pi/session.ts | 127 +++++ cli/src/pi/types.ts | 119 ++++ cli/src/runner/buildCliArgs.test.ts | 40 ++ cli/src/runner/run.ts | 21 +- cli/src/utils/jsonLineParser.ts | 35 ++ hub/src/sync/rpcGateway.ts | 8 +- hub/src/sync/sessionCache.ts | 51 +- hub/src/sync/sessionModel.test.ts | 56 ++ hub/src/sync/syncEngine.ts | 14 +- hub/src/web/routes/sessions.test.ts | 2 +- hub/src/web/routes/sessions.ts | 41 +- shared/src/apiTypes.ts | 43 +- shared/src/flavors.test.ts | 18 + shared/src/flavors.ts | 2 + shared/src/index.ts | 1 + shared/src/modes.test.ts | 66 ++- shared/src/modes.ts | 7 +- shared/src/piThinkingLevel.ts | 13 + shared/src/rpcMethods.ts | 1 + shared/src/schemas.ts | 11 +- web/src/api/client.ts | 10 +- web/src/chat/modelConfig.ts | 9 + web/src/components/AgentFlavorIcon.test.tsx | 101 ++++ web/src/components/AgentFlavorIcon.tsx | 4 + .../AssistantChat/ComposerButtons.tsx | 49 ++ .../AssistantChat/HappyComposer.tsx | 306 ++++++++--- .../components/AssistantChat/PiModelPanel.tsx | 72 +++ .../AssistantChat/PiThinkingLevelPanel.tsx | 76 +++ .../AssistantChat/modelOptions.test.ts | 91 ++++ .../components/AssistantChat/modelOptions.ts | 13 + .../components/AssistantChat/piModelGroups.ts | 35 ++ .../AssistantChat/piThinkingLevelOptions.ts | 82 +++ web/src/components/NewSession/types.ts | 1 + web/src/components/SessionChat.tsx | 38 +- web/src/hooks/mutations/useSessionActions.ts | 4 +- web/src/hooks/queries/usePiModels.ts | 49 ++ web/src/lib/query-keys.ts | 1 + web/src/lib/sessionResume.test.ts | 129 +++++ web/src/lib/sessionResume.ts | 4 +- web/src/types/api.ts | 3 + 70 files changed, 4359 insertions(+), 142 deletions(-) create mode 100644 cli/src/agent/runnerLifecycle.test.ts create mode 100644 cli/src/commands/pi.ts create mode 100644 cli/src/pi/loop.test.ts create mode 100644 cli/src/pi/loop.ts create mode 100644 cli/src/pi/piEventConverter.test.ts create mode 100644 cli/src/pi/piEventConverter.ts create mode 100644 cli/src/pi/piMessageAccumulator.test.ts create mode 100644 cli/src/pi/piMessageAccumulator.ts create mode 100644 cli/src/pi/piTransport.test.ts create mode 100644 cli/src/pi/piTransport.ts create mode 100644 cli/src/pi/runPi.ts create mode 100644 cli/src/pi/schemas.ts create mode 100644 cli/src/pi/session.ts create mode 100644 cli/src/pi/types.ts create mode 100644 cli/src/utils/jsonLineParser.ts create mode 100644 shared/src/piThinkingLevel.ts create mode 100644 web/src/components/AgentFlavorIcon.test.tsx create mode 100644 web/src/components/AssistantChat/PiModelPanel.tsx create mode 100644 web/src/components/AssistantChat/PiThinkingLevelPanel.tsx create mode 100644 web/src/components/AssistantChat/piModelGroups.ts create mode 100644 web/src/components/AssistantChat/piThinkingLevelOptions.ts create mode 100644 web/src/hooks/queries/usePiModels.ts diff --git a/.gitignore b/.gitignore index 1ab099e2..7c12eb73 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 8400dfe3..7a40e439 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 ebfd9227..0cfc6602 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 1405a944..d1c1f4be 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 00000000..ec9c3d99 --- /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 0ae8faa9..d632c17a 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 c8e72e79..6795e5e7 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 c6e26431..fcc26296 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 95603941..5af3186c 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 f0f8510f..0aa2e72b 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 0dca6b8f..7c138bdd 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -60,7 +60,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 7561774f..f70c3e4a 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 0e2e271b..f7e8b29c 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 6ff36916..7e93ca4f 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 fa0f254d..73b0de0a 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 353f7cab..cf2af593 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 3cc404c4..7a7d7bb9 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 b6ef8e58..9b9b3be4 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 41ef11ad..4537ca0a 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 00000000..626abcd0 --- /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 00000000..d4f48256 --- /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 00000000..aaa0a5d0 --- /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 00000000..360b83a9 --- /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 00000000..1631599a --- /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 00000000..c8ffc189 --- /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 00000000..be9c8c0b --- /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 00000000..8d4a99ce --- /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 00000000..430a0479 --- /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 00000000..3532265a --- /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 00000000..e5740fe3 --- /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 00000000..ded9607b --- /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 f80b6e22..fb6d7648 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -98,4 +98,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 9a19a87f..ea827f87 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,7 +1121,7 @@ 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')) { @@ -1125,10 +1130,14 @@ export function buildCliArgs( if (options.serviceTier && agent === 'codex') { args.push('--service-tier', options.serviceTier); } - 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 00000000..c4b08d29 --- /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 7811c901..193eedd0 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 @@ -261,6 +261,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 b498cac0..86dd02a5 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -417,7 +417,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 serviceTier?: string | null @@ -436,15 +436,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) { @@ -923,6 +935,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 @@ -948,12 +988,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 5de15ce2..8489ade9 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1830,6 +1830,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 a1e73686..1d733cee 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -618,7 +618,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 serviceTier?: string | null @@ -634,7 +634,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') } @@ -654,7 +654,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 @@ -714,6 +714,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) } @@ -1405,6 +1406,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 { @@ -1520,6 +1523,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 fc1a7032..d1875c88 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -576,7 +576,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 b6f84e66..8deb54d0 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -12,11 +12,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' @@ -536,8 +538,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 { @@ -834,5 +836,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 7262c618..e6381da7 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 @@ -386,6 +392,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 a92595f1..0d745950 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 a4832e93..15c59df3 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 b8f5e291..0aaf85f2 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 0b0a50c1..eb97fe10 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 06007c5f..a8d1c665 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 00000000..6f70b40c --- /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 0ac67fac..c4284451 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 f1c7f271..1760337e 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 ba33a892..d37e0828 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 }) @@ -634,6 +634,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 a079e030..65466a54 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 00000000..5dc776cb --- /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 b88f2518..796bea95 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 5b0325a6..0bb8484b 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 || showFastModeSettings)) { return (
@@ -765,81 +899,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} @@ -987,6 +1119,12 @@ export function HappyComposer(props: { return null }, [ showSettings, + showPiModelPanel, + showPiThinkingPanel, + agentFlavor, + piModels, + selectedPiModel, + closeAllPanels, showCollaborationSettings, showPermissionSettings, showModelSettings, @@ -1113,6 +1251,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 00000000..899b2086 --- /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 00000000..94029001 --- /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 ef189830..a7900cec 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 11a5fea4..9f350ecd 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 00000000..df0d33c0 --- /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 00000000..8a9ad358 --- /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 eaa3c823..0da14396 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, @@ -552,7 +565,6 @@ function SessionChatInner(props: SessionChatProps) { ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) : null ), [agentFlavor, cursorModelEffortOptions, props.session.model]) - const { abortSession, switchSession, @@ -792,7 +804,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') @@ -1015,7 +1027,11 @@ function SessionChatInner(props: SessionChatProps) {
0 ? piModelsState.availableModels : piCachedModels) : undefined} + piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} availableModelReasoningEffortOptions={ agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0 ? opencodeReasoningEffortState.options @@ -1153,9 +1177,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 bdbb59f3..d9e8b300 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 setServiceTier: (serviceTier: string | null) => Promise @@ -111,7 +111,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 00000000..61ffd96d --- /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 a0664af7..e7adcfb3 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 637ae965..31c5209b 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 061e08e5..5cb9fd58 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 b9f5d0f7..e3a78c81 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,