diff --git a/.github/workflows/overseer-replay.yml b/.github/workflows/overseer-replay.yml new file mode 100644 index 0000000000..64cf45195d --- /dev/null +++ b/.github/workflows/overseer-replay.yml @@ -0,0 +1,50 @@ +name: Overseer replay gate + +# Dedicated CI gate (build sequence Step 2.75 / prioritization §6): the replay +# harness + one-boss invariant (ADR-001) run on every PR that touches Overseer +# logic, inbox scoring, the event taxonomy, or the worker-emission contract. +# A failure here blocks merge — it is the single mechanical guard against +# salience regressions and one-boss attribution leaks. + +on: + push: + paths: + - 'hub/src/overseer/**' + - 'hub/src/store/events.ts' + - 'hub/src/store/eventStore.ts' + - 'hub/src/store/inboxItems.ts' + - 'hub/src/store/inboxStore.ts' + - 'hub/src/sync/overseerEventRecorder.ts' + - 'shared/src/overseerEvents.ts' + - 'shared/src/overseerInbox.ts' + - 'test/fixtures/overseer-replay/**' + - '.github/workflows/overseer-replay.yml' + pull_request: + paths: + - 'hub/src/overseer/**' + - 'hub/src/store/events.ts' + - 'hub/src/store/eventStore.ts' + - 'hub/src/store/inboxItems.ts' + - 'hub/src/store/inboxStore.ts' + - 'hub/src/sync/overseerEventRecorder.ts' + - 'shared/src/overseerEvents.ts' + - 'shared/src/overseerInbox.ts' + - 'test/fixtures/overseer-replay/**' + - '.github/workflows/overseer-replay.yml' + +jobs: + overseer-replay: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install + # Whole-repo typecheck (tsc compiles each package as one program) + # lives in test.yml; this gate runs the Overseer test surface so it + # stays green/red on Overseer correctness specifically. + - name: Overseer taxonomy + inbox scoring tests (shared) + run: cd shared && bun test src/overseerEvents.test.ts src/overseerInbox.test.ts + - name: Replay harness + one-boss invariant (hub) + run: cd hub && bun test src/overseer src/store/inboxItems.test.ts src/sync/overseerEventRecorder.test.ts src/sync/overseerEventRecorder.injection.test.ts diff --git a/README.md b/README.md index 450bb17208..1dc8d858d5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HAPI -Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. +Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. > **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences. @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. +- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode—different agents, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. - **Workspace Browser** - Opt-in via one or more `hapi runner start --workspace-root ` flags: browse scoped file trees from the web and start sessions in allowed subdirectories. @@ -38,6 +38,7 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ - [App](docs/guide/pwa.md) - [How it Works](docs/guide/how-it-works.md) - [Cursor Agent](docs/guide/cursor.md) +- [Grok Build](docs/guide/grok.md) - [Voice Assistant](docs/guide/voice-assistant.md) - [Why HAPI](docs/guide/why-hapi.md) - [FAQ](docs/guide/faq.md) diff --git a/bun.lock b/bun.lock index 7a40e43908..c2f0d593b1 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "cli": { "name": "@twsxtd/hapi", - "version": "0.20.2", + "version": "0.22.3", "bin": { "hapi": "bin/hapi.cjs", }, @@ -46,11 +46,11 @@ "vitest": "^4.0.16", }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.20.2", - "@twsxtd/hapi-darwin-x64": "0.20.2", - "@twsxtd/hapi-linux-arm64": "0.20.2", - "@twsxtd/hapi-linux-x64": "0.20.2", - "@twsxtd/hapi-win32-x64": "0.20.2", + "@twsxtd/hapi-darwin-arm64": "0.22.3", + "@twsxtd/hapi-darwin-x64": "0.22.3", + "@twsxtd/hapi-linux-arm64": "0.22.3", + "@twsxtd/hapi-linux-x64": "0.22.3", + "@twsxtd/hapi-win32-x64": "0.22.3", }, }, "docs": { @@ -99,6 +99,7 @@ "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", @@ -132,6 +133,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -1066,15 +1068,13 @@ "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], - "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.20.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-gV9qqvfNjmWcIRWscO20MtbfpHERgPm7gTiK3kLaylCObH+38UBi+4IqI64k9JuVDZ+XCf0b7h77iyLEUg91hg=="], - - "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.20.2", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-I3apIM91dJ8vpS4HgUkw2xo2o8ryUCDqvM6hNCgE9Ha/KEAXQ8bDxc1LcE5ga+LupluBiBeYpQ+XAHUKRVOARA=="], + "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.22.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-iG+MG4Ai6MJNaOs2G3QI/HepHLFIH4+nr/HiH/zhe2GAkwjzpay45mZ9SfzKe8EMEGJ2Wjsr7DKTufRVDrembA=="], - "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.20.2", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-efY3KuvPpHfUaVDaHYSL1NC9OFfm0GnC6r4V8aOHkFUc62MPytMcx6zHBKhMABb58dUTT7Xc264mdLqy+2OMMQ=="], + "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.22.3", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-J9a+/WjDZFEC35D7EvGfekP7EDI1lCcJk5Qidmoou5wRLy+W1nfVSWkCc45Dav/ub7ATsKB7c33eDecDaHeMFw=="], - "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="], + "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.22.3", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-Jr4kujGmhdJTK/RzJmQmT18byrbp2NpcXCWS6KOBbOT6GLLvY4ezITacnQLOF83em4BW++/MGDCyTwzEzmffQg=="], - "@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=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.22.3", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-0Ye8J01M4zsp3H8qGJ9qfx+1cAyZF1BuuqADZJ3eSWmeDfvSZeYxwwSQhgt5R9cpZqWUYCYgHjROzpverOfBnw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/cli/README.md b/cli/README.md index 7b707dce52..0f67be469d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,12 +1,13 @@ # hapi CLI -Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. +Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. - Starts Cursor Agent mode for Cursor CLI sessions. +- Starts Grok Build locally or via ACP for remote sessions. - Starts Gemini mode via ACP (Anthropic Code Plugins). - Starts OpenCode mode via ACP and its plugin hook system. - Provides an MCP stdio bridge for external tools. @@ -30,6 +31,7 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. Local and remote modes supported; remote uses `agent -p` with stream-json. +- `hapi grok` - Start Grok Build mode. See `src/grok/runGrok.ts`. - `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`. Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram. - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. @@ -127,6 +129,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): - Claude CLI installed and logged in (`claude` on PATH). - Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows). +- Grok Build CLI installed (`grok` on PATH) for `hapi grok`. Authenticate with `grok login --device-auth` on headless runner machines, or set `XAI_API_KEY`. - OpenCode CLI installed (`opencode` on PATH). - Bun for building from source. @@ -152,6 +155,7 @@ bun run build:single-exe - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. - `src/cursor/` - Cursor Agent integration. +- `src/grok/` - Grok Build native TUI + ACP integration. - `src/agent/` - Multi-agent support (Gemini via ACP). - `src/opencode/` - OpenCode ACP + hook integration. - `src/runner/` - Background service. diff --git a/cli/package.json b/cli/package.json index 0cfc660224..2785de940f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@twsxtd/hapi", - "version": "0.20.2", + "version": "0.22.3", "description": "App for agentic coding - access coding agent anywhere", "author": "Kirill Dubovitskiy & weishu", "license": "AGPL-3.0-only", @@ -26,11 +26,11 @@ } }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.20.2", - "@twsxtd/hapi-darwin-x64": "0.20.2", - "@twsxtd/hapi-linux-arm64": "0.20.2", - "@twsxtd/hapi-linux-x64": "0.20.2", - "@twsxtd/hapi-win32-x64": "0.20.2" + "@twsxtd/hapi-darwin-arm64": "0.22.3", + "@twsxtd/hapi-darwin-x64": "0.22.3", + "@twsxtd/hapi-linux-arm64": "0.22.3", + "@twsxtd/hapi-linux-x64": "0.22.3", + "@twsxtd/hapi-win32-x64": "0.22.3" }, "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"", @@ -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/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index ed699f590a..ec882ce77b 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -377,6 +377,25 @@ describe('AcpMessageHandler', () => { expect(messages).toEqual([{ type: 'text', text: 'hello world' }]); }); + it('preserves overlapping text chunks in delta mode', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler( + (message) => messages.push(message), + { textChunkMode: 'delta' } + ); + + for (const text of ['|-----|', '-----|', '-----|\n']) { + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text } + }); + } + + handler.flushText(); + + expect(messages).toEqual([{ type: 'text', text: '|-----|-----|-----|\n' }]); + }); + it('keeps existing tool name when update only has kind fallback', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 68a9ac00b1..f994f229d6 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -358,6 +358,8 @@ function normalizePlanEntries(entries: unknown): PlanItem[] { return items; } +export type AcpTextChunkMode = 'dedupe' | 'delta'; + function getSuffixPrefixOverlap(base: string, next: string): number { const maxOverlap = Math.min(base.length, next.length); for (let length = maxOverlap; length > 0; length -= 1) { @@ -380,8 +382,14 @@ export class AcpMessageHandler { private lastReasoningSnapshotAt: number | null = null; private lastReasoningSnapshotText = ''; private reasoningSnapshotEmitted = false; + private readonly textChunkMode: AcpTextChunkMode; - constructor(private readonly onMessage: (message: AgentMessage) => void) {} + constructor( + private readonly onMessage: (message: AgentMessage) => void, + options: { textChunkMode?: AcpTextChunkMode } = {} + ) { + this.textChunkMode = options.textChunkMode ?? 'dedupe'; + } /** * Emits any buffered assistant text as a single message and clears the @@ -444,6 +452,13 @@ export class AcpMessageHandler { } private appendTextChunk(text: string): void { + if (this.textChunkMode === 'delta') { + if (text) { + this.bufferedText += text; + } + return; + } + if (!text) { return; } diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts new file mode 100644 index 0000000000..6dc851b794 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +const transportState = vi.hoisted(() => ({ + calls: [] as Array<{ method: string; params?: unknown }> +})); + +vi.mock('./AcpStdioTransport', () => ({ + AcpStdioTransport: class { + constructor(_options: unknown) {} + onNotification = vi.fn(); + onStderrError = vi.fn(); + registerRequestHandler = vi.fn(); + sendRequest = vi.fn(async (method: string, params?: unknown) => { + transportState.calls.push({ method, params }); + if (method === 'initialize') { + return { protocolVersion: 1, authMethods: [] }; + } + return null; + }); + close = vi.fn(async () => {}); + } +})); + +import { AcpSdkBackend } from './AcpSdkBackend'; + +describe('AcpSdkBackend.initialize', () => { + it('advertises Cursor-compatible parameterized model picker support', async () => { + const backend = new AcpSdkBackend({ command: 'agent', args: ['acp'] }); + + await backend.initialize(); + + expect(transportState.calls).toContainEqual({ + method: 'initialize', + params: expect.objectContaining({ + clientCapabilities: expect.objectContaining({ + _meta: expect.objectContaining({ + parameterizedModelPicker: true + }) + }) + }) + }); + }); +}); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index d0c32f56a7..cccbeb700b 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -187,6 +187,79 @@ describe('AcpSdkBackend', () => { }); }); + it('captures Grok reasoning efforts from x.ai session metadata and switches with set_mode', async () => { + const backend = new AcpSdkBackend({ command: 'grok' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + if (method === 'session/new') { + return { + sessionId: 'grok-session-1', + models: { + currentModelId: 'grok-4.5', + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + _meta: { + reasoningEfforts: [ + { value: 'high', label: 'High Effort', default: true }, + { value: 'low', label: 'Low Effort', default: false } + ] + } + }] + }, + _meta: { + availableCommands: [{ name: 'auto' }], + 'x.ai/sessionConfig': { + options: [ + { id: 'high', category: 'mode', label: 'High Effort', selected: false }, + { id: 'low', category: 'mode', label: 'Low Effort', selected: true } + ] + } + } + }; + } + if (method === 'session/set_mode') return { meta: null }; + return null; + }, + close: async () => {} + }; + + const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + expect(backend.getSessionModelsMetadata(sessionId)).toEqual({ + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [ + { value: 'high', name: 'High Effort', isDefault: true }, + { value: 'low', name: 'Low Effort', isDefault: false } + ] + }], + currentModelId: 'grok-4.5' + }); + expect(backend.getThoughtLevelConfigOption(sessionId)).toMatchObject({ + currentValue: 'low', + options: [ + { value: 'high', name: 'High Effort' }, + { value: 'low', name: 'Low Effort' } + ] + }); + expect(backend.hasAvailableCommand(sessionId, 'auto')).toBe(true); + + await backend.setMode(sessionId, 'high'); + + expect(calls).toContainEqual({ + method: 'session/set_mode', + params: { sessionId, modeId: 'high' } + }); + expect(backend.getThoughtLevelConfigOption(sessionId)?.currentValue).toBe('high'); + }); + it('merges configOptions model variants into availableModels when both are present', async () => { const backend = new AcpSdkBackend({ command: 'agent' }); const backendInternal = backend as unknown as { @@ -755,6 +828,52 @@ describe('AcpSdkBackend', () => { expect(realtimeUsage.map((m) => m.contextTokens)).toEqual([1_000, 2_500]); }); + it('forwards title changes from session_info_update', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const updates: Array<{ title?: string | null }> = []; + backend.setSessionInfoUpdateListener((update) => updates.push(update)); + + const backendInternal = backend as unknown as { + activeSessionId: string | null; + handleSessionUpdate: (params: unknown) => void; + }; + backendInternal.activeSessionId = 'session-1'; + + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'Native ACP title' + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: null + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 123 + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'other-session', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'Wrong session' + } + }); + + expect(updates).toEqual([ + { title: 'Native ACP title' }, + { title: null } + ]); + }); + it('emits a context-only usage on finalize when the prompt response carries no usage', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 25; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 13191a5a4c..7e22bdcd79 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -2,7 +2,7 @@ import type { AgentFlavor } from '@hapi/protocol'; import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest, PermissionResponse, PromptContent } from '@/agent/types'; import { asString, isObject } from '@hapi/protocol'; import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport'; -import { AcpMessageHandler } from './AcpMessageHandler'; +import { AcpMessageHandler, type AcpTextChunkMode } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; import { logger } from '@/ui/logger'; import { withRetry } from '@/utils/time'; @@ -25,9 +25,14 @@ type AcpUsageUpdate = { contextWindow: number | undefined; }; +export type AcpSessionInfoUpdate = { + title?: string | null; +}; + export type AcpModelDescriptor = { modelId: string; name?: string; + reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>; }; export type AcpSessionModelsMetadata = { @@ -59,6 +64,9 @@ export class AcpSdkBackend implements AgentBackend { private readonly pendingPermissions = new Map(); private readonly sessionModelsMetadata = new Map(); private readonly sessionConfigOptions = new Map(); + private readonly initialAvailableCommands = new Set(); + private readonly sessionAvailableCommands = new Map>(); + private autoPermissionModeEnabled: boolean | null = null; private messageHandler: AcpMessageHandler | null = null; private activeSessionId: string | null = null; private initializeResult: AcpInitializeResult | null = null; @@ -69,6 +77,7 @@ export class AcpSdkBackend implements AgentBackend { private latestUsageUpdate: AcpUsageUpdate | null = null; private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; + private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; /** Retry configuration for ACP initialization */ @@ -101,7 +110,12 @@ export class AcpSdkBackend implements AgentBackend { private static readonly LATE_FLUSH_QUIET_PERIOD_MS = 250; private static readonly LATE_FLUSH_WINDOW_MS = 6000; - constructor(private readonly options: { command: string; args?: string[]; env?: Record }) {} + constructor(private readonly options: { + command: string; + args?: string[]; + env?: Record; + textChunkMode?: AcpTextChunkMode; + }) {} async initialize(): Promise { if (this.transport) return; @@ -115,6 +129,12 @@ export class AcpSdkBackend implements AgentBackend { this.transport.onNotification((method, params) => { if (method === 'session/update') { this.handleSessionUpdate(params); + } else if ( + method === '_x.ai/settings/update' + && isObject(params) + && 'auto_permission_mode_enabled' in params + ) { + this.autoPermissionModeEnabled = params.auto_permission_mode_enabled === true; } }); @@ -131,7 +151,13 @@ export class AcpSdkBackend implements AgentBackend { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, - terminal: false + terminal: false, + _meta: { + // Cursor ACP exposes Composer's non-fast/fast choice as separate + // `model` + `fast` config options only when the client advertises + // this capability. Agents that do not know this metadata ignore it. + parameterizedModelPicker: true + } }, clientInfo: { name: 'hapi', @@ -150,6 +176,8 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('Invalid initialize response from ACP agent'); } + this.captureAvailableCommands(null, response); + this.initializeResult = { protocolVersion: response.protocolVersion, authMethods: Array.isArray(response.authMethods) @@ -227,6 +255,7 @@ export class AcpSdkBackend implements AgentBackend { try { await this.transport.sendRequest('session/set_mode', { sessionId, modeId }); this.setModeSupported = true; + this.updateThoughtLevelCurrentValue(sessionId, modeId); return; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -324,7 +353,7 @@ export class AcpSdkBackend implements AgentBackend { modelId }); - if (opts?.flavor === 'opencode') { + if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { // OpenCode's set_model response only carries an opaque `_meta` block, // not `availableModels`/`currentModelId`. Optimistically update the // cached currentModelId (the call succeeded, so the agent has switched) @@ -369,11 +398,24 @@ export class AcpSdkBackend implements AgentBackend { return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level'); } + hasAvailableCommand(sessionId: string, command: string): boolean { + if (command === 'auto' && this.autoPermissionModeEnabled === true) { + return true; + } + return this.sessionAvailableCommands.get(sessionId)?.has(command) + ?? this.initialAvailableCommands.has(command); + } + /** Forwards ACP `usage_update` to the web status bar when no prompt is active (e.g. session resume). */ setUsageUpdateListener(listener: ((msg: AgentMessage) => void) | null): void { this.usageUpdateListener = listener; } + /** Forwards ACP `session_info_update` metadata independently of prompt turns. */ + setSessionInfoUpdateListener(listener: ((update: AcpSessionInfoUpdate) => void) | null): void { + this.sessionInfoUpdateListener = listener; + } + async prompt( sessionId: string, content: PromptContent[], @@ -395,7 +437,7 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS ); this.messageHandler?.drainBuffers(); - this.messageHandler = new AcpMessageHandler(onUpdate); + this.messageHandler = new AcpMessageHandler(onUpdate, { textChunkMode: this.options.textChunkMode }); this.isProcessingMessage = true; this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; @@ -542,6 +584,9 @@ export class AcpSdkBackend implements AgentBackend { this.activeSessionId = null; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); + this.initialAvailableCommands.clear(); + this.sessionAvailableCommands.clear(); + this.autoPermissionModeEnabled = null; this.notifyResponseComplete(); await this.transport.close(); this.transport = null; @@ -555,10 +600,24 @@ export class AcpSdkBackend implements AgentBackend { } this.lastSessionUpdateAt = Date.now(); const update = params.update; + if (sessionId) { + this.captureAvailableCommands(sessionId, update); + } + this.captureSessionInfoUpdate(update); this.captureUsageUpdate(update); this.messageHandler?.handleUpdate(update); } + private captureSessionInfoUpdate(update: unknown): void { + if (!isObject(update)) return; + if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) return; + if (!Object.prototype.hasOwnProperty.call(update, 'title')) return; + + const title = update.title; + if (typeof title !== 'string' && title !== null) return; + this.sessionInfoUpdateListener?.({ title }); + } + private captureUsageUpdate(update: unknown): void { if (!isObject(update)) return; @@ -760,6 +819,16 @@ export class AcpSdkBackend implements AgentBackend { }); } + private updateThoughtLevelCurrentValue(sessionId: string, value: string): void { + const options = this.sessionConfigOptions.get(sessionId); + if (!options) return; + this.sessionConfigOptions.set(sessionId, options.map((option) => ( + option.category === 'thought_level' + ? { ...option, currentValue: value } + : option + ))); + } + /** After a successful model config apply, avoid stale base-only ACP currentValue overwriting cache. */ pinSessionModelWireId(sessionId: string, modelId: string): void { this.updateCurrentModelOptimistic(sessionId, modelId); @@ -794,12 +863,41 @@ export class AcpSdkBackend implements AgentBackend { private captureSessionMetadata(sessionId: string, response: unknown): void { this.captureSessionModelsMetadata(sessionId, response); this.captureSessionConfigOptions(sessionId, response); + this.captureAvailableCommands(sessionId, response); + } + + private captureAvailableCommands(sessionId: string | null, source: unknown): void { + if (!isObject(source)) return; + + const meta = isObject(source._meta) ? source._meta : null; + const rawCommands = Array.isArray(source.availableCommands) + ? source.availableCommands + : meta && Array.isArray(meta.availableCommands) + ? meta.availableCommands + : null; + if (!rawCommands) return; + + const commands = new Set( + rawCommands + .filter((entry): entry is Record => isObject(entry)) + .map((entry) => asString(entry.name) ?? '') + .filter((name) => name.length > 0) + ); + if (sessionId) { + this.sessionAvailableCommands.set(sessionId, commands); + return; + } + + this.initialAvailableCommands.clear(); + for (const command of commands) { + this.initialAvailableCommands.add(command); + } } private captureSessionConfigOptions(sessionId: string, response: unknown): void { - if (!isObject(response) || !Array.isArray(response.configOptions)) return; + if (!isObject(response)) return; - const options = response.configOptions + const options = (Array.isArray(response.configOptions) ? response.configOptions : []) .filter((entry): entry is Record => isObject(entry)) .map((entry): AcpConfigOptionDescriptor | null => { const id = asString(entry.id); @@ -820,7 +918,33 @@ export class AcpSdkBackend implements AgentBackend { }) .filter((entry): entry is AcpConfigOptionDescriptor => entry !== null); - this.sessionConfigOptions.set(sessionId, options); + const meta = isObject(response._meta) ? response._meta : null; + const xaiConfig = meta && isObject(meta['x.ai/sessionConfig']) + ? meta['x.ai/sessionConfig'] + : null; + const xaiOptions = xaiConfig && Array.isArray(xaiConfig.options) + ? xaiConfig.options.filter((entry): entry is Record => isObject(entry)) + : []; + const effortOptions = xaiOptions + .filter((entry) => asString(entry.category) === 'mode') + .map((entry) => ({ + value: asString(entry.id) ?? '', + name: asString(entry.label) ?? undefined, + selected: entry.selected === true + })) + .filter((entry) => entry.value.length > 0); + if (effortOptions.length > 0) { + options.push({ + id: 'x.ai/reasoning-effort', + category: 'thought_level', + currentValue: effortOptions.find((entry) => entry.selected)?.value, + options: effortOptions.map(({ value, name }) => ({ value, name })) + }); + } + + if (options.length > 0) { + this.sessionConfigOptions.set(sessionId, options); + } } /** @@ -839,7 +963,7 @@ export class AcpSdkBackend implements AgentBackend { for (const entry of response.configOptions) { if (!isObject(entry)) continue; - if (asString(entry.category) !== 'model') continue; + if (asString(entry.category) !== 'model' && asString(entry.id) !== 'model') continue; return { currentValue: asString(entry.currentValue), options: Array.isArray(entry.options) ? entry.options : [] @@ -875,7 +999,11 @@ export class AcpSdkBackend implements AgentBackend { } const byModelId = new Map(); - const addModel = (modelId: string, name?: string) => { + const addModel = ( + modelId: string, + name?: string, + reasoningEfforts?: AcpModelDescriptor['reasoningEfforts'] + ) => { const trimmedId = modelId.trim(); if (!trimmedId) return; const trimmedName = name?.trim(); @@ -884,13 +1012,13 @@ export class AcpSdkBackend implements AgentBackend { byModelId.set( trimmedId, trimmedName && trimmedName !== trimmedId - ? { modelId: trimmedId, name: trimmedName } - : { modelId: trimmedId } + ? { modelId: trimmedId, name: trimmedName, ...(reasoningEfforts ? { reasoningEfforts } : {}) } + : { modelId: trimmedId, ...(reasoningEfforts ? { reasoningEfforts } : {}) } ); return; } if (!existing.name && trimmedName && trimmedName !== trimmedId) { - byModelId.set(trimmedId, { modelId: trimmedId, name: trimmedName }); + byModelId.set(trimmedId, { ...existing, name: trimmedName }); } }; @@ -899,14 +1027,25 @@ export class AcpSdkBackend implements AgentBackend { if (!isObject(entry)) continue; const modelId = asString(entry.modelId) ?? asString(entry.value); if (!modelId) continue; - addModel(modelId, asString(entry.name) ?? undefined); + const meta = isObject(entry._meta) ? entry._meta : null; + const reasoningEfforts = meta && Array.isArray(meta.reasoningEfforts) + ? meta.reasoningEfforts + .filter((effort): effort is Record => isObject(effort)) + .map((effort) => ({ + value: asString(effort.value) ?? asString(effort.id) ?? '', + name: asString(effort.label) ?? undefined, + isDefault: effort.default === true + })) + .filter((effort) => effort.value.length > 0) + : undefined; + addModel(modelId, asString(entry.name) ?? undefined, reasoningEfforts); } } else { // Preserve previously-captured availableModels when the response only // updates currentModelId (e.g. a setModel response from some agents). const existing = this.sessionModelsMetadata.get(sessionId); for (const entry of existing?.availableModels ?? []) { - addModel(entry.modelId, entry.name); + addModel(entry.modelId, entry.name, entry.reasoningEfforts); } } diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts index 172dca0aef..f5b5b2d665 100644 --- a/cli/src/agent/runnerLifecycle.test.ts +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -100,6 +100,15 @@ describe('createRunnerLifecycle', () => { await lc.cleanup(); expect(session.sendSessionDeath).toHaveBeenCalledWith('completed'); }); + + it('limits the final connected flush budget to one second', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + + await lc.cleanup(); + + expect(session.flush).toHaveBeenCalledWith({ timeoutMs: 1_000 }); + }); }); }); diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index 16a0495120..d6ae14198d 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -62,7 +62,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi })) options.session.sendSessionDeath(sessionEndReason) - await options.session.flush() + await options.session.flush({ timeoutMs: 1_000 }) await options.session.close() } diff --git a/cli/src/agent/sessionConfigRpc.test.ts b/cli/src/agent/sessionConfigRpc.test.ts index ef0d41a92a..1e0ded85dc 100644 --- a/cli/src/agent/sessionConfigRpc.test.ts +++ b/cli/src/agent/sessionConfigRpc.test.ts @@ -91,6 +91,23 @@ describe('sessionConfigRpc', () => { expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' }) }) + it('applies nullable launch effort for Grok runtime switching', async () => { + const harness = createRpcHarness() + const onApply = vi.fn() + + registerSessionConfigRpc({ + rpcHandlerManager: harness.rpcHandlerManager, + flavor: 'grok', + effortMode: 'nullable', + onApply + }) + + const result = await harness.getHandler()({ effort: 'low' }) as { applied: Record } + + expect(result.applied.effort).toBe('low') + expect(onApply).toHaveBeenCalledWith({ effort: 'low' }) + }) + it('rejects model config for agents configured to reject model changes', async () => { const harness = createRpcHarness() diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index 6795e5e74c..b6507e1593 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -8,6 +8,7 @@ type SessionConfigState permissionMode?: TPermissionMode model?: string | null modelReasoningEffort?: string | null + effort?: string | null } type RegisterSessionConfigRpcOptions = { @@ -15,6 +16,7 @@ type RegisterSessionConfigRpcOptions Record onApply: (config: SessionConfigState) => void onAfterApply?: () => void @@ -58,6 +60,7 @@ export function registerSessionConfigRpc flavor, modelMode = 'reject', modelReasoningEffortMode = 'reject', + effortMode = 'reject', appliedFallback, onApply, onAfterApply @@ -67,7 +70,7 @@ export function registerSessionConfigRpc throw new Error('Invalid session config payload') } - const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown } + const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; effort?: unknown } const applied: Record = {} const next: SessionConfigState = {} @@ -97,6 +100,16 @@ export function registerSessionConfigRpc } } + if (config.effort !== undefined) { + if (effortMode === 'reject') { + throw new Error('Invalid effort') + } + if (effortMode === 'nullable') { + next.effort = resolveNullableSessionModel(config.effort) + applied.effort = next.effort + } + } + onApply(next) onAfterApply?.() diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 70a7c31af0..cf6fcb0158 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -3,12 +3,14 @@ import type { Session } from '@/api/types' const { getSessionMock, + getOrCreateSessionMock, getOrCreateMachineMock, sessionSyncClientMock, notifyRunnerSessionStartedMock, readSettingsMock } = vi.hoisted(() => ({ getSessionMock: vi.fn(), + getOrCreateSessionMock: vi.fn(), getOrCreateMachineMock: vi.fn(), sessionSyncClientMock: vi.fn(), notifyRunnerSessionStartedMock: vi.fn(async () => ({})), @@ -19,6 +21,7 @@ vi.mock('@/api/api', () => ({ ApiClient: { create: async () => ({ getSession: getSessionMock, + getOrCreateSession: getOrCreateSessionMock, getOrCreateMachine: getOrCreateMachineMock, sessionSyncClient: sessionSyncClientMock }) @@ -47,7 +50,7 @@ vi.mock('@/ui/logger', () => ({ } })) -import { bootstrapExistingSession, buildSessionMetadata } from './sessionFactory' +import { bootstrapExistingSession, bootstrapLazySession, buildSessionMetadata } from './sessionFactory' function createSession(): Session { return { @@ -83,6 +86,7 @@ function createSession(): Session { describe('bootstrapExistingSession', () => { beforeEach(() => { getSessionMock.mockReset() + getOrCreateSessionMock.mockReset() getOrCreateMachineMock.mockReset() sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() @@ -132,6 +136,7 @@ describe('bootstrapExistingSession', () => { codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', + grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', summary: { @@ -160,6 +165,7 @@ describe('bootstrapExistingSession', () => { codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', + grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', summary: { @@ -172,12 +178,14 @@ describe('bootstrapExistingSession', () => { expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() const updateHandler = sessionClient.updateMetadata.mock.calls[0][0] expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({ - codexSessionId: 'codex-thread-1' + codexSessionId: 'codex-thread-1', + grokSessionId: 'grok-thread-1' })) expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( 'hapi-session-1', expect.objectContaining({ - codexSessionId: 'codex-thread-1' + codexSessionId: 'codex-thread-1', + grokSessionId: 'grok-thread-1' }) ) }) @@ -194,3 +202,65 @@ describe('bootstrapExistingSession', () => { expect(metadata.capabilities?.terminal).toBe(true) }) }) + +describe('bootstrapLazySession', () => { + beforeEach(() => { + getOrCreateSessionMock.mockReset() + getOrCreateMachineMock.mockReset() + sessionSyncClientMock.mockReset() + notifyRunnerSessionStartedMock.mockClear() + readSettingsMock.mockReset() + }) + + it('does not persist a machine or session until materialization', async () => { + const pendingClient = { isPending: () => true } + sessionSyncClientMock.mockReturnValue(pendingClient) + readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) + + const result = await bootstrapLazySession({ + flavor: 'codex', + startedBy: 'terminal', + workingDirectory: '/tmp/project', + agentState: { controlledByUser: false } + }) + + expect(result.session).toBe(pendingClient) + expect(getOrCreateMachineMock).not.toHaveBeenCalled() + expect(getOrCreateSessionMock).not.toHaveBeenCalled() + expect(notifyRunnerSessionStartedMock).not.toHaveBeenCalled() + + const [provisional, options] = sessionSyncClientMock.mock.calls[0] + expect(provisional.id).toMatch(/^[0-9a-f-]{36}$/) + expect(provisional.metadata).toEqual(expect.objectContaining({ + machineId: 'machine-1', + path: '/tmp/project', + flavor: 'codex' + })) + + const materialized = createSession() + materialized.id = provisional.id + getOrCreateSessionMock.mockResolvedValue(materialized) + const snapshot = { + metadata: { + ...provisional.metadata, + codexSessionId: 'codex-thread-1' + }, + agentState: { controlledByUser: true } + } + await options.materialize(snapshot, new AbortController().signal) + + expect(getOrCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + id: provisional.id, + metadata: snapshot.metadata, + state: snapshot.agentState, + timeoutMs: 10_000, + machine: expect.objectContaining({ id: 'machine-1' }) + })) + + options.onMaterialized(materialized, snapshot) + expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( + provisional.id, + expect.objectContaining({ codexSessionId: 'codex-thread-1' }) + ) + }) +}) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index fcc2629648..f77a772879 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -97,6 +97,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId + if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId @@ -184,6 +185,88 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis } } +export async function bootstrapLazySession(options: SessionBootstrapOptions): Promise { + const workingDirectory = options.workingDirectory ?? getInvokedCwd() + const startedBy = options.startedBy ?? 'terminal' + if (startedBy !== 'terminal') { + throw new Error('Lazy session bootstrap is only supported for terminal sessions') + } + + const api = await ApiClient.create() + const machineId = await getMachineIdOrExit() + const machineMetadata = buildMachineMetadata() + const metadata = buildSessionMetadata({ + flavor: options.flavor, + startedBy, + workingDirectory, + machineId, + metadataOverrides: options.metadataOverrides + }) + const agentState = options.agentState === undefined ? {} : options.agentState + const now = Date.now() + const requestedId = randomUUID() + const sessionTag = options.tag ?? randomUUID() + const sessionInfo: Session = { + id: requestedId, + namespace: 'pending', + seq: 0, + createdAt: now, + updatedAt: now, + active: false, + activeAt: now, + metadata, + metadataVersion: 0, + agentState, + agentStateVersion: 0, + thinking: false, + thinkingAt: now, + todos: [], + model: options.model ?? null, + modelReasoningEffort: options.modelReasoningEffort ?? null, + effort: options.effort ?? null, + serviceTier: null, + permissionMode: undefined, + collaborationMode: undefined + } + + const session = api.sessionSyncClient(sessionInfo, { + materialize: async (snapshot, signal) => { + const materialized = await api.getOrCreateSession({ + id: requestedId, + tag: sessionTag, + metadata: snapshot.metadata ?? metadata, + state: snapshot.agentState, + model: options.model, + modelReasoningEffort: options.modelReasoningEffort, + effort: options.effort, + machine: { + id: machineId, + metadata: machineMetadata + }, + timeoutMs: 10_000, + signal + }) + if (materialized.id !== requestedId) { + throw new Error(`Hub returned unexpected session id ${materialized.id}`) + } + return materialized + }, + onMaterialized: (materialized, snapshot) => { + void reportSessionStarted(materialized.id, snapshot.metadata ?? metadata) + } + }) + + return { + api, + session, + sessionInfo, + metadata, + machineId, + startedBy, + workingDirectory + } +} + export async function bootstrapExistingSession(options: { sessionId: string flavor: string diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 03d8543187..5c8df7a718 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -71,6 +71,7 @@ export type PermissionResponse = export type AgentSessionModelDescriptor = { modelId: string; name?: string; + reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>; }; export type AgentSessionModelsMetadata = { diff --git a/cli/src/api/api.getSession.activeAt.test.ts b/cli/src/api/api.getSession.activeAt.test.ts new file mode 100644 index 0000000000..25da4c955c --- /dev/null +++ b/cli/src/api/api.getSession.activeAt.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configuration } from '@/configuration' + +const axiosGetMock = vi.hoisted(() => vi.fn()) + +vi.mock('axios', () => ({ + default: { + get: axiosGetMock, + post: vi.fn() + } +})) + +vi.mock('@/api/auth', () => ({ + getAuthToken: () => 'cli-token' +})) + +import { ApiClient } from './api' + +describe('ApiClient.getSession activeAt coerce', () => { + const now = 1_710_000_000_000 + + beforeEach(() => { + configuration._setApiUrl('https://hapi.example.com') + configuration._setExtraHeaders({}) + axiosGetMock.mockReset() + }) + + it('accepts hub payloads with null activeAt without throwing', async () => { + axiosGetMock.mockResolvedValue({ + data: { + session: { + id: '11111111-1111-4111-8111-111111111111', + namespace: 'default', + seq: 1, + createdAt: now, + updatedAt: now, + active: false, + activeAt: null, + metadata: { + path: '/tmp/project', + host: 'test-host' + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: now, + todos: [], + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null + } + } + }) + + const client = await ApiClient.create() + const session = await client.getSession('11111111-1111-4111-8111-111111111111') + + expect(session.activeAt).toBe(0) + expect(typeof session.activeAt).toBe('number') + }) +}) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index df52e4548a..275c7196b6 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -17,7 +17,7 @@ import { configuration } from '@/configuration' import { getAuthToken } from '@/api/auth' import { apiValidationError } from '@/utils/errorUtils' import { ApiMachineClient } from './apiMachine' -import { ApiSessionClient } from './apiSession' +import { ApiSessionClient, type ApiSessionClientOptions } from './apiSession' import { buildHubRequestHeaders } from './hubExtraHeaders' export class ApiClient { @@ -35,29 +35,46 @@ export class ApiClient { } async getOrCreateSession(opts: { + id?: string tag: string metadata: Metadata state: AgentState | null model?: string modelReasoningEffort?: string effort?: string + machine?: { + id: string + metadata: MachineMetadata + runnerState?: RunnerState + } + timeoutMs?: number + signal?: AbortSignal }): Promise { const response = await axios.post( `${configuration.apiUrl}/cli/sessions`, { + id: opts.id, tag: opts.tag, metadata: opts.metadata, agentState: opts.state, model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, - effort: opts.effort + effort: opts.effort, + machine: opts.machine + ? { + id: opts.machine.id, + metadata: opts.machine.metadata, + runnerState: opts.machine.runnerState ?? null + } + : undefined }, { headers: buildHubRequestHeaders({ Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' }), - timeout: 60_000 + timeout: opts.timeoutMs ?? 60_000, + signal: opts.signal } ) @@ -255,8 +272,8 @@ export class ApiClient { } } - sessionSyncClient(session: Session): ApiSessionClient { - return new ApiSessionClient(this.token, session) + sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient { + return new ApiSessionClient(this.token, session, options) } machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient { diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 5af3186cee..6253566e55 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' const ioMock = vi.hoisted(() => vi.fn()) const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) +const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn()) vi.mock('socket.io-client', () => ({ io: ioMock @@ -18,7 +19,11 @@ vi.mock('../modules/common/opencodeModels', () => ({ listOpencodeModelsForCwd: listOpencodeModelsForCwdMock })) -import { ApiMachineClient } from './apiMachine' +vi.mock('../modules/common/grokModels', () => ({ + listGrokModelsForCwd: listGrokModelsForCwdMock +})) + +import { ApiMachineClient, normalizeWindowsDriveRoot } from './apiMachine' import type { Machine } from './types' function makeMachine(id: string): Machine { @@ -37,6 +42,18 @@ function makeMachine(id: string): Machine { } } +describe('normalizeWindowsDriveRoot', () => { + it('restores the trailing separator when Windows realpath returns a bare drive', () => { + expect(normalizeWindowsDriveRoot('C:')).toBe('C:\\') + expect(normalizeWindowsDriveRoot('D:')).toBe('D:\\') + }) + + it('leaves non-drive-root paths unchanged', () => { + expect(normalizeWindowsDriveRoot('C:\\Users')).toBe('C:\\Users') + expect(normalizeWindowsDriveRoot('/tmp/workspace')).toBe('/tmp/workspace') + }) +}) + async function callListOpencodeModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { // Reach into the private rpc handler manager to dispatch a request. // Mirrors how the on-socket 'rpc-request' listener invokes handleRequest. @@ -48,12 +65,22 @@ async function callListOpencodeModels(client: ApiMachineClient, machineId: strin return JSON.parse(raw) as unknown } +async function callListGrokModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listGrokModelsForCwd`, + params: JSON.stringify({ cwd }) + }) + return JSON.parse(raw) as unknown +} + describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { let workspaceRoot: string beforeEach(() => { ioMock.mockReset() listOpencodeModelsForCwdMock.mockReset() + listGrokModelsForCwdMock.mockReset() workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-')) }) @@ -137,10 +164,121 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { }) // The handler realpaths the cwd (security: prevents symlink escape), // so on macOS /var/folders/... resolves to /private/var/folders/... - expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot)) + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(secondWorkspaceRoot)) } finally { rmSync(secondWorkspaceRoot, { recursive: true, force: true }) client.shutdown() } }) }) + +describe('ApiMachineClient listGrokModelsForCwd handler', () => { + let workspaceRoot: string + + beforeEach(() => { + ioMock.mockReset() + listGrokModelsForCwdMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-grok-machine-ws-')) + }) + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + it('rejects cwd outside workspace roots before running grok models', async () => { + const machine = makeMachine('grok-machine-1') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-grok-outside-')) + + try { + expect(await callListGrokModels(client, machine.id, outsideCwd)).toEqual({ + success: false, + error: 'Path is outside workspace roots' + }) + expect(listGrokModelsForCwdMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) + + it('forwards a workspace cwd to the Grok model probe', async () => { + const machine = makeMachine('grok-machine-2') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + listGrokModelsForCwdMock.mockResolvedValueOnce({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + + try { + expect(await callListGrokModels(client, machine.id, workspaceRoot)).toEqual({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + expect(listGrokModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(workspaceRoot)) + } finally { + client.shutdown() + } + }) +}) + +describe('ApiMachineClient keepAlive lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('clears priming timeout on shutdown before first machine-alive emit', () => { + const machine = makeMachine('machine-keepalive') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + keepAliveStartTimeout: ReturnType | null + } + + priv.startKeepAlive() + client.shutdown() + vi.advanceTimersByTime(100) + + expect(emit).not.toHaveBeenCalled() + expect(priv.keepAliveInterval).toBeNull() + expect(priv.keepAliveStartTimeout).toBeNull() + }) + + it('clears running keepAlive interval on shutdown', () => { + const machine = makeMachine('machine-keepalive-2') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + } + + priv.startKeepAlive() + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + + client.shutdown() + vi.advanceTimersByTime(20_000) + + expect(emit).toHaveBeenCalledTimes(1) + expect(priv.keepAliveInterval).toBeNull() + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 88ba35966b..f0e67bd218 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -22,9 +22,15 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { + listGrokModelsForCwd, + type ListGrokModelsForCwdRequest, + type ListGrokModelsForCwdResponse +} from '../modules/common/grokModels' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' +import { collectMachineHealth } from '@/utils/machineHealth' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -40,6 +46,14 @@ interface ListMachineDirectoryRequest { path: string } +export function normalizeWindowsDriveRoot(path: string): string { + return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path +} + +function canonicalRealpathSync(path: string): string { + return normalizeWindowsDriveRoot(realpathSync.native(path)) +} + function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { if (!paths?.length) { return undefined @@ -47,9 +61,9 @@ function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { const normalized = Array.from(new Set(paths.map((path) => { try { - return realpathSync(path) + return canonicalRealpathSync(path) } catch { - return resolvePath(path) + return normalizeWindowsDriveRoot(resolvePath(path)) } }))) @@ -73,6 +87,7 @@ function formatWorkspaceRoots(paths?: string[]): string { export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null + private keepAliveStartTimeout: ReturnType | null = null private rpcHandlerManager: RpcHandlerManager private readonly normalizedWorkspaceRoots: string[] | undefined @@ -206,6 +221,21 @@ export class ApiMachineClient { return await listOpencodeModelsForCwd(resolvedCwd) } ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListGrokModelsForCwd, + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) return { success: false, error: 'cwd is required' } + + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + + return await listGrokModelsForCwd(resolvedCwd) + } + ) } private isWithinWorkspaceRoots(absolutePath: string): boolean { @@ -230,7 +260,7 @@ export class ApiMachineClient { private async resolveForWorkspaceCheck(path: string): Promise { const absolute = resolvePath(path) try { - return await realpath(absolute) + return normalizeWindowsDriveRoot(await realpath(absolute)) } catch { const missing: string[] = [] let cursor = absolute @@ -238,12 +268,12 @@ export class ApiMachineClient { missing.unshift(basename(cursor)) cursor = dirname(cursor) try { - return join(await realpath(cursor), ...missing) + return join(normalizeWindowsDriveRoot(await realpath(cursor)), ...missing) } catch { // keep walking to the nearest existing parent } } - return absolute + return normalizeWindowsDriveRoot(absolute) } } @@ -489,15 +519,27 @@ export class ApiMachineClient { private startKeepAlive(): void { this.stopKeepAlive() - this.keepAliveInterval = setInterval(() => { + const emitAlive = () => { this.socket.emit('machine-alive', { machineId: this.machine.id, - time: Date.now() + time: Date.now(), + health: collectMachineHealth() }) - }, 20_000) + } + // Prime CPU sampling so the first heartbeat already includes CPU %. + collectMachineHealth() + this.keepAliveStartTimeout = setTimeout(() => { + this.keepAliveStartTimeout = null + emitAlive() + this.keepAliveInterval = setInterval(emitAlive, 20_000) + }, 50) } private stopKeepAlive(): void { + if (this.keepAliveStartTimeout) { + clearTimeout(this.keepAliveStartTimeout) + this.keepAliveStartTimeout = null + } if (this.keepAliveInterval) { clearInterval(this.keepAliveInterval) this.keepAliveInterval = null diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index a44ebc8670..5b4b5a4453 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -1,5 +1,326 @@ -import { describe, expect, it } from 'vitest' -import { isExternalUserMessage, IncomingMessageFilter } from './apiSession' +import { describe, expect, it, vi } from 'vitest' +import type { Session } from './types' + +const socketHarness = vi.hoisted(() => ({ + sockets: [] as Array<{ + connected: boolean + connectCalls: number + connectImmediately: boolean + emitted: Array<{ event: string; args: unknown[] }> + listeners: Map void>> + triggerConnect: () => void + triggerConnectError: () => void + }> +})) + +vi.mock('socket.io-client', () => ({ + io: () => { + const state = { + connected: false, + connectCalls: 0, + connectImmediately: true, + emitted: [] as Array<{ event: string; args: unknown[] }>, + listeners: new Map void>>(), + triggerConnect: () => {}, + triggerConnectError: () => {} + } + const triggerConnect = () => { + state.connected = true + for (const listener of state.listeners.get('connect') ?? []) listener() + } + state.triggerConnect = triggerConnect + state.triggerConnectError = () => { + for (const listener of state.listeners.get('connect_error') ?? []) { + listener(new Error('connect failed')) + } + } + const socket = { + get connected() { + return state.connected + }, + on: (event: string, listener: (...args: any[]) => void) => { + const listeners = state.listeners.get(event) ?? [] + listeners.push(listener) + state.listeners.set(event, listeners) + return socket + }, + off: (event: string, listener: (...args: any[]) => void) => { + const listeners = state.listeners.get(event) ?? [] + state.listeners.set(event, listeners.filter((candidate) => candidate !== listener)) + return socket + }, + emit: (event: string, ...args: unknown[]) => { + state.emitted.push({ event, args }) + return socket + }, + emitWithAck: async () => ({}), + timeout: () => ({ emitWithAck: async () => ({}) }), + connect: () => { + state.connectCalls += 1 + if (state.connectImmediately) { + triggerConnect() + } + return socket + }, + disconnect: () => { + state.connected = false + return socket + } + } + Object.assign(socket, { volatile: socket }) + socketHarness.sockets.push(state) + return socket + } +})) + +import { ApiSessionClient, isExternalUserMessage, IncomingMessageFilter } from './apiSession' + +function createSession(overrides: Partial = {}): Session { + return { + id: '11111111-1111-4111-8111-111111111111', + namespace: 'pending', + seq: 0, + createdAt: 1, + updatedAt: 1, + active: false, + activeAt: 1, + metadata: null, + metadataVersion: 0, + agentState: { controlledByUser: false }, + agentStateVersion: 0, + thinking: false, + thinkingAt: 1, + todos: [], + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + permissionMode: undefined, + collaborationMode: undefined, + ...overrides + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +describe('ApiSessionClient lazy materialization', () => { + it('does not connect or materialize without a real user message', async () => { + socketHarness.sockets.length = 0 + const materialize = vi.fn(async () => createSession()) + const client = new ApiSessionClient('token', createSession(), { materialize }) + + client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' })) + client.sendSessionEvent({ type: 'ready' }) + client.keepAlive(false, 'local') + await client.flush({ timeoutMs: 100 }) + + expect(client.getState()).toBe('pending') + expect(materialize).not.toHaveBeenCalled() + expect(socketHarness.sockets[0]?.connectCalls).toBe(0) + expect(socketHarness.sockets[0]?.emitted).toEqual([]) + client.close() + }) + + it('materializes on the first user message and replays queued events', async () => { + socketHarness.sockets.length = 0 + const materialize = vi.fn(async (snapshot) => createSession({ + namespace: 'default', + metadata: snapshot.metadata, + metadataVersion: 1, + agentState: snapshot.agentState, + agentStateVersion: 1 + })) + const client = new ApiSessionClient('token', createSession(), { materialize }) + client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' })) + client.sendSessionEvent({ type: 'ready' }) + + client.sendUserMessage('hello') + expect(await client.materialize()).toBe(true) + + expect(materialize).toHaveBeenCalledWith({ + metadata: { path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' }, + agentState: { controlledByUser: false } + }, expect.any(AbortSignal)) + expect(client.getState()).toBe('active') + expect(socketHarness.sockets[0]?.connectCalls).toBe(1) + expect(socketHarness.sockets[0]?.emitted.map((entry) => entry.event)).toEqual([ + 'message', + 'message', + 'session-alive' + ]) + client.close() + }) + + it('materializes on non-text user activity and preserves following agent events', async () => { + socketHarness.sockets.length = 0 + const pendingMaterialization = deferred() + const materialize = vi.fn(async () => await pendingMaterialization.promise) + const client = new ApiSessionClient('token', createSession(), { materialize }) + + client.notifyUserActivity() + client.sendAgentMessage({ type: 'message', message: 'image response' }) + expect(client.getState()).toBe('materializing') + + pendingMaterialization.resolve(createSession({ namespace: 'default' })) + expect(await client.materialize()).toBe(true) + + expect(materialize).toHaveBeenCalledTimes(1) + const messages = socketHarness.sockets[0]?.emitted.filter((entry) => entry.event === 'message') + expect(messages).toHaveLength(1) + client.close() + }) + + it('preserves all replayed transcript messages while materialization is in flight', async () => { + socketHarness.sockets.length = 0 + const pendingMaterialization = deferred() + const client = new ApiSessionClient('token', createSession(), { + materialize: async () => await pendingMaterialization.promise + }) + const expectedMessages: string[] = [] + + for (let index = 0; index < 150; index += 1) { + const userMessage = `user-${index}` + const agentMessage = `agent-${index}` + expectedMessages.push(userMessage, agentMessage) + client.sendUserMessage(userMessage) + client.sendAgentMessage({ type: 'message', message: agentMessage }) + } + + pendingMaterialization.resolve(createSession({ namespace: 'default' })) + expect(await client.materialize()).toBe(true) + + const emittedMessages = socketHarness.sockets[0]?.emitted + .filter((entry) => entry.event === 'message') + .map((entry) => { + const payload = entry.args[0] as { + message: { + role: 'user' | 'agent' + content: { text?: string; data?: { message?: string } } + } + } + return payload.message.role === 'user' + ? payload.message.content.text + : payload.message.content.data?.message + }) + + expect(emittedMessages).toEqual(expectedMessages) + client.close() + }) + + it('drains in-flight materialization and initial socket delivery before closing', async () => { + socketHarness.sockets.length = 0 + const pendingMaterialization = deferred() + const client = new ApiSessionClient('token', createSession(), { + materialize: async () => await pendingMaterialization.promise + }) + const socket = socketHarness.sockets[0] + if (!socket) throw new Error('expected socket') + socket.connectImmediately = false + + client.sendUserMessage('persist me') + client.sendAgentMessage({ type: 'message', message: 'persist response' }) + client.sendSessionDeath('completed') + + let flushed = false + const flushTask = client.flush({ timeoutMs: 1_000 }).then(() => { + flushed = true + }) + await Promise.resolve() + expect(flushed).toBe(false) + + pendingMaterialization.resolve(createSession({ namespace: 'default' })) + await vi.waitFor(() => expect(socket.connectCalls).toBe(1)) + expect(flushed).toBe(false) + + socket.triggerConnectError() + await Promise.resolve() + expect(flushed).toBe(false) + + socket.triggerConnect() + await flushTask + + expect(socket.emitted.map((entry) => entry.event)).toEqual([ + 'message', + 'message', + 'session-end', + 'session-alive' + ]) + client.close() + }) + + it('skips materialization backoff and performs one final attempt during shutdown drain', async () => { + socketHarness.sockets.length = 0 + const materialize = vi.fn(async () => { + if (materialize.mock.calls.length === 1) { + throw Object.assign(new Error('hub unavailable'), { isAxiosError: true }) + } + return createSession({ namespace: 'default' }) + }) + const client = new ApiSessionClient('token', createSession(), { materialize }) + + client.sendUserMessage('hello') + await vi.waitFor(() => expect(materialize).toHaveBeenCalledTimes(1)) + await Promise.resolve() + + await client.flush({ timeoutMs: 500 }) + + expect(materialize).toHaveBeenCalledTimes(2) + expect(client.getState()).toBe('active') + expect(socketHarness.sockets[0]?.emitted.some((entry) => entry.event === 'message')).toBe(true) + client.close() + }) + + it('aborts in-flight materialization when closed', async () => { + socketHarness.sockets.length = 0 + const observedSignals: AbortSignal[] = [] + const materialize = vi.fn(async (_snapshot, signal: AbortSignal) => { + observedSignals.push(signal) + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + }) + const client = new ApiSessionClient('token', createSession(), { materialize }) + + const task = client.materialize() + await Promise.resolve() + client.close() + + expect(await task).toBe(false) + expect(observedSignals[0]?.aborted).toBe(true) + expect(client.getState()).toBe('closed') + }) + + it('reconnects a disconnected active session during final flush', async () => { + socketHarness.sockets.length = 0 + const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) + const socket = socketHarness.sockets[0] + if (!socket) throw new Error('expected socket') + socket.connected = false + socket.connectImmediately = false + client.sendSessionDeath('completed') + + let flushed = false + const flushTask = client.flush({ timeoutMs: 500 }).then(() => { + flushed = true + }) + await vi.waitFor(() => expect(socket.connectCalls).toBe(2)) + expect(flushed).toBe(false) + + socket.triggerConnect() + await flushTask + + expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true) + client.close() + }) +}) describe('isExternalUserMessage', () => { const baseUserMsg = { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index cd98d69a51..3e98814a6e 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -152,6 +152,72 @@ export class IncomingMessageFilter { } } +export type ApiSessionClientState = 'pending' | 'materializing' | 'active' | 'closed' + +export type PendingSessionSnapshot = { + metadata: Metadata | null + agentState: AgentState | null +} + +export type ApiSessionClientOptions = { + materialize?: (snapshot: PendingSessionSnapshot, signal: AbortSignal) => Promise + onMaterialized?: (session: Session, snapshot: PendingSessionSnapshot) => void +} + +type PendingOutboundEvent = { + emit: () => void + retention: 'lossless' | 'droppable' +} + +const MAX_PENDING_DROPPABLE_EVENTS = 256 +const MATERIALIZATION_RETRY_MIN_MS = 1_000 +const MATERIALIZATION_RETRY_MAX_MS = 30_000 + +function isTransientMaterializationError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false + } + if (!error.response) { + return true + } + const status = error.response.status + return status === 408 || status === 425 || status === 429 || status >= 500 +} + +async function waitForAbortableDelay( + ms: number, + signal: AbortSignal, + interruptSignal?: AbortSignal +): Promise { + if (signal.aborted || interruptSignal?.aborted) { + return false + } + + return await new Promise((resolve) => { + let settled = false + const finish = (completed: boolean) => { + if (settled) return + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', onAbort) + interruptSignal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const timeout = setTimeout(() => { + finish(true) + }, ms) + const onAbort = () => { + finish(false) + } + signal.addEventListener('abort', onAbort, { once: true }) + interruptSignal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +function hasSameJsonValue(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + export class ApiSessionClient extends EventEmitter { private readonly token: string readonly sessionId: string @@ -171,8 +237,20 @@ export class ApiSessionClient extends EventEmitter { private readonly terminalManager: TerminalManager private agentStateLock = new AsyncLock() private metadataLock = new AsyncLock() - - constructor(token: string, session: Session) { + private state: ApiSessionClientState + private readonly materializer?: ApiSessionClientOptions['materialize'] + private readonly onMaterialized?: ApiSessionClientOptions['onMaterialized'] + private materializationTask: Promise | null = null + private materializationAbortController: AbortController | null = null + private materializationRetryAbortController: AbortController | null = null + private materializationDrainRequested = false + private awaitingMaterializedConnection = false + private metadataChangedDuringAttempt = false + private agentStateChangedDuringAttempt = false + private readonly pendingOutboundEvents: PendingOutboundEvent[] = [] + private didWarnPendingQueueFull = false + + constructor(token: string, session: Session, options: ApiSessionClientOptions = {}) { super() this.token = token this.sessionId = session.id @@ -180,6 +258,9 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = session.metadataVersion this.agentState = session.agentState this.agentStateVersion = session.agentStateVersion + this.materializer = options.materialize + this.onMaterialized = options.onMaterialized + this.state = this.materializer ? 'pending' : 'active' this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.sessionId, @@ -217,6 +298,7 @@ export class ApiSessionClient extends EventEmitter { this.socket.on('connect', () => { logger.debug('Socket connected successfully') + this.awaitingMaterializedConnection = false this.rpcHandlerManager.onSocketConnect(this.socket) if (this.hasConnectedOnce) { this.needsBackfill = true @@ -332,7 +414,186 @@ export class ApiSessionClient extends EventEmitter { } }) - this.socket.connect() + if (this.state === 'active') { + this.socket.connect() + } + } + + getState(): ApiSessionClientState { + return this.state + } + + isPending(): boolean { + return this.state === 'pending' || this.state === 'materializing' + } + + private isClosed(): boolean { + return this.state === 'closed' + } + + async materialize(): Promise { + if (this.state === 'active') { + return true + } + if (this.state === 'closed' || !this.materializer || this.materializationDrainRequested) { + return false + } + if (this.materializationTask) { + return await this.materializationTask + } + + this.state = 'materializing' + const abortController = new AbortController() + this.materializationAbortController = abortController + this.materializationTask = this.runMaterializationLoop(abortController.signal) + .finally(() => { + this.materializationTask = null + if (this.materializationAbortController === abortController) { + this.materializationAbortController = null + } + }) + return await this.materializationTask + } + + private async runMaterializationLoop(signal: AbortSignal): Promise { + let retryDelayMs = MATERIALIZATION_RETRY_MIN_MS + let finalDrainAttemptStarted = false + + while (!signal.aborted && !this.isClosed()) { + this.metadataChangedDuringAttempt = false + this.agentStateChangedDuringAttempt = false + const snapshot: PendingSessionSnapshot = { + metadata: this.metadata, + agentState: this.agentState + } + + try { + const materialized = await this.materializer!(snapshot, signal) + if (signal.aborted || this.isClosed()) { + return false + } + + const latestMetadata = this.metadata + const latestAgentState = this.agentState + const shouldSyncMetadata = this.metadataChangedDuringAttempt + || !hasSameJsonValue(materialized.metadata, latestMetadata) + const shouldSyncAgentState = this.agentStateChangedDuringAttempt + || !hasSameJsonValue(materialized.agentState, latestAgentState) + + this.metadata = materialized.metadata + this.metadataVersion = materialized.metadataVersion + this.agentState = materialized.agentState + this.agentStateVersion = materialized.agentStateVersion + this.state = 'active' + + if (shouldSyncMetadata && latestMetadata) { + this.updateMetadata(() => latestMetadata) + } + if (shouldSyncAgentState && latestAgentState) { + this.updateAgentState(() => latestAgentState) + } + + const pendingEvents = this.pendingOutboundEvents.splice(0) + this.awaitingMaterializedConnection = pendingEvents.length > 0 + || shouldSyncMetadata + || shouldSyncAgentState + for (const pendingEvent of pendingEvents) { + pendingEvent.emit() + } + this.socket.connect() + try { + this.onMaterialized?.(materialized, { + metadata: latestMetadata, + agentState: latestAgentState + }) + } catch (error) { + logger.debug(`[API] Post-materialization callback failed for ${this.sessionId}`, error) + } + logger.debug(`[API] Materialized pending session ${this.sessionId}`) + return true + } catch (error) { + if (signal.aborted || this.isClosed()) { + return false + } + if (!isTransientMaterializationError(error)) { + this.state = 'pending' + logger.warn(`[API] Failed to materialize pending session ${this.sessionId}`, error) + return false + } + if (this.materializationDrainRequested) { + if (finalDrainAttemptStarted) { + this.state = 'pending' + return false + } + finalDrainAttemptStarted = true + logger.debug(`[API] Retrying materialization once during final drain for ${this.sessionId}`) + continue + } + + logger.debug( + `[API] Hub unavailable while materializing ${this.sessionId}; retrying in ${retryDelayMs}ms`, + error + ) + const retryAbortController = new AbortController() + this.materializationRetryAbortController = retryAbortController + const completedDelay = await waitForAbortableDelay( + retryDelayMs, + signal, + retryAbortController.signal + ) + if (this.materializationRetryAbortController === retryAbortController) { + this.materializationRetryAbortController = null + } + if (!completedDelay) { + if (signal.aborted || this.isClosed()) { + return false + } + if (this.materializationDrainRequested && !finalDrainAttemptStarted) { + finalDrainAttemptStarted = true + logger.debug(`[API] Skipping materialization backoff during final drain for ${this.sessionId}`) + continue + } + this.state = 'pending' + return false + } + retryDelayMs = Math.min(retryDelayMs * 2, MATERIALIZATION_RETRY_MAX_MS) + } + } + + return false + } + + private emitOrQueue( + emit: () => void, + retention: PendingOutboundEvent['retention'] = 'lossless' + ): void { + if (this.state === 'active') { + emit() + return + } + if (this.state === 'closed') { + return + } + + if (retention === 'droppable') { + const droppableCount = this.pendingOutboundEvents.reduce( + (count, event) => count + (event.retention === 'droppable' ? 1 : 0), + 0 + ) + if (droppableCount >= MAX_PENDING_DROPPABLE_EVENTS) { + const oldestDroppableIndex = this.pendingOutboundEvents.findIndex( + (event) => event.retention === 'droppable' + ) + if (oldestDroppableIndex >= 0) { + this.pendingOutboundEvents.splice(oldestDroppableIndex, 1) + } + if (!this.didWarnPendingQueueFull) { + this.didWarnPendingQueueFull = true + logger.warn(`[API] Pending control event queue full for ${this.sessionId}; dropping oldest control event`) + } + } + } + this.pendingOutboundEvents.push({ emit, retention }) } onUserMessage(callback: (data: UserMessage, localId?: string) => void): void { @@ -482,9 +743,11 @@ export class ApiSessionClient extends EventEmitter { } } - this.socket.emit('message', { - sid: this.sessionId, - message: content + this.emitOrQueue(() => { + this.socket.emit('message', { + sid: this.sessionId, + message: content + }) }) if (body.type === 'summary' && 'summary' in body && 'leafUuid' in body) { @@ -515,10 +778,17 @@ export class ApiSessionClient extends EventEmitter { } } - this.socket.emit('message', { - sid: this.sessionId, - message: content + this.emitOrQueue(() => { + this.socket.emit('message', { + sid: this.sessionId, + message: content + }) }) + this.notifyUserActivity() + } + + notifyUserActivity(): void { + void this.materialize() } sendAgentMessage(body: unknown): void { @@ -532,9 +802,11 @@ export class ApiSessionClient extends EventEmitter { sentFrom: 'cli' } } - this.socket.emit('message', { - sid: this.sessionId, - message: content + this.emitOrQueue(() => { + this.socket.emit('message', { + sid: this.sessionId, + message: content + }) }) } @@ -559,10 +831,12 @@ export class ApiSessionClient extends EventEmitter { } } - this.socket.emit('message', { - sid: this.sessionId, - message: content - }) + this.emitOrQueue(() => { + this.socket.emit('message', { + sid: this.sessionId, + message: content + }) + }, event.type === 'message' ? 'lossless' : 'droppable') } keepAlive( @@ -577,6 +851,9 @@ export class ApiSessionClient extends EventEmitter { collaborationMode?: SessionCollaborationMode } ): void { + if (this.state !== 'active') { + return + } this.socket.volatile.emit('session-alive', { sid: this.sessionId, time: Date.now(), @@ -588,10 +865,12 @@ export class ApiSessionClient extends EventEmitter { /** Hub waits for this before mergeSessions on Cursor ACP reopen (tiann/hapi#939). */ emitSessionReady(): void { - this.socket.emit('session-ready', { - sid: this.sessionId, - time: Date.now() - }) + this.emitOrQueue(() => { + this.socket.emit('session-ready', { + sid: this.sessionId, + time: Date.now() + }) + }, 'droppable') } emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { @@ -609,15 +888,28 @@ export class ApiSessionClient extends EventEmitter { if (options?.clearQueuedThinkingGrace) { payload.clearQueuedThinkingGrace = true } - this.socket.emit('messages-consumed', payload) + this.emitOrQueue(() => this.socket.emit('messages-consumed', payload)) } sendSessionDeath(reason?: SessionEndReason): void { - void cleanupUploadDir(this.sessionId) - this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason }) + if (this.state === 'active') { + void cleanupUploadDir(this.sessionId) + } + this.emitOrQueue(() => { + this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason }) + }) } updateMetadata(handler: (metadata: Metadata) => Metadata): void { + if (this.state !== 'active') { + if (this.state === 'closed') return + const current = this.metadata ?? ({} as Metadata) + this.metadata = handler(current) + if (this.state === 'materializing') { + this.metadataChangedDuringAttempt = true + } + return + } this.metadataLock.inLock(async () => { await backoff(async () => { const current = this.metadata ?? ({} as Metadata) @@ -654,6 +946,15 @@ export class ApiSessionClient extends EventEmitter { } updateAgentState(handler: (state: AgentState) => AgentState): void { + if (this.state !== 'active') { + if (this.state === 'closed') return + const current = this.agentState ?? ({} as AgentState) + this.agentState = handler(current) + if (this.state === 'materializing') { + this.agentStateChangedDuringAttempt = true + } + return + } this.agentStateLock.inLock(async () => { await backoff(async () => { const current = this.agentState ?? ({} as AgentState) @@ -689,62 +990,83 @@ export class ApiSessionClient extends EventEmitter { }) } - private async waitForConnected(timeoutMs: number): Promise { - if (this.socket.connected) { - return true + private async drainLock(lock: AsyncLock, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return false } - this.socket.connect() - return await new Promise((resolve) => { let settled = false + let timeout: ReturnType | null = null - const cleanup = () => { - this.socket.off('connect', onConnect) - clearTimeout(timeout) - } - - const onConnect = () => { + const finish = (value: boolean) => { if (settled) return settled = true - cleanup() - resolve(true) + if (timeout) { + clearTimeout(timeout) + } + resolve(value) } - const timeout = setTimeout(() => { + timeout = setTimeout(() => finish(false), timeoutMs) + + lock.inLock(async () => { }) + .then(() => finish(true)) + .catch(() => finish(false)) + }) + } + + private async waitForPromise(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return false + } + + return await new Promise((resolve) => { + let settled = false + const timeout = setTimeout(() => finish(false), timeoutMs) + const finish = (value: boolean) => { if (settled) return settled = true - cleanup() - resolve(false) - }, Math.max(0, timeoutMs)) + clearTimeout(timeout) + resolve(value) + } - this.socket.on('connect', onConnect) + promise.then(() => finish(true)).catch(() => finish(true)) }) } - private async drainLock(lock: AsyncLock, timeoutMs: number): Promise { + private async waitForConnected(timeoutMs: number): Promise { + if (this.socket.connected) { + return true + } if (timeoutMs <= 0) { return false } return await new Promise((resolve) => { let settled = false - let timeout: ReturnType | null = null - + const cleanup = () => { + clearTimeout(timeout) + this.socket.off('connect', onConnect) + } const finish = (value: boolean) => { if (settled) return settled = true - if (timeout) { - clearTimeout(timeout) - } + cleanup() resolve(value) } + const onConnect = () => finish(true) + const timeout = setTimeout(() => finish(false), timeoutMs) - timeout = setTimeout(() => finish(false), timeoutMs) + this.socket.on('connect', onConnect) - lock.inLock(async () => { }) - .then(() => finish(true)) - .catch(() => finish(false)) + if (!this.awaitingMaterializedConnection) { + this.socket.connect() + } + + if (this.socket.connected) { + finish(true) + } }) } @@ -760,23 +1082,38 @@ export class ApiSessionClient extends EventEmitter { * Returns true when the lock drained, false when the timeout fired. */ async flushMetadata(timeoutMs: number = 5_000): Promise { + if (this.state !== 'active') { + return false + } return await this.drainLock(this.metadataLock, timeoutMs) } async flush(options?: { timeoutMs?: number }): Promise { const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000) - const remainingMs = () => Math.max(0, deadlineMs - Date.now()) - await this.drainLock(this.metadataLock, remainingMs()) - await this.drainLock(this.agentStateLock, remainingMs()) + const materializationTask = this.materializationTask + if (materializationTask) { + this.materializationDrainRequested = true + this.materializationRetryAbortController?.abort() + await this.waitForPromise(materializationTask, remainingMs()) + } - if (remainingMs() === 0) { + if (this.state !== 'active') { return } - const connected = await this.waitForConnected(remainingMs()) - if (!connected) { + if (!this.socket.connected) { + const connected = await this.waitForConnected(remainingMs()) + if (!connected) { + return + } + } + + await this.drainLock(this.metadataLock, remainingMs()) + await this.drainLock(this.agentStateLock, remainingMs()) + + if (remainingMs() === 0) { return } @@ -787,12 +1124,23 @@ export class ApiSessionClient extends EventEmitter { try { await this.socket.timeout(pingTimeoutMs).emitWithAck('ping') + this.awaitingMaterializedConnection = false } catch { // best effort } } close(): void { + if (this.state === 'closed') { + return + } + this.state = 'closed' + this.materializationAbortController?.abort() + this.materializationAbortController = null + this.materializationRetryAbortController?.abort() + this.materializationRetryAbortController = null + this.awaitingMaterializedConnection = false + this.pendingOutboundEvents.length = 0 this.rpcHandlerManager.onSocketDisconnect() this.terminalManager.closeAll() this.socket.disconnect() diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index f3ca0eb702..fb009dfc6b 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -105,7 +105,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', cwd: session.path, - version: process.env.npm_package_version + version: process.env.npm_package_version, + selectedModel: session.getModel() }, permissionHandler.getResponses()); const handleSessionFound = (sessionId: string) => { @@ -318,6 +319,12 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let p = pending; pending = null; permissionHandler.handleModeChange(p.mode.permissionMode); + // Re-resolve the selected-model seed hint for every turn, not + // just the first: a single claudeRemote() call keeps accepting + // new turns (potentially with a different model, e.g. after a + // mid-session model switch), so a construction-time snapshot + // would go stale. See SDKToLogConverter.updateSelectedModel. + sdkToLogConverter.updateSelectedModel(p.mode.model ?? null); return p; } @@ -332,6 +339,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { modeHash = msg.hash; mode = msg.mode; permissionHandler.handleModeChange(mode.permissionMode); + sdkToLogConverter.updateSelectedModel(mode.model ?? null); return { message: msg.message, mode: msg.mode diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 5ea303276f..8c4e1cbe1f 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -236,7 +236,7 @@ describe('SDKToLogConverter', () => { type: 'system', subtype: 'init', session_id: 'session-2', - model: 'claude-sonnet-4-6' + model: 'claude-sonnet-5' } converter.convert(initMsg) @@ -308,6 +308,452 @@ describe('SDKToLogConverter', () => { const log = converter.convert(makeAssistantMessage()) as any expect(log?.message?.usage?.context_window).toBeUndefined() }) + + it('does not downgrade to the 200k heuristic on a same-model re-init after result refined it (sticky, per-model)', () => { + // Turn 1: new-CLI-style init with no [1m] suffix (the actual regression trigger). + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn1 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn1?.message?.usage?.context_window).toBe(200_000) + + // Result arrives with the authoritative window for this model. + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-5', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // Turn 2: the CLI re-emits system/init for the *same* model (this happens on + // every turn in the remote launcher's while-loop). The stale 200k heuristic + // must NOT clobber the value we already learned from result.modelUsage. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn2 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi again' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn2?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('gives a switched-to model its own seed instead of inheriting the previous model\'s cached window (per-model cache, not globally sticky)', () => { + // Learn opus's real 1M window first. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-6', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // User switches to a different model mid-session. It has no cached value and no + // 1M seed signal here (bare init, no selectedModel), so it gets its own + // conservative 200k seed rather than inheriting opus's cached 1M. (Its real + // window would arrive with its own first result; this asserts cache isolation, + // i.e. the value is keyed per model rather than a single global sticky number.) + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + + const afterSwitch = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + // The switched-to model gets its own seed, not opus's cached 1M value. + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds a 1M turn-1 estimate from selectedModel for an [1m] preset whose init model arrives bare (fable[1m] shape)', () => { + // Real claude 2.1.200 shape for "fable[1m]": init.model and result keys are + // BARE ("claude-fable-5", no suffix), unlike opus[1m]/sonnet[1m] which keep it. + // So systemMsg.model.endsWith('[1m]') is false here — the selectedModel hint is + // the only thing that lets turn 1 seed 1M instead of flashing 200k until the + // first result lands. This is why the selectedModel seed is load-bearing. + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-7', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('seeds a conservative 200k when neither the bare init model nor a selectedModel hint indicates 1M, until result confirms it', () => { + // Bare init model + no selectedModel hint: neither signal says "1M", so we can't + // know the real window on turn 1 and seed 200k conservatively rather than + // guessing high. The authoritative value arrives with the first result. (A real + // 1M account whose init keeps the suffix, e.g. "claude-opus-4-8[1m]", or that + // carries a selectedModel hint, seeds 1M immediately instead — covered above.) + const defaultConverter = new SDKToLogConverter({ ...context } as any) + + defaultConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-8', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = defaultConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds correctly after a live mid-session switch TO an [1m] preset (updateSelectedModel), not the stale construction-time value', () => { + // Session started on Default (no selectedModel) -- as HAPI's remote launcher + // does for every turn via updateSelectedModel(), not just turn 1. + const liveConverter = new SDKToLogConverter({ ...context } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(200_000) + + // User switches to an explicit 1M preset (fable[1m], whose init model arrives + // bare so the selectedModel hint is what carries the 1M signal). Without a live + // update, the converter would still be seeding from the session-start snapshot + // (none) and would under-seed 200k for this new model's first turn too. + liveConverter.updateSelectedModel('fable[1m]') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-fable-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('does not over-seed after a live mid-session switch AWAY FROM an [1m] preset (updateSelectedModel)', () => { + // Session started on an explicit 1M preset (fable[1m]; its init model arrives + // bare, so the selectedModel hint carries the 1M signal on turn 1). + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-fable-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(1_000_000) + + // User switches away to a model with no 1M signal (no "[1m]" on the updated + // selectedModel and none on the bare init model). Without a live update, the + // converter would still be seeding from the stale "fable[1m]" snapshot and + // would over-seed 1,000,000 for the newly-selected model's first turn. + liveConverter.updateSelectedModel('sonnet') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('injects 1M for an explicit [1m] preset whose assistant messages report a bare model id (real opus[1m] shape)', () => { + // Real claude 2.1.200 shape for an explicit "opus[1m]" session: system/init and + // result.modelUsage both use the suffixed id "claude-opus-4-8[1m]", while each + // assistant message reports the bare "claude-opus-4-8". Because init and result + // agree, the cache stores 1M under the suffixed key and the assistant lookup — + // which goes through the resolved cache key (= the suffixed init id here), not + // the bare message.model — finds it. (A lookup keyed on the bare message.model + // would miss.) + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-11', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + + // result reports the authoritative 1M under the SUFFIXED key... + seededConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-11', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // ...but the assistant message reports the BARE model id. + const assistant = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(assistant?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('injects the MAIN session window into a sidechain (subagent) message, not the subagent model\'s own window', () => { + // Main session is opus[1m] (1M). A Task subagent runs on haiku (200k). Because + // the web status bar picks the most recent usage message without filtering + // sidechains, the subagent's assistant message must carry the main 1M window, + // or the footer denominator would visibly drop to 200k while the subagent runs. + // The subagent emits no system/init of its own, so the resolved cache key stays + // on the main model — and since lookups always go through that key, the sidechain + // message inherits the main window automatically. (The subagent's own 200k is + // still cached under its own id, but it is never the resolved lookup key here.) + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-12', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + liveConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-12', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 }, + 'claude-haiku-4-5-20251001': { contextWindow: 200_000 } + } + } as SDKResultMessage) + + // Sidechain assistant message from the haiku subagent (carries parent_tool_use_id). + const sidechain = liveConverter.convert({ + type: 'assistant', + parent_tool_use_id: 'toolu_task_1', + message: { + role: 'assistant', + model: 'claude-haiku-4-5-20251001', + content: [{ type: 'text', text: 'subagent reply' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(sidechain?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('keeps plain vs [1m] variants of the same base model on distinct cache keys (multi-tier: no collision)', () => { + // On some tiers plain "sonnet" is 200k while "sonnet[1m]" is 1M. For sonnet the + // CLI already reports the "[1m]" on system/init.model and the result key, so the + // two variants land on DISTINCT cache keys on their own (only the per-turn + // assistant message.model is bare/lossy, which is why lookups go through the + // resolved cache key, not message.model). fable is the case where the CLI does + // NOT suffix the id and the key has to be folded — covered by the next test. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'sonnet[1m]' } as any) + + // Turn 1 on sonnet[1m]: learns 1M under the suffixed key. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5[1m]': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain sonnet (200k on this tier): learns 200k under the bare key. + conv.updateSelectedModel('sonnet') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5': { contextWindow: 200_000 } } + } as SDKResultMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to sonnet[1m], turn-1 before its result re-arrives: must still read + // 1M from the suffixed key, NOT the 200k that plain sonnet just cached under the + // bare key (that cross-contamination is exactly what suffix-stripping would cause). + conv.updateSelectedModel('sonnet[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('distinguishes fable vs fable[1m] even though the CLI reports both with the bare id', () => { + // Unlike opus[1m]/sonnet[1m], the CLI reports BOTH "fable" and "fable[1m]" with + // the bare id "claude-fable-5" on system/init and in result.modelUsage. A cache + // keyed on that raw id alone can't tell the two apart, so switching fable[1m] + // (1M) -> fable (200k) would keep showing the stale 1M until fable's result + // lands. Folding the selectedModel's "[1m]" into the cache key keeps them + // distinct. selectedModel is the ONLY turn-1 signal that separates them here. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'fable[1m]' } as any) + + // fable[1m]: seeds 1M from selectedModel, result confirms 1M. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-fable-5': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain fable (200k): must re-seed 200k, NOT keep the stale 1M. + conv.updateSelectedModel('fable') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to fable[1m]: its 1M entry was never overwritten by plain fable's + // 200k (distinct keys), so turn-1 before the next result still reads 1M. + conv.updateSelectedModel('fable[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) }) describe('Parent-child relationships', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 7dcbafb6a6..36ec09cf29 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -24,6 +24,12 @@ export interface ConversionContext { version?: string gitBranch?: string parentUuid?: string | null + // The model preset the session actually selected at launch time (e.g. "fable[1m]"), + // with the `[1m]` suffix intact. Some 1M presets (fable[1m]) arrive on system/init + // with the suffix already dropped ("claude-fable-5"), so this preserved preset is + // the only turn-1 signal that such a session is 1M. Used only to seed the very first + // contextWindow estimate before result.modelUsage confirms the real value. + selectedModel?: string | null } type PermissionResponse = { @@ -57,8 +63,22 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + // The raw model id from the most recent system/init (the session's authoritative model). private resolvedModel: string | null = null - private modelContextWindow: number | null = null + // The cache key for the current session model's contextWindow. Usually equal to + // resolvedModel, but for presets whose "[1m]" variant the CLI still reports with the + // bare id (fable[1m] arrives as "claude-fable-5", same as plain fable) it folds the + // selectedModel's "[1m]" back in so the two variants don't collide. See + // computeContextWindowKey. + private resolvedContextWindowKey: string | null = null + // Per-model contextWindow cache. Keys are the CLI's model id, except that for a + // preset whose "[1m]" variant shares the bare id of its plain form (fable) the "[1m]" + // is folded back into the key (computeContextWindowKey) so a 1M variant and a + // potentially-smaller plain variant stay on distinct entries. opus[1m]/sonnet[1m] + // already arrive suffixed from the CLI, so their keys are unchanged. Keying per model + // — rather than a single sticky number — means a mid-session model switch picks up the + // new model's own window immediately instead of inheriting the previous model's. + private modelContextWindows = new Map() constructor( context: Omit, @@ -73,6 +93,39 @@ export class SDKToLogConverter { this.responses = responses } + /** + * Compute the contextWindow cache key for an init model id. + * + * The CLI reports opus[1m]/sonnet[1m] with the "[1m]" suffix already on the id, but + * reports fable[1m] with the same bare id as plain fable ("claude-fable-5"). To keep a + * 1M variant from colliding with its (possibly-smaller) plain form, we fold the "[1m]" + * back onto the bare id when the session's selected preset asks for 1M. Ids that + * already carry the suffix are returned unchanged. + */ + private computeContextWindowKey(model: string): string { + if (model.endsWith('[1m]')) { + return model + } + const wants1m = this.context.selectedModel?.endsWith('[1m]') ?? false + return wants1m ? `${model}[1m]` : model + } + + /** + * Update the originally-selected model hint (for when the session's model + * changes mid-conversation, e.g. via the web model picker). `context.selectedModel` + * is only a turn-1 seed hint (see the system/init handler in `convert()`), but the + * caller (claudeRemoteLauncher) re-resolves the active mode -- including its model -- + * on every turn, not just the first, since a single long-running `claudeRemote()` + * call keeps accepting new turns with a live-updatable mode. Without this update, + * a mid-session switch would seed new models from a stale, session-start value: + * switching *to* an 1M preset would under-seed (still guess 200k for its first + * turn), and switching *away from* one would over-seed (guess 1M for a model that + * isn't 1M-capable) until a result message corrects it. + */ + updateSelectedModel(model: string | null | undefined): void { + this.context.selectedModel = model ?? null + } + /** * Update session ID (for when session changes during resume) */ @@ -198,10 +251,24 @@ export class SDKToLogConverter { case 'assistant': { const assistantMsg = sdkMessage as SDKAssistantMessage const message = assistantMsg.message as Record - if (this.modelContextWindow !== null && message && typeof message.usage === 'object' && message.usage !== null) { + // Look up the contextWindow by the session's resolved cache key (derived + // from the last system/init model), NOT the assistant message's own `model` + // field. The message's model is always reported bare (no "[1m]"), so it + // can't tell a 200k plain preset apart from its 1M "[1m]" variant when they + // share a base id; resolvedContextWindowKey carries the disambiguated key. + // Using the resolved key also means sidechain (Task subagent) messages carry + // the MAIN session window rather than the subagent's own — the web status + // bar's latestUsage picks the most recent usage message without filtering + // sidechains (Claude usage carries no scope_role), so a subagent's smaller + // window would otherwise make the footer denominator visibly drop while it + // runs. + const contextWindow = this.resolvedContextWindowKey + ? this.modelContextWindows.get(this.resolvedContextWindowKey) + : undefined + if (contextWindow !== undefined && message && typeof message.usage === 'object' && message.usage !== null) { const usage = message.usage as Record if (usage.context_window === undefined) { - usage.context_window = this.modelContextWindow + usage.context_window = contextWindow } } logMessage = { @@ -229,13 +296,33 @@ export class SDKToLogConverter { this.updateSessionId(systemMsg.session_id) } - // Capture the resolved model name on init (e.g. "claude-opus-4-7[1m]"). - // The `[1m]` suffix is stripped on per-turn assistant messages, so - // remember the full name here to derive an initial contextWindow - // estimate. The result message later refines it with the real value. + // Capture the resolved model name on init. The remote launcher re-emits + // system/init on every turn for the lifetime of this converter, so if we + // already learned this model's real contextWindow from a previous result + // message, leave it alone — recomputing a heuristic guess here would + // downgrade an already-known-good value and is the exact cause of the + // 200k<->1M flicker. Only seed a heuristic when this model has no cached + // value yet (first time we see it in this session). if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') { this.resolvedModel = systemMsg.model - this.modelContextWindow = systemMsg.model.endsWith('[1m]') ? 1_000_000 : 200_000 + this.resolvedContextWindowKey = this.computeContextWindowKey(systemMsg.model) + if (!this.modelContextWindows.has(this.resolvedContextWindowKey)) { + // Best-effort 1M-vs-200k seed for turn 1, before any authoritative + // result has arrived. `systemMsg.model` only tells us it's a 1M + // model for the presets whose init keeps the "[1m]" suffix + // (opus[1m]/sonnet[1m]); for others the init model is bare even + // when it's a 1M preset (fable[1m] -> "claude-fable-5"). So we + // primarily consult the originally-selected preset, which always + // preserves the suffix (e.g. "fable[1m]"), and fall back to the + // init model string. This selectedModel seed is load-bearing — + // without it, a fresh fable[1m] turn would flash 200k until the + // first result lands. Guarding/seeding on resolvedContextWindowKey + // (not the bare init id) is what forces a re-seed when switching + // fable[1m] <-> fable, whose bare ids would otherwise be identical. + const seedIs1m = (this.context.selectedModel?.endsWith('[1m]') ?? false) + || systemMsg.model.endsWith('[1m]') + this.modelContextWindows.set(this.resolvedContextWindowKey, seedIs1m ? 1_000_000 : 200_000) + } } // System messages are typically not sent to logs @@ -257,13 +344,25 @@ export class SDKToLogConverter { // They're SDK-specific messages that indicate session completion // Not part of the actual conversation log. // - // But they carry the authoritative per-model contextWindow, - // which we cache and inject into subsequent assistant messages. + // But they carry the authoritative per-model contextWindow. modelUsage is + // keyed by the same raw model id the CLI reports on system/init, so the + // entry for the current session model is stored under resolvedContextWindowKey + // (which folds in the "[1m]" for fable), matching what assistant lookups use. + // Other entries — Task subagents like haiku — are stored under their own raw + // id (never fold the session's "[1m]" onto a subagent; haiku is 200k). Always + // overwrite on result — it is ground truth — for every model reported, so a + // model switched away from earlier this session keeps its real value cached + // for if/when the session switches back to it. const resultMsg = sdkMessage as SDKResultMessage - if (resultMsg.modelUsage && this.resolvedModel) { - const cw = resultMsg.modelUsage[this.resolvedModel]?.contextWindow - if (typeof cw === 'number' && cw > 0) { - this.modelContextWindow = cw + if (resultMsg.modelUsage) { + for (const [model, usage] of Object.entries(resultMsg.modelUsage)) { + const cw = usage?.contextWindow + if (typeof cw === 'number' && cw > 0) { + const key = (this.resolvedModel && model === this.resolvedModel) + ? (this.resolvedContextWindowKey ?? model) + : model + this.modelContextWindows.set(key, cw) + } } } break diff --git a/cli/src/claude/utils/sessionHookForwarder.ts b/cli/src/claude/utils/sessionHookForwarder.ts index 8cc206d307..b34183ac3e 100644 --- a/cli/src/claude/utils/sessionHookForwarder.ts +++ b/cli/src/claude/utils/sessionHookForwarder.ts @@ -1,5 +1,7 @@ import { request } from 'node:http'; +export const SESSION_HOOK_FORWARD_TIMEOUT_MS = 1_000; + function logError(message: string, error?: unknown): void { const detail = error instanceof Error ? error.message : (error ? String(error) : ''); const suffix = detail ? `: ${detail}` : ''; @@ -93,6 +95,13 @@ export async function runSessionHookForwarder(args: string[]): Promise { let hadError = false; await new Promise((resolve) => { + let settled = false; + let timedOut = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; const req = request({ host: '127.0.0.1', port, @@ -111,16 +120,25 @@ export async function runSessionHookForwarder(args: string[]): Promise { res.on('error', (error) => { hadError = true; logError('Error reading hook server response', error); - resolve(); + finish(); }); - res.on('end', () => resolve()); + res.on('end', finish); res.resume(); }); req.on('error', (error) => { hadError = true; - logError('Failed to send hook request', error); - resolve(); + if (!timedOut) { + logError('Failed to send hook request', error); + } + finish(); + }); + req.setTimeout(SESSION_HOOK_FORWARD_TIMEOUT_MS, () => { + timedOut = true; + hadError = true; + logError(`Hook request timed out after ${SESSION_HOOK_FORWARD_TIMEOUT_MS}ms`); + req.destroy(); + finish(); }); req.end(body); }); diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts index 02073edfe6..096ac6f19e 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/claude/utils/startHookServer.ts @@ -107,7 +107,6 @@ export async function startHookServer(options: HookServerOptions): Promise { + try { + onSessionHook(sessionId, data); + } catch (error) { + logger.debug('[hookServer] Error dispatching session hook:', error); + } + }); } catch (error) { clearTimeout(timeout); if (timedOut) { diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index e6cb42c579..2978aab487 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -1,4 +1,16 @@ -export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'; +export type ApprovalPolicyPreset = 'untrusted' | 'on-failure' | 'on-request' | 'never'; + +export type ApprovalPolicy = + | ApprovalPolicyPreset + | { + granular: { + sandbox_approval: boolean; + rules: boolean; + skill_approval?: boolean; + request_permissions?: boolean; + mcp_elicitations: boolean; + }; + }; export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'; export interface InitializeCapabilities { @@ -153,7 +165,9 @@ export type SandboxPolicy = excludeSlashTmp?: boolean; }; -export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +// The app server reports supported effort identifiers per model. Keep this +// open so newly introduced server values can flow through without a CLI update. +export type ReasoningEffort = string; export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed'; export type CollaborationMode = { @@ -202,6 +216,19 @@ export interface TurnInterruptResponse { [key: string]: unknown; } +export interface ThreadRollbackParams { + threadId: string; + numTurns: number; +} + +export interface ThreadRollbackResponse { + thread: { + id: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + export interface ThreadCompactStartParams { threadId: string; } diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 0aa2e72bf0..31569e13b7 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -16,6 +16,8 @@ import type { TurnStartResponse, TurnInterruptParams, TurnInterruptResponse, + ThreadRollbackParams, + ThreadRollbackResponse, ThreadCompactStartParams, ThreadCompactStartResponse, ThreadGoalSetParams, @@ -207,6 +209,18 @@ export class CodexAppServerClient extends JsonLineParser { return response as TurnInterruptResponse; } + /** + * Deprecated upstream, but still required to match Codex's native + * safety-buffering retry flow. Keep the protocol call isolated here so it + * can be replaced when app-server exposes a successor. + */ + async rollbackThread(params: ThreadRollbackParams): Promise { + const response = await this.sendRequest('thread/rollback', params, { + timeoutMs: 30_000 + }); + return response as ThreadRollbackResponse; + } + async compactThread( params: ThreadCompactStartParams, options?: { signal?: AbortSignal } diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 74e7237a4d..345b69680c 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -70,11 +70,13 @@ function createSessionStub( codexArgs?: string[], path = '/tmp/worktree', initialTranscriptPath: string | null = null, - replayTranscriptHistoryOnStart = false + replayTranscriptHistoryOnStart = false, + pendingClient = false ) { const sessionEvents: Array<{ type: string; message?: string }> = []; const userMessages: string[] = []; const agentMessages: unknown[] = []; + let userActivityCount = 0; let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; let sessionId: string | null = null; let transcriptPath: string | null = initialTranscriptPath; @@ -94,6 +96,7 @@ function createSessionStub( codexArgs, replayTranscriptHistoryOnStart, client: { + isPending: () => pendingClient, rpcHandlerManager: { registerHandler: () => {} } @@ -130,6 +133,9 @@ function createSessionStub( sendUserMessage: (message: string) => { userMessages.push(message); }, + notifyUserActivity: () => { + userActivityCount += 1; + }, sendAgentMessage: (message: unknown) => { agentMessages.push(message); }, @@ -138,6 +144,7 @@ function createSessionStub( sessionEvents, userMessages, agentMessages, + getUserActivityCount: () => userActivityCount, getLocalLaunchFailure: () => localLaunchFailure }; } @@ -354,6 +361,68 @@ describe('codexLocalLauncher', () => { }); }); + it('falls back to fresh transcript activity when SessionStart does not arrive', async () => { + const originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = tempDir; + const now = new Date(); + const sessionDirectory = join( + tempDir, + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ); + await mkdir(sessionDirectory, { recursive: true }); + const transcriptPath = join(sessionDirectory, 'rollout-fallback-thread.jsonl'); + const { session, userMessages } = createSessionStub( + 'default', + ['--cd', '/tmp/effective-codex-cwd'], + '/tmp/worktree', + null, + true, + true + ); + let releaseRunBarrier: (() => void) | undefined; + harness.runBarrier = new Promise((resolve) => { + releaseRunBarrier = resolve; + }); + + try { + const launcherPromise = codexLocalLauncher(session as never); + await vi.waitFor(() => expect(harness.launches).toHaveLength(1)); + expect(session.sessionId).toBeNull(); + + await writeFile(transcriptPath, [ + JSON.stringify({ + type: 'session_meta', + payload: { id: 'fallback-thread', cwd: '/tmp/effective-codex-cwd' } + }), + JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'fallback prompt' } + }) + ].join('\n') + '\n'); + + await vi.waitFor( + () => expect(session.sessionId).toBe('fallback-thread'), + { timeout: 3_000, interval: 50 } + ); + if (releaseRunBarrier) releaseRunBarrier(); + await launcherPromise; + + expect(session.transcriptPath).toBe(transcriptPath); + expect(userMessages).toContain('fallback prompt'); + } finally { + if (releaseRunBarrier) releaseRunBarrier(); + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + } + }); + it('replays existing transcript messages when importing a Codex thread into a new Hapi session', async () => { const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); @@ -390,9 +459,9 @@ describe('codexLocalLauncher', () => { }); }); - it('replays existing response_item chat messages when importing a Codex thread into a new Hapi session', async () => { + it('replays semantic chat events once and keeps a same-turn preface before its plan', async () => { const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl'); - const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); + const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; @@ -407,7 +476,23 @@ describe('codexLocalLauncher', () => { payload: { type: 'message', role: 'user', - content: [{ type: 'input_text', text: 'old response_item user message' }] + content: [{ type: 'input_text', text: 'visible user message' }] + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'user_message', message: 'visible user message' } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-with-preface', + item: { + type: 'Plan', + id: 'plan-1', + text: '## Proposed plan\n\n1. Inspect\n2. Implement' + } } }), JSON.stringify({ @@ -415,7 +500,31 @@ describe('codexLocalLauncher', () => { payload: { type: 'message', role: 'assistant', - content: [{ type: 'output_text', text: 'old response_item assistant message' }] + content: [{ + type: 'output_text', + text: 'visible assistant preface\n\n## Proposed plan\n\n1. Inspect\n2. Implement' + }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-with-preface' } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'visible assistant preface', + phase: 'final_answer' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'task_complete', turn_id: 'turn-with-preface' } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hidden context' }] } }) ].join('\n') + '\n' @@ -434,12 +543,87 @@ describe('codexLocalLauncher', () => { } await launcherPromise; - expect(userMessages).toContain('old response_item user message'); - expect(agentMessages).toContainEqual({ + expect(userMessages).toEqual(['visible user message']); + expect(getUserActivityCount()).toBe(0); + expect(agentMessages).toEqual([{ type: 'message', - message: 'old response_item assistant message', + message: 'visible assistant preface', id: expect.any(String) + }, { + type: 'tool-call', + name: 'ExitPlanMode', + callId: 'codex-proposed-plan:plan-1', + input: { plan: '## Proposed plan\n\n1. Inspect\n2. Implement' }, + id: 'plan-1' + }, { + type: 'tool-call-result', + callId: 'codex-proposed-plan:plan-1', + output: null, + id: 'plan-1:result' + }]); + }); + + it('replays a plan-only turn when the turn completes', async () => { + const transcriptPath = join(tempDir, 'codex-import-plan-only-transcript.jsonl'); + const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); + let releaseRunBarrier: (() => void) | undefined; + harness.runBarrier = new Promise((resolve) => { + releaseRunBarrier = resolve; }); + + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-plan-only' } }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-plan-only', + item: { type: 'Plan', id: 'plan-only', text: '## Plan only' } + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: '## Plan only' }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-plan-only' } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'task_complete', turn_id: 'turn-plan-only' } + }) + ].join('\n') + '\n' + ); + + const launcherPromise = codexLocalLauncher(session as never); + await wait(50); + + harness.sessionHookHandlers[0]?.('codex-thread-plan-only', { + transcript_path: transcriptPath + }); + await wait(300); + + if (releaseRunBarrier) { + releaseRunBarrier(); + } + await launcherPromise; + + expect(agentMessages).toEqual([{ + type: 'tool-call', + name: 'ExitPlanMode', + callId: 'codex-proposed-plan:plan-only', + input: { plan: '## Plan only' }, + id: 'plan-only' + }, { + type: 'tool-call-result', + callId: 'codex-proposed-plan:plan-only', + output: null, + id: 'plan-only:result' + }]); }); it('does not let a later non-clear hook replace the primary session', async () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a02e23ed6a..4589a259c4 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -1,14 +1,18 @@ import { logger } from '@/ui/logger'; +import { resolve } from 'node:path'; import { startHookServer } from '@/claude/utils/startHookServer'; import { codexLocal } from './codexLocal'; import type { ReasoningEffort } from './appServerTypes'; import { CodexSession } from './session'; import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner'; -import { convertCodexEvent } from './utils/codexEventConverter'; +import { convertCodexEvent, type CodexMessage } from './utils/codexEventConverter'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; -import { stripCodexCliOverrides } from './utils/codexCliOverrides'; +import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCliOverrides'; import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; +import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator'; + +type ProposedPlanMessage = Extract; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { const resumeSessionId = session.sessionId; @@ -18,6 +22,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch let hookReady = false; let shuttingDown = false; let pendingScannerSetup: Promise | null = null; + let transcriptLocator: CodexTranscriptLocator | null = null; + let scannerTranscriptPath: string | null = null; + const pendingPlansByTurnId = new Map(); const permissionMode = session.getPermissionMode(); const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo' ? permissionMode @@ -28,6 +35,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch ...stripCodexCliOverrides(session.codexArgs) ] : session.codexArgs; + const cwdOverride = parseCodexCliOverrides(session.codexArgs).cwd; + const effectiveCodexCwd = cwdOverride ? resolve(session.path, cwdOverride) : session.path; // Start hapi hub for MCP bridge (same as remote mode) const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); @@ -56,6 +65,38 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch return primarySessionId === null || primarySessionId === sessionId; }; + const sendProposedPlan = (message: ProposedPlanMessage): void => { + const callId = `codex-proposed-plan:${message.id}`; + session.sendAgentMessage({ + type: 'tool-call', + name: 'ExitPlanMode', + callId, + input: { plan: message.plan }, + id: message.id + }); + session.sendAgentMessage({ + type: 'tool-call-result', + callId, + output: null, + id: `${message.id}:result` + }); + }; + + const flushPendingPlan = (turnId: string): void => { + const message = pendingPlansByTurnId.get(turnId); + if (!message) { + return; + } + pendingPlansByTurnId.delete(turnId); + sendProposedPlan(message); + }; + + const flushAllPendingPlans = (): void => { + for (const turnId of pendingPlansByTurnId.keys()) { + flushPendingPlan(turnId); + } + }; + const bindPrimarySession = (sessionId: string, transcriptPath: string, allowSwitch = false): void => { if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) { logger.debug(`[codex-local]: Ignoring non-primary SessionStart hook ${sessionId}; primary is ${primarySessionId}`); @@ -78,7 +119,11 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch return; } if (scanner) { + if (scannerTranscriptPath !== transcriptPath) { + flushAllPendingPlans(); + } await scanner.setTranscriptPath(transcriptPath); + scannerTranscriptPath = transcriptPath; return; } const createdScanner = await createCodexSessionScanner({ @@ -103,9 +148,19 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch } if (converted?.userMessage) { session.sendUserMessage(converted.userMessage); + } else if (converted?.userActivity) { + session.notifyUserActivity(); } if (converted?.message) { - session.sendAgentMessage(converted.message); + if (converted.message.type === 'proposed_plan') { + // Codex may complete the Plan item before emitting its final text preface. + pendingPlansByTurnId.set(converted.message.turnId, converted.message); + } else { + session.sendAgentMessage(converted.message); + } + } + if (converted?.finishedTurnId) { + flushPendingPlan(converted.finishedTurnId); } } }); @@ -114,6 +169,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch return; } scanner = createdScanner; + scannerTranscriptPath = transcriptPath; }; const handleTranscriptPath = (transcriptPath: string): Promise => { @@ -142,6 +198,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const hookSource = typeof data.source === 'string' ? data.source : null; const shouldAllowSessionSwitch = hookSource === 'clear'; + if (transcriptPath) { + const activeLocator = transcriptLocator; + transcriptLocator = null; + void activeLocator?.cleanup(); + } + if (!transcriptPath) { handleSessionFound(sessionId, shouldAllowSessionSwitch); return; @@ -160,6 +222,27 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch }); logger.debug(`[codex-local]: Started Codex SessionStart hook server on port ${hookServer.port}`); + if (session.client.isPending()) { + const createdLocator = createCodexTranscriptLocator({ + cwd: effectiveCodexCwd, + startupTimestampMs: Date.now(), + resumeSessionId, + onLocated: ({ sessionId, transcriptPath }) => { + if (shuttingDown || hookReady || primaryTranscriptPath) { + return; + } + transcriptLocator = null; + bindPrimarySession(sessionId, transcriptPath); + }, + onAmbiguous: (paths) => { + transcriptLocator = null; + logger.warn(`[codex-local]: Transcript fallback was ambiguous (${paths.length} active candidates)`); + } + }); + transcriptLocator = createdLocator; + await createdLocator.ready; + } + const launcher = new BaseLocalLauncher({ label: 'codex-local', failureLabel: 'Local Codex process failed', @@ -204,6 +287,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch shuttingDown = true; session.removeTranscriptPathCallback(handleTranscriptPathCallback); hookServer.stop(); + const activeLocator = transcriptLocator; + transcriptLocator = null; + void activeLocator?.cleanup(); if (pendingScannerSetup) { await pendingScannerSetup; } @@ -211,6 +297,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch if (activeScanner) { await activeScanner.cleanup(); } + flushAllPendingPlans(); happyServer.stop(); if (!hookReady) { logger.debug('[codex-local]: SessionStart hook did not provide transcript path before shutdown'); diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 28e931a166..f389b4f22e 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -4,6 +4,7 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, + dispatchNotification: null as ((method: string, params: unknown) => void) | null, registerRequestCalls: [] as string[], requestHandlers: new Map Promise | unknown>(), initializeCalls: [] as unknown[], @@ -18,6 +19,9 @@ const harness = vi.hoisted(() => ({ startTurnParams: [] as Array>, startTurnErrors: [] as Error[], interruptedTurns: [] as Array<{ threadId: string; turnId: string }>, + interruptErrors: [] as Error[], + rollbackCalls: [] as Array<{ threadId: string; numTurns: number }>, + rollbackErrors: [] as Error[], compactThreadIds: [] as string[], goalSetCalls: [] as unknown[], goalGetCalls: [] as unknown[], @@ -26,6 +30,11 @@ const harness = vi.hoisted(() => ({ suppressGoalNotifications: false, suppressTurnCompletion: false, remainingThreadSystemErrors: 0, + emitFailedCompletionAfterThreadSystemError: false, + emitCyberPolicyAfterThreadSystemError: false, + emitSafetyBuffering: false, + safetyBufferingFasterModel: null as string | null, + emitModelSafetyNotices: false, startTurnMessages: [] as string[], failResumeThreadIds: [] as string[], nextThreadSystemErrorMessage: null as string | null, @@ -72,6 +81,7 @@ vi.mock('./codexAppServerClient', () => { setNotificationHandler(handler: ((method: string, params: unknown) => void) | null): void { this.notificationHandler = handler; + harness.dispatchNotification = handler; } setStderrHandler(handler: ((text: string) => void) | null): void { @@ -194,9 +204,75 @@ vi.mock('./codexAppServerClient', () => { } else { notify(); } + if (harness.emitCyberPolicyAfterThreadSystemError) { + const policyError = { + threadId, + turnId, + error: { + message: 'This content was flagged for possible cybersecurity risk.', + codexErrorInfo: 'cyberPolicy' + }, + willRetry: false + }; + harness.notifications.push({ method: 'error', params: policyError }); + this.notificationHandler?.('error', policyError); + + const completed = { + threadId, + turnId, + turn: { id: turnId, status: 'failed' } + }; + harness.notifications.push({ method: 'turn/completed', params: completed }); + this.notificationHandler?.('turn/completed', completed); + } else if (harness.emitFailedCompletionAfterThreadSystemError) { + const completed = { + threadId, + turnId, + turn: { id: turnId, status: 'failed' } + }; + harness.notifications.push({ method: 'turn/completed', params: completed }); + this.notificationHandler?.('turn/completed', completed); + } return { turn: { id: turnId } }; } + if (harness.emitSafetyBuffering) { + harness.emitSafetyBuffering = false; + const notification = { + threadId, + turnId, + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: true, + fasterModel: harness.safetyBufferingFasterModel + }; + harness.notifications.push({ method: 'model/safetyBuffering/updated', params: notification }); + this.notificationHandler?.('model/safetyBuffering/updated', notification); + return { turn: { id: turnId } }; + } + + if (harness.emitModelSafetyNotices) { + harness.emitModelSafetyNotices = false; + const rerouted = { + threadId, + turnId, + fromModel: 'gpt-5.4', + toModel: 'gpt-5.4-codex', + reason: 'highRiskCyberActivity' + }; + harness.notifications.push({ method: 'model/rerouted', params: rerouted }); + this.notificationHandler?.('model/rerouted', rerouted); + + const verification = { + threadId, + turnId, + verifications: ['trustedAccessForCyber'] + }; + harness.notifications.push({ method: 'model/verification', params: verification }); + this.notificationHandler?.('model/verification', verification); + } + if ( harness.emitRunningChildTurnBeforeSuppressedParent || harness.emitCompletedChildTurnBeforeSuppressedParent @@ -772,6 +848,10 @@ vi.mock('./codexAppServerClient', () => { const threadId = params?.threadId ?? 'thread-unknown'; const turnId = params?.turnId ?? 'turn-unknown'; harness.interruptedTurns.push({ threadId, turnId }); + const error = harness.interruptErrors.shift(); + if (error) { + throw error; + } if (harness.emitTurnAbortedOnInterrupt) { const interrupted = { threadId, @@ -785,6 +865,16 @@ vi.mock('./codexAppServerClient', () => { return {}; } + async rollbackThread(params?: { threadId?: string; numTurns?: number }): Promise<{ thread: { id: string } }> { + const threadId = params?.threadId ?? 'thread-unknown'; + harness.rollbackCalls.push({ threadId, numTurns: params?.numTurns ?? 0 }); + const error = harness.rollbackErrors.shift(); + if (error) { + throw error; + } + return { thread: { id: threadId } }; + } + async disconnect(): Promise {} } @@ -838,6 +928,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat const collaborationModes: Array = []; let currentPermissionMode: EnhancedMode['permissionMode'] = mode.permissionMode; let currentModel: string | null | undefined = mode.model; + let currentModelReasoningEffort = mode.modelReasoningEffort; let currentCollaborationMode: EnhancedMode['collaborationMode'] | undefined = mode.collaborationMode; let agentState: FakeAgentState = { requests: {}, @@ -884,6 +975,9 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat getModel() { return currentModel; }, + setModelReasoningEffort(nextEffort: EnhancedMode['modelReasoningEffort']) { + currentModelReasoningEffort = nextEffort; + }, getCollaborationMode() { return currentCollaborationMode; }, @@ -927,6 +1021,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat currentPermissionMode = nextMode; }, getModel: () => currentModel, + getModelReasoningEffort: () => currentModelReasoningEffort, getCollaborationMode: () => currentCollaborationMode, collaborationModes, getAgentState: () => agentState @@ -936,6 +1031,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; + harness.dispatchNotification = null; harness.registerRequestCalls = []; harness.requestHandlers = new Map(); harness.initializeCalls = []; @@ -950,6 +1046,9 @@ describe('codexRemoteLauncher', () => { harness.startTurnParams = []; harness.startTurnErrors = []; harness.interruptedTurns = []; + harness.interruptErrors = []; + harness.rollbackCalls = []; + harness.rollbackErrors = []; harness.compactThreadIds = []; harness.goalSetCalls = []; harness.goalGetCalls = []; @@ -957,6 +1056,11 @@ describe('codexRemoteLauncher', () => { harness.goal = null; harness.suppressGoalNotifications = false; harness.suppressTurnCompletion = false; + harness.emitFailedCompletionAfterThreadSystemError = false; + harness.emitCyberPolicyAfterThreadSystemError = false; + harness.emitSafetyBuffering = false; + harness.safetyBufferingFasterModel = null; + harness.emitModelSafetyNotices = false; harness.startTurnMessages = []; harness.failResumeThreadIds = []; harness.remainingThreadSystemErrors = 0; @@ -1025,8 +1129,8 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); - it('uses live permission mode for app-server MCP elicitation handlers', async () => { - const { session, setPermissionMode } = createSessionStub(); + it('routes app-server MCP elicitation through the existing user-input transport', async () => { + const { session, codexMessages, rpcHandlers, setPermissionMode } = createSessionStub(); const exitReason = await codexRemoteLauncher(session as never); @@ -1052,14 +1156,24 @@ describe('codexRemoteLauncher', () => { } }; - await expect(handler?.(request)).resolves.toEqual({ - action: 'cancel', - content: null, - _meta: null + setPermissionMode('yolo'); + const response = handler?.(request); + await vi.waitFor(() => { + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'tool-call', + name: 'request_user_input' + })); + }); + const requestMessage = codexMessages.find((message) => ( + typeof message === 'object' && message !== null && 'name' in message && message.name === 'request_user_input' + )) as { callId: string }; + await rpcHandlers.get('permission')?.({ + id: requestMessage.callId, + approved: true, + answers: { approval: { answers: ['allow'] } } }); - setPermissionMode('yolo'); - await expect(handler?.(request)).resolves.toEqual({ + await expect(response).resolves.toEqual({ action: 'accept', content: { approval: 'allow' @@ -1067,12 +1181,6 @@ describe('codexRemoteLauncher', () => { _meta: null }); - setPermissionMode('default'); - await expect(handler?.(request)).resolves.toEqual({ - action: 'cancel', - content: null, - _meta: null - }); }); it('sends Codex plan collaboration mode when the app-server advertises it', async () => { @@ -1376,6 +1484,373 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); + it('still retries a generic systemError when an empty failed turn completion confirms it', async () => { + harness.remainingThreadSystemErrors = 1; + harness.emitFailedCompletionAfterThreadSystemError = true; + const { session, sessionEvents } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnMessages).toEqual(['first message', 'first message']); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Task failed: Codex thread entered systemError; retrying same conversation (1/3)' + }); + expect(session.thinking).toBe(false); + }); + + it('does not retry when a generic systemError is followed by a cyber-policy block', async () => { + harness.remainingThreadSystemErrors = 1; + harness.emitCyberPolicyAfterThreadSystemError = true; + const { session, sessionEvents } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnThreadIds).toEqual(['thread-1']); + expect(harness.startTurnMessages).toEqual(['first message']); + const failureMessages = sessionEvents.filter((event) => event.type === 'message'); + expect(failureMessages).toHaveLength(1); + expect(failureMessages[0]?.message).toContain('This content was flagged for possible cybersecurity risk.'); + expect(failureMessages[0]?.message).toContain('https://openai.com/form/enterprise-trusted-access-for-cyber/'); + expect(failureMessages[0]?.message).toContain('https://help.openai.com/en/articles/20001326'); + expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); + expect(session.thinking).toBe(false); + }); + + it('does not retry an explicitly non-retryable error even when its text is retryable', async () => { + harness.suppressTurnCompletion = true; + const { session, sessionEvents } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(harness.startTurnMessages).toEqual(['first message']); + }); + + harness.dispatchNotification?.('error', { + threadId: 'thread-1', + turnId: 'turn-1', + error: { message: 'Selected model is at capacity' }, + willRetry: false + }); + + await expect(running).resolves.toBe('exit'); + expect(harness.startTurnMessages).toEqual(['first message']); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Task failed: Selected model is at capacity' + }); + expect(sessionEvents.some((event) => String(event.message ?? '').includes('retrying same conversation'))).toBe(false); + expect(session.thinking).toBe(false); + }); + + it('retries a safety-buffered turn with the offered faster model only after user opt-in', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; + harness.emitTurnAbortedOnInterrupt = true; + const { + session, + rpcHandlers, + getAgentState, + getModel, + getModelReasoningEffort + } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + + await vi.waitFor(() => { + expect(Object.values(getAgentState().requests)).toContainEqual(expect.objectContaining({ + tool: 'request_user_input' + })); + }); + expect(harness.startTurnThreadIds).toEqual(['thread-1']); + expect(harness.interruptedTurns).toEqual([]); + expect(harness.rollbackCalls).toEqual([]); + + const requestId = Object.keys(getAgentState().requests)[0]; + await rpcHandlers.get('permission')?.({ + id: requestId, + approved: true, + answers: { + safety_buffering_action: { + answers: ['Retry with a faster model'] + } + } + }); + + await expect(running).resolves.toBe('exit'); + expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]); + expect(harness.rollbackCalls).toEqual([{ threadId: 'thread-1', numTurns: 1 }]); + expect(harness.startTurnMessages).toEqual(['first message', 'first message']); + expect(harness.startTurnParams[1]).toMatchObject({ + threadId: 'thread-1', + effort: 'low', + input: [{ type: 'text', text: 'first message' }], + collaborationMode: { + mode: 'default', + settings: { + model: 'gpt-5.4-mini', + reasoning_effort: 'low' + } + } + }); + expect(getModel()).toBe('gpt-5.4-mini'); + expect(getModelReasoningEffort()).toBe('low'); + }); + + it('keeps the original safety-buffered turn running when the user dismisses the retry', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; + const { session, rpcHandlers, getAgentState } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + + const requestId = Object.keys(getAgentState().requests)[0]; + await rpcHandlers.get('permission')?.({ + id: requestId, + approved: true, + answers: { + safety_buffering_action: { + answers: ['Keep waiting'] + } + } + }); + + expect(harness.interruptedTurns).toEqual([]); + expect(harness.rollbackCalls).toEqual([]); + expect(harness.startTurnMessages).toEqual(['first message']); + await new Promise((resolve) => setTimeout(resolve, 0)); + + harness.dispatchNotification?.('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: true, + fasterModel: 'gpt-5.4-mini' + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(getAgentState().requests).toEqual({}); + + harness.dispatchNotification?.('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: false, + fasterModel: 'gpt-5.4-mini' + }); + harness.dispatchNotification?.('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: true, + fasterModel: 'gpt-5.4-mini' + }); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + + harness.dispatchNotification?.('turn/completed', { + threadId: 'thread-1', + turnId: 'turn-1', + turn: { id: 'turn-1', status: 'completed' } + }); + await expect(running).resolves.toBe('exit'); + }); + + it('dismisses safety-buffering choices when hidden or when agent output starts', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; + const { session, getAgentState } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + const hiddenRequestId = Object.keys(getAgentState().requests)[0]; + + harness.dispatchNotification?.('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: false, + fasterModel: 'gpt-5.4-mini' + }); + await vi.waitFor(() => { + expect(getAgentState().requests).toEqual({}); + expect(getAgentState().completedRequests[hiddenRequestId]).toMatchObject({ + status: 'canceled', + reason: 'Safety buffering ended' + }); + }); + + harness.dispatchNotification?.('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: true, + fasterModel: 'gpt-5.4-mini' + }); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + const outputRequestId = Object.keys(getAgentState().requests)[0]; + expect(outputRequestId).not.toBe(hiddenRequestId); + + harness.dispatchNotification?.('item/agentMessage/delta', { + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'message-1', + delta: 'Visible response' + }); + await vi.waitFor(() => { + expect(getAgentState().requests).toEqual({}); + expect(getAgentState().completedRequests[outputRequestId]).toMatchObject({ + status: 'canceled', + reason: 'Agent output started' + }); + }); + expect(harness.interruptedTurns).toEqual([]); + expect(harness.rollbackCalls).toEqual([]); + + harness.dispatchNotification?.('turn/completed', { + threadId: 'thread-1', + turnId: 'turn-1', + turn: { id: 'turn-1', status: 'completed' } + }); + await expect(running).resolves.toBe('exit'); + }); + + it('surfaces safety buffering without offering retry when fasterModel is null', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = null; + const { session, sessionEvents, getAgentState } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Codex is taking extra time to review this request. Learn more: https://help.openai.com/en/articles/20001326' + }); + }); + expect(getAgentState().requests).toEqual({}); + expect(harness.interruptedTurns).toEqual([]); + expect(harness.rollbackCalls).toEqual([]); + + harness.dispatchNotification?.('turn/completed', { + threadId: 'thread-1', + turnId: 'turn-1', + turn: { id: 'turn-1', status: 'completed' } + }); + await expect(running).resolves.toBe('exit'); + }); + + it('does not replay a safety-buffered turn when rollback is unavailable', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; + harness.emitTurnAbortedOnInterrupt = true; + harness.rollbackErrors.push(new Error('thread/rollback is unsupported')); + const { session, sessionEvents, rpcHandlers, getAgentState } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + + const requestId = Object.keys(getAgentState().requests)[0]; + await rpcHandlers.get('permission')?.({ + id: requestId, + approved: true, + answers: { + safety_buffering_action: { + answers: ['Retry with a faster model'] + } + } + }); + + await expect(running).resolves.toBe('exit'); + expect(harness.startTurnMessages).toEqual(['first message']); + expect(harness.rollbackCalls).toEqual([{ threadId: 'thread-1', numTurns: 1 }]); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Failed to retry with a faster model: thread/rollback is unsupported' + }); + await vi.waitFor(() => { + expect(sessionEvents).toContainEqual({ type: 'ready' }); + }); + expect(session.thinking).toBe(false); + }); + + it('keeps the original turn running when safety-buffering interrupt fails', async () => { + harness.emitSafetyBuffering = true; + harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; + harness.interruptErrors.push(new Error('turn/interrupt failed')); + const { session, sessionEvents, rpcHandlers, getAgentState } = createSessionStub(['first message']); + + const running = codexRemoteLauncher(session as never); + await vi.waitFor(() => { + expect(Object.keys(getAgentState().requests)).toHaveLength(1); + }); + + const requestId = Object.keys(getAgentState().requests)[0]; + await rpcHandlers.get('permission')?.({ + id: requestId, + approved: true, + answers: { + safety_buffering_action: { + answers: ['Retry with a faster model'] + } + } + }); + await vi.waitFor(() => { + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Failed to retry with a faster model: turn/interrupt failed' + }); + }); + + expect(harness.startTurnMessages).toEqual(['first message']); + expect(harness.rollbackCalls).toEqual([]); + expect(session.thinking).toBe(true); + + harness.dispatchNotification?.('turn/completed', { + threadId: 'thread-1', + turnId: 'turn-1', + turn: { id: 'turn-1', status: 'completed' } + }); + await expect(running).resolves.toBe('exit'); + expect(session.thinking).toBe(false); + }); + + it('surfaces model reroute and Trusted Access verification notices', async () => { + harness.emitModelSafetyNotices = true; + const { session, sessionEvents } = createSessionStub(['first message']); + + await codexRemoteLauncher(session as never); + + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Codex rerouted the model from gpt-5.4 to gpt-5.4-codex (highRiskCyberActivity).' + }); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Your conversations have multiple flags for possible cybersecurity risk. Responses may take longer because extra safety checks are on. To get authorized for security work, join [Trusted Access for Cyber](https://chatgpt.com/cyber).' + }); + }); + it('compacts the same thread before retrying context-window overflow', async () => { harness.remainingThreadSystemErrors = 1; harness.nextThreadSystemErrorMessage = "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."; diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2cb80fe17f..5e35c37696 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -92,6 +92,10 @@ const CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS = [ const SAME_THREAD_MAX_RETRIES = 3; const SAME_THREAD_MAX_COMPACT_RETRIES = 1; const SAME_THREAD_COMPACT_TIMEOUT_MS = 10 * 60 * 1000; +const THREAD_STATUS_FAILURE_GRACE_MS = 250; +const SAFETY_BUFFERING_LEARN_MORE_URL = 'https://help.openai.com/en/articles/20001326'; +const TRUSTED_ACCESS_FOR_CYBER_URL = 'https://chatgpt.com/cyber'; +const CYBER_POLICY_TRUSTED_ACCESS_URL = 'https://openai.com/form/enterprise-trusted-access-for-cyber/'; const CODEX_GOALS_UNSUPPORTED_MESSAGE = 'Codex goals are not supported by this Codex runtime. Upgrade Codex or enable features.goals.'; const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000; @@ -137,6 +141,30 @@ function isSameThreadRetryableCodexError(error: string | null): boolean { return SAME_THREAD_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern)); } +function normalizePolicyToken(value: unknown): string { + return typeof value === 'string' + ? value.toLowerCase().replace(/[^a-z0-9]/g, '') + : ''; +} + +function isPolicyBlockedCodexFailure(msg: Record, error: string | null): boolean { + if (normalizePolicyToken(msg.codex_error_info ?? msg.codexErrorInfo) === 'cyberpolicy') { + return true; + } + + const normalizedError = error?.toLowerCase() ?? ''; + return normalizedError.includes('flagged for possible cybersecurity risk') + || normalizedError.includes('flagged for potentially high-risk cyber activity') + || normalizedError.includes('cyber policy') + || normalizedError.includes('cyberpolicy') + || normalizedError.includes('limited access to this content for safety reasons') + || normalizedError.includes("this content can't be shown"); +} + +function isGenericThreadSystemError(error: string | null): boolean { + return error?.trim().toLowerCase() === 'codex thread entered systemerror'; +} + function isContextCompactRetryableCodexError(error: string | null): boolean { if (!error) { return false; @@ -1778,6 +1806,22 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let sameThreadRetryAttempt = 0; let sameThreadCompactAttempt = 0; let recoveryInFlight = false; + let lastFinalizedTurnId: string | null = null; + let deferredThreadStatusFailure: { + event: Record; + threadId: string; + turnId: string; + timer: ReturnType; + } | null = null; + let activeSafetyBufferingRequest: { + requestId: string; + threadId: string; + turnId: string; + fasterModel: string; + message: QueuedMessage; + } | null = null; + const dismissedSafetyBufferingKeys = new Set(); + let agentMessageStartedForTurn = false; let compactRecovery: { threadId: string; message: QueuedMessage; @@ -1921,6 +1965,190 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return true; }; + const clearDeferredThreadStatusFailure = () => { + if (!deferredThreadStatusFailure) { + return; + } + clearTimeout(deferredThreadStatusFailure.timer); + deferredThreadStatusFailure = null; + recoveryInFlight = false; + }; + + const cancelSafetyBufferingRequest = (reason: string) => { + const request = activeSafetyBufferingRequest; + if (!request) { + return; + } + activeSafetyBufferingRequest = null; + permissionHandler.cancelUserInputRequest(request.requestId, reason); + }; + + const safetyBufferingTurnKey = (threadId: string, turnId: string) => `${threadId}\u0000${turnId}`; + const safetyBufferingKey = (threadId: string, turnId: string, fasterModel: string) => { + return `${safetyBufferingTurnKey(threadId, turnId)}\u0000${fasterModel}`; + }; + const clearDismissedSafetyBufferingForTurn = (threadId: string | null, turnId: string | null) => { + if (!threadId || !turnId) { + return; + } + const prefix = `${safetyBufferingTurnKey(threadId, turnId)}\u0000`; + for (const key of dismissedSafetyBufferingKeys) { + if (key.startsWith(prefix)) { + dismissedSafetyBufferingKeys.delete(key); + } + } + }; + + const safetyBufferingChoice = (answers: unknown): string | null => { + const answersRecord = asRecord(answers); + const action = asRecord(answersRecord?.safety_buffering_action); + const values = action?.answers ?? answersRecord?.safety_buffering_action; + if (!Array.isArray(values)) { + return null; + } + return values.find((value): value is string => typeof value === 'string') ?? null; + }; + + const retrySafetyBufferedTurn = async (request: NonNullable) => { + if ( + this.currentThreadId !== request.threadId + || this.currentTurnId !== request.turnId + || !turnInFlight + || agentMessageStartedForTurn + ) { + return; + } + + recoveryInFlight = true; + suppressReadyForInterruptedTurn(request.turnId); + clearReadyAfterTurnTimer?.(); + let interrupted = false; + try { + await appServerClient.interruptTurn({ + threadId: request.threadId, + turnId: request.turnId + }); + interrupted = true; + await appServerClient.rollbackThread({ + threadId: request.threadId, + numTurns: 1 + }); + + lastFinalizedTurnId = request.turnId; + turnInFlight = false; + allowAnonymousTerminalEvent = false; + this.currentTurnId = null; + sameThreadRetryAttempt = 0; + sameThreadCompactAttempt = 0; + + const retryMode: EnhancedMode = { + ...request.message.mode, + model: request.fasterModel, + modelReasoningEffort: 'low' + }; + session.setModel(request.fasterModel); + session.setModelReasoningEffort('low'); + pending = { + ...request.message, + mode: retryMode + }; + const message = `Retrying with the faster model ${request.fasterModel}.`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } catch (error) { + if (interrupted) { + lastFinalizedTurnId = request.turnId; + turnInFlight = false; + allowAnonymousTerminalEvent = false; + this.currentTurnId = null; + activeMessage = null; + } else { + consumeInterruptedTurnReadySuppression(request.turnId); + } + const message = `Failed to retry with a faster model: ${errorMessage(error)}`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } finally { + recoveryInFlight = false; + wakeLoop(); + if (interrupted && !pending) { + scheduleReadyAfterTurn?.(); + } + } + }; + + const showSafetyBufferingRequest = (args: { + threadId: string; + turnId: string; + fasterModel: string; + }) => { + if (!activeMessage || agentMessageStartedForTurn) { + return; + } + if (dismissedSafetyBufferingKeys.has(safetyBufferingKey(args.threadId, args.turnId, args.fasterModel))) { + return; + } + if ( + activeSafetyBufferingRequest?.threadId === args.threadId + && activeSafetyBufferingRequest.turnId === args.turnId + && activeSafetyBufferingRequest.fasterModel === args.fasterModel + ) { + return; + } + + cancelSafetyBufferingRequest('Safety buffering prompt replaced'); + const request = { + requestId: `codex-safety-buffering:${args.threadId}:${args.turnId}:${randomUUID()}`, + ...args, + message: activeMessage + }; + activeSafetyBufferingRequest = request; + + void permissionHandler.handleUserInputRequest(request.requestId, { + questions: [{ + id: 'safety_buffering_action', + question: 'Codex is taking extra time to review this request. What would you like to do?', + options: [ + { + label: 'Retry with a faster model', + description: `Interrupt this turn and retry with ${args.fasterModel} using low reasoning effort.` + }, + { + label: 'Keep waiting', + description: 'Dismiss this choice and let the current turn continue.' + }, + { + label: 'Learn more', + description: `[Read about safety checks](${SAFETY_BUFFERING_LEARN_MORE_URL}); the current turn will keep waiting.` + } + ] + }] + }).then((answers) => { + if (activeSafetyBufferingRequest !== request) { + return; + } + activeSafetyBufferingRequest = null; + const choice = safetyBufferingChoice(answers); + if (choice === 'Retry with a faster model') { + void retrySafetyBufferedTurn(request); + } else if (choice === 'Keep waiting' || choice === 'Learn more') { + dismissedSafetyBufferingKeys.add( + safetyBufferingKey(request.threadId, request.turnId, request.fasterModel) + ); + if (choice === 'Learn more') { + const message = `Learn more about Codex safety checks: ${SAFETY_BUFFERING_LEARN_MORE_URL}`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } + } + }).catch((error) => { + if (activeSafetyBufferingRequest === request) { + activeSafetyBufferingRequest = null; + } + logger.debug(`[Codex] Safety buffering choice dismissed: ${errorMessage(error)}`); + }); + }; + const shouldForwardGoalUpdate = (msg: Record, threadId: string | null): boolean => { const goal = asRecord(msg.goal); const scopedThreadId = threadId @@ -1962,9 +2190,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const eventTurnId = asString(msg.turn_id ?? msg.turnId); const eventThreadId = asString(msg.thread_id ?? msg.threadId); const isTerminalEvent = msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed'; - const suppressReadyForThisTerminalEvent = isTerminalEvent - ? consumeInterruptedTurnReadySuppression(eventTurnId) - : false; if (msgType === 'thread_started') { const threadId = asString(msg.thread_id ?? msg.threadId); @@ -2035,8 +2260,15 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return; } + if (isTerminalEvent && eventTurnId && eventTurnId === lastFinalizedTurnId) { + logger.debug(`[Codex] Ignoring duplicate terminal event for turn ${eventTurnId}`); + return; + } + if (msgType === 'task_started') { const turnId = eventTurnId; + agentMessageStartedForTurn = false; + dismissedSafetyBufferingKeys.clear(); if (turnId) { this.currentTurnId = turnId; allowAnonymousTerminalEvent = false; @@ -2045,20 +2277,167 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } + if (msgType === 'agent_message_delta') { + agentMessageStartedForTurn = true; + if ( + activeSafetyBufferingRequest + && (!eventTurnId || activeSafetyBufferingRequest.turnId === eventTurnId) + ) { + cancelSafetyBufferingRequest('Agent output started'); + } + return; + } + + if (msgType === 'model_safety_buffering') { + const showBufferingUi = msg.show_buffering_ui === true; + if (!showBufferingUi) { + clearDismissedSafetyBufferingForTurn( + eventThreadId ?? this.currentThreadId, + eventTurnId ?? this.currentTurnId + ); + if ( + activeSafetyBufferingRequest + && (!eventTurnId || activeSafetyBufferingRequest.turnId === eventTurnId) + ) { + cancelSafetyBufferingRequest('Safety buffering ended'); + } + return; + } + + const fasterModel = asString(msg.faster_model ?? msg.fasterModel); + if ( + fasterModel + && eventThreadId + && eventTurnId + && eventThreadId === this.currentThreadId + && eventTurnId === this.currentTurnId + && turnInFlight + ) { + showSafetyBufferingRequest({ + threadId: eventThreadId, + turnId: eventTurnId, + fasterModel + }); + } else if (!fasterModel) { + const message = `Codex is taking extra time to review this request. Learn more: ${SAFETY_BUFFERING_LEARN_MORE_URL}`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } + return; + } + + if (msgType === 'model_rerouted') { + const fromModel = asString(msg.from_model ?? msg.fromModel); + const toModel = asString(msg.to_model ?? msg.toModel); + const reason = asString(msg.reason); + if (fromModel && toModel) { + const message = `Codex rerouted the model from ${fromModel} to ${toModel}${reason ? ` (${reason})` : ''}.`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } + return; + } + + if (msgType === 'model_verification') { + const verifications = Array.isArray(msg.verifications) ? msg.verifications : []; + if (verifications.includes('trustedAccessForCyber')) { + const message = 'Your conversations have multiple flags for possible cybersecurity risk. ' + + 'Responses may take longer because extra safety checks are on. To get authorized for ' + + `security work, join [Trusted Access for Cyber](${TRUSTED_ACCESS_FOR_CYBER_URL}).`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + } + return; + } + const isThreadStatusFailure = msgType === 'task_failed' && msg.terminal_source === 'thread_status'; const error = msgType === 'task_failed' ? asString(msg.error) : null; + const explicitlyNonRetryable = msgType === 'task_failed' + && (msg.retryable === false || isPolicyBlockedCodexFailure(msg, error)); + + if (deferredThreadStatusFailure && isTerminalEvent && !isThreadStatusFailure) { + const sameThread = !eventThreadId || eventThreadId === deferredThreadStatusFailure.threadId; + const sameTurn = !eventTurnId || eventTurnId === deferredThreadStatusFailure.turnId; + if (sameThread && sameTurn) { + if ( + msgType === 'task_failed' + && msg.terminal_source === 'turn_completed' + && !error + && !explicitlyNonRetryable + ) { + const deferred = deferredThreadStatusFailure; + clearDeferredThreadStatusFailure(); + await handleCodexEvent({ + ...deferred.event, + turn_id: deferred.turnId, + deferred_thread_status: true + }); + return; + } + clearDeferredThreadStatusFailure(); + } + } + + if ( + isThreadStatusFailure + && isGenericThreadSystemError(error) + && msg.deferred_thread_status !== true + ) { + if (shouldIgnoreTerminalEvent({ + eventTurnId, + currentTurnId: this.currentTurnId, + turnInFlight, + allowAnonymousTerminalEvent, + eventThreadId, + currentThreadId: this.currentThreadId, + allowMatchingThreadIdTerminalEvent: true + })) { + return; + } + const threadId = eventThreadId ?? this.currentThreadId; + const turnId = eventTurnId ?? this.currentTurnId; + if (!threadId || !turnId) { + return; + } + clearDeferredThreadStatusFailure(); + const event = { ...msg }; + const timer = setTimeout(() => { + if (deferredThreadStatusFailure?.event !== event) { + return; + } + deferredThreadStatusFailure = null; + recoveryInFlight = false; + void handleCodexEvent({ + ...event, + turn_id: turnId, + deferred_thread_status: true + }).catch((deferredError) => { + logger.debug(`[Codex] Failed to handle deferred thread status: ${errorMessage(deferredError)}`); + }); + }, THREAD_STATUS_FAILURE_GRACE_MS); + deferredThreadStatusFailure = { event, threadId, turnId, timer }; + recoveryInFlight = true; + return; + } + const shouldCompactAndRetrySameThread = msgType === 'task_failed' + && !explicitlyNonRetryable && isContextCompactRetryableCodexError(error) && Boolean(activeMessage) && Boolean(this.currentThreadId) && sameThreadCompactAttempt < SAME_THREAD_MAX_COMPACT_RETRIES; const shouldRetrySameThread = msgType === 'task_failed' + && !explicitlyNonRetryable && !shouldCompactAndRetrySameThread && isSameThreadRetryableCodexError(error) && Boolean(activeMessage) && Boolean(this.currentThreadId) && sameThreadRetryAttempt < SAME_THREAD_MAX_RETRIES; + const suppressReadyForThisTerminalEvent = isTerminalEvent + ? consumeInterruptedTurnReadySuppression(eventTurnId) + : false; + if (isTerminalEvent) { if (shouldIgnoreTerminalEvent({ eventTurnId, @@ -2077,6 +2456,20 @@ class CodexRemoteLauncher extends RemoteLauncherBase { ); return; } + const finalizedTurnId = eventTurnId ?? this.currentTurnId; + if (finalizedTurnId) { + lastFinalizedTurnId = finalizedTurnId; + } + clearDismissedSafetyBufferingForTurn( + eventThreadId ?? this.currentThreadId, + finalizedTurnId + ); + if ( + activeSafetyBufferingRequest + && (!finalizedTurnId || activeSafetyBufferingRequest.turnId === finalizedTurnId) + ) { + cancelSafetyBufferingRequest('Turn completed'); + } if (shouldCompactAndRetrySameThread) { const threadId = this.currentThreadId; const messageToRetry = activeMessage; @@ -2139,7 +2532,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage(retryMessage, 'status'); session.sendSessionEvent({ type: 'message', message: retryMessage }); } else { - const message = error ? `Task failed: ${error}` : 'Task failed'; + const visibleError = error && isPolicyBlockedCodexFailure(msg, error) + ? `${error}\n\nTrusted Access: ${CYBER_POLICY_TRUSTED_ACCESS_URL}\nLearn more: ${SAFETY_BUFFERING_LEARN_MORE_URL}` + : error; + const message = visibleError ? `Task failed: ${visibleError}` : 'Task failed'; messageBuffer.addMessage(message, 'status'); session.sendSessionEvent({ type: 'message', message }); } @@ -2172,7 +2568,16 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (isTerminalEvent && !turnInFlight && !suppressReadyForThisTerminalEvent) { - scheduleReadyAfterTurn?.(); + if (msg.deferred_thread_status === true) { + emitReadyIfIdle({ + pending: pending ?? (recoveryInFlight ? activeMessage : null), + queueSize: () => session.queue.size(), + shouldExit: this.shouldExit, + sendReady + }); + } else { + scheduleReadyAfterTurn?.(); + } } else if (readyAfterTurnTimer && msgType !== 'task_started' && !suppressReadyForThisTerminalEvent) { scheduleReadyAfterTurn?.(); } @@ -2647,6 +3052,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }; const resetCurrentTurnState = () => { + clearDeferredThreadStatusFailure(); + cancelSafetyBufferingRequest('Session reset'); turnInFlight = false; allowAnonymousTerminalEvent = false; this.currentTurnId = null; @@ -2935,7 +3342,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { while (!this.shouldExit) { logActiveHandles('loop-top'); - if (!pending && (turnInFlight || recoveryInFlight) && session.queue.size() === 0) { + if (!pending && (recoveryInFlight || (turnInFlight && session.queue.size() === 0))) { await waitForTurnOrRecovery(this.abortController.signal); if (this.abortController.signal.aborted && !this.shouldExit) { logger.debug('[codex]: Internal wait aborted while turn/recovery was active; continuing'); @@ -3156,6 +3563,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } failPendingAgentStarts('spawn_agent did not return an agent id before the Codex session ended'); + clearDeferredThreadStatusFailure(); + cancelSafetyBufferingRequest('Session ended'); cancelAllPendingThrottledAgentRunUpdates(); } diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 7c138bddb2..5fc99c808f 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -32,6 +32,14 @@ vi.mock('@/agent/sessionFactory', () => ({ sessionInfo: harness.sessionInfo } }), + bootstrapLazySession: vi.fn(async (options: Record) => { + harness.bootstrapArgs.push({ ...options, lazy: true }) + return { + api: {}, + session: harness.session, + sessionInfo: harness.sessionInfo + } + }), bootstrapExistingSession: vi.fn(async (options: Record) => { harness.bootstrapArgs.push(options) return { @@ -103,6 +111,7 @@ vi.mock('./utils/codexCliOverrides', () => ({ })) import { runCodex as runCodexImpl } from './runCodex' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' describe('runCodex', () => { beforeEach(() => { @@ -202,6 +211,27 @@ describe('runCodex', () => { expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled() }) + it('uses lazy bootstrap for a fresh terminal launch', async () => { + await runCodexImpl({ workingDirectory: '/tmp/project' }) + + expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({ + workingDirectory: '/tmp/project', + lazy: true + })) + expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ + replayTranscriptHistoryOnStart: true + })) + }) + + it('keeps eager bootstrap for runner launches', async () => { + await runCodexImpl({ + startedBy: 'runner', + workingDirectory: '/tmp/project' + }) + + expect(harness.bootstrapArgs[0]).not.toHaveProperty('lazy') + }) + it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => { await runCodexImpl({ workingDirectory: '/tmp/project', @@ -213,4 +243,20 @@ describe('runCodex', () => { replayTranscriptHistoryOnStart: true })) }) + + it('accepts and normalizes model-reported reasoning efforts from session config', async () => { + await runCodexImpl({ workingDirectory: '/tmp/project' }) + + const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( + ([method]) => method === RPC_METHODS.SetSessionConfig + ) + const handler = registration?.[1] as ((payload: unknown) => Promise) | undefined + expect(handler).toBeTypeOf('function') + + await handler?.({ modelReasoningEffort: 'max' }) + await handler?.({ modelReasoningEffort: ' EXTREME ' }) + + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'max') + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(3, 'extreme') + }) }) diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index de907c2271..04b8a9df3a 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -7,7 +7,7 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' import type { AgentState } from '@/api/types'; import type { CodexSession } from './session'; import { parseCodexCliOverrides } from './utils/codexCliOverrides'; -import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession } from '@/agent/sessionFactory'; import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; @@ -19,11 +19,10 @@ import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; +import { parseReasoningEffortValue } from './utils/reasoningEffort'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) - export async function runCodex(opts: { startedBy?: 'runner' | 'terminal'; codexArgs?: string[]; @@ -44,6 +43,7 @@ export async function runCodex(opts: { let state: AgentState = { controlledByUser: false }; + const useLazyBootstrap = !opts.existingSessionId && startedBy === 'terminal'; const bootstrap = opts.existingSessionId ? await bootstrapExistingSession({ sessionId: opts.existingSessionId, @@ -51,7 +51,7 @@ export async function runCodex(opts: { startedBy, workingDirectory }) - : await bootstrapSession({ + : await (useLazyBootstrap ? bootstrapLazySession : bootstrapSession)({ flavor: 'codex', startedBy, workingDirectory, @@ -77,7 +77,7 @@ export async function runCodex(opts: { const sessionWrapperRef: { current: CodexSession | null } = { current: null }; // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时, // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 - const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId); + const replayTranscriptHistoryOnStart = useLazyBootstrap || Boolean(opts.resumeSessionId && !opts.existingSessionId); let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; @@ -303,16 +303,6 @@ export async function runCodex(opts: { return parsed.data; }; - const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => { - if (value === null) { - return undefined; - } - if (typeof value !== 'string' || !REASONING_EFFORTS.has(value as ReasoningEffort)) { - throw new Error('Invalid model reasoning effort'); - } - return value as ReasoningEffort; - }; - const resolveModel = (value: unknown): string => { if (typeof value !== 'string') { throw new Error('Invalid model'); @@ -362,7 +352,7 @@ export async function runCodex(opts: { } if (config.modelReasoningEffort !== undefined) { - currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); } if (config.collaborationMode !== undefined) { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index c8892419d1..1fdf985f97 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -144,6 +144,10 @@ export class CodexSession extends AgentSessionBase { this.client.sendUserMessage(text); }; + notifyUserActivity = (): void => { + this.client.notifyUserActivity(); + }; + sendSessionEvent = (event: Parameters[0]): void => { this.client.sendSessionEvent(event); }; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 378cec11ff..ebf128fb3b 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -87,7 +87,15 @@ describe('appServerConfig', () => { }); expect(params.sandbox).toBe('danger-full-access'); - expect(params.approvalPolicy).toBe('never'); + expect(params.approvalPolicy).toEqual({ + granular: { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true + } + }); }); it('keeps on-failure approvals for safe-yolo threads', () => { @@ -101,6 +109,25 @@ describe('appServerConfig', () => { expect(params.approvalPolicy).toBe('on-failure'); }); + it('allows MCP elicitation without enabling sandbox prompts for read-only threads', () => { + const params = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'read-only', collaborationMode: 'default' }, + mcpServers + }); + + expect(params.sandbox).toBe('read-only'); + expect(params.approvalPolicy).toEqual({ + granular: { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true + } + }); + }); + it('concatenates custom developer instructions after base instructions', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', @@ -123,7 +150,7 @@ describe('appServerConfig', () => { it('passes model reasoning effort via thread config', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', - mode: { permissionMode: 'default', modelReasoningEffort: 'xhigh', collaborationMode: 'default' }, + mode: { permissionMode: 'default', modelReasoningEffort: 'ultra', collaborationMode: 'default' }, mcpServers }); @@ -133,7 +160,7 @@ describe('appServerConfig', () => { args: ['mcp'] }, developer_instructions: codexSystemPrompt, - model_reasoning_effort: 'xhigh' + model_reasoning_effort: 'ultra' }); }); @@ -229,7 +256,15 @@ describe('appServerConfig', () => { expect(params.threadId).toBe('thread-1'); expect(params.cwd).toBe('/workspace/project'); expect(params.input).toEqual([{ type: 'text', text: 'hello' }]); - expect(params.approvalPolicy).toBe('never'); + expect(params.approvalPolicy).toEqual({ + granular: { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true + } + }); expect(params.sandboxPolicy).toEqual({ type: 'readOnly' }); expect(params.effort).toBe('high'); expect(params.summary).toBeUndefined(); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 9a69413364..13bbc8c006 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -23,7 +23,23 @@ const MODELS_WITHOUT_REASONING_SUMMARY = new Set([ 'gpt-5.3-codex-spark' ]); +const MCP_ELICITATION_ONLY_APPROVAL_POLICY = { + granular: { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true + } +} as const satisfies ApprovalPolicy; + function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy { + if (mode.permissionMode === 'yolo' || mode.permissionMode === 'read-only') { + // Codex's `never` policy auto-declines MCP elicitations before app-server + // can forward them. Keep command/sandbox prompts disabled for Yolo and + // read-only while allowing auth and structured input to reach HAPI's UI. + return MCP_ELICITATION_ONLY_APPROVAL_POLICY; + } return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy; } diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 6bb8d9c4b2..62f68489a5 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -72,7 +72,12 @@ describe('AppServerEventConverter', () => { expect(interrupted).toEqual([{ type: 'turn_aborted', turn_id: 'turn-1' }]); const failed = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Failed', message: 'boom' }); - expect(failed).toEqual([{ type: 'task_failed', turn_id: 'turn-1', error: 'boom' }]); + expect(failed).toEqual([{ + type: 'task_failed', + turn_id: 'turn-1', + terminal_source: 'turn_completed', + error: 'boom' + }]); }); it('accumulates agent message deltas', () => { @@ -693,6 +698,120 @@ describe('AppServerEventConverter', () => { expect(events).toEqual([{ type: 'task_failed', error: 'fatal' }]); }); + it('preserves typed non-retryable cyber-policy errors', () => { + const converter = new AppServerEventConverter(); + + const events = converter.handleNotification('error', { + threadId: 'thread-1', + turnId: 'turn-1', + error: { + message: 'This content was flagged for possible cybersecurity risk.', + codexErrorInfo: 'cyberPolicy' + }, + willRetry: false + }); + + expect(events).toEqual([{ + type: 'task_failed', + thread_id: 'thread-1', + turn_id: 'turn-1', + terminal_source: 'error', + retryable: false, + codex_error_info: 'cyberPolicy', + error: 'This content was flagged for possible cybersecurity risk.' + }]); + }); + + it('preserves cyber-policy metadata from wrapped and completed-turn errors', () => { + const converter = new AppServerEventConverter(); + + expect(converter.handleNotification('codex/event/error', { + msg: { + type: 'error', + thread_id: 'thread-1', + turn_id: 'turn-1', + message: 'wrapped policy failure', + codex_error_info: 'cyber_policy', + will_retry: false + } + })).toEqual([{ + type: 'task_failed', + thread_id: 'thread-1', + turn_id: 'turn-1', + retryable: false, + codex_error_info: 'cyber_policy', + error: 'wrapped policy failure' + }]); + + expect(converter.handleNotification('turn/completed', { + threadId: 'thread-1', + turn: { + id: 'turn-1', + status: 'failed', + error: { + message: 'completed policy failure', + codexErrorInfo: 'CyberPolicy' + } + } + })).toEqual([{ + type: 'task_failed', + thread_id: 'thread-1', + turn_id: 'turn-1', + terminal_source: 'turn_completed', + codex_error_info: 'CyberPolicy', + error: 'completed policy failure' + }]); + }); + + it('maps Codex model safety notifications', () => { + const converter = new AppServerEventConverter(); + + expect(converter.handleNotification('model/safetyBuffering/updated', { + threadId: 'thread-1', + turnId: 'turn-1', + model: 'gpt-5.4', + useCases: ['cyber'], + reasons: ['review'], + showBufferingUi: true, + fasterModel: 'gpt-5.4-mini' + })).toEqual([{ + type: 'model_safety_buffering', + thread_id: 'thread-1', + turn_id: 'turn-1', + model: 'gpt-5.4', + use_cases: ['cyber'], + reasons: ['review'], + show_buffering_ui: true, + faster_model: 'gpt-5.4-mini' + }]); + + expect(converter.handleNotification('model/rerouted', { + threadId: 'thread-1', + turnId: 'turn-1', + fromModel: 'gpt-5.4', + toModel: 'gpt-5.4-codex', + reason: 'highRiskCyberActivity' + })).toEqual([{ + type: 'model_rerouted', + thread_id: 'thread-1', + turn_id: 'turn-1', + from_model: 'gpt-5.4', + to_model: 'gpt-5.4-codex', + reason: 'highRiskCyberActivity' + }]); + + expect(converter.handleNotification('model/verification', { + threadId: 'thread-1', + turnId: 'turn-1', + verifications: ['trustedAccessForCyber'] + })).toEqual([{ + type: 'model_verification', + thread_id: 'thread-1', + turn_id: 'turn-1', + verifications: ['trustedAccessForCyber'] + }]); + }); + it('maps thread/compacted notifications', () => { const converter = new AppServerEventConverter(); const events = converter.handleNotification('thread/compacted', { diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 28bae2be92..1944a7232f 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -306,6 +306,18 @@ function extractStringArray(value: unknown): string[] { : []; } +function extractCodexErrorInfo( + record: Record, + errorRecord: Record | null +): string | null { + return asString( + record.codexErrorInfo + ?? record.codex_error_info + ?? errorRecord?.codexErrorInfo + ?? errorRecord?.codex_error_info + ); +} + function buildCollabAgentInput(item: Record, toolName: string): Record { const targets = extractStringArray(item.receiverThreadIds ?? item.receiver_thread_ids ?? item.targets); const input: Record = {}; @@ -526,12 +538,19 @@ export class AppServerEventConverter { if (msgType === 'error') { const errorRecord = asRecord(msg.error); - const willRetry = asBoolean(msg.will_retry ?? msg.willRetry ?? errorRecord?.will_retry ?? errorRecord?.willRetry) ?? false; + const retryable = asBoolean(msg.will_retry ?? msg.willRetry ?? errorRecord?.will_retry ?? errorRecord?.willRetry); + const willRetry = retryable ?? false; if (willRetry) { return []; } const error = asString(msg.message ?? msg.reason ?? errorRecord?.message); - return error ? addEventScope([{ type: 'task_failed', error }], msgScope) : []; + const codexErrorInfo = extractCodexErrorInfo(msg, errorRecord); + return error ? addEventScope([{ + type: 'task_failed', + ...(retryable !== null ? { retryable } : {}), + ...(codexErrorInfo ? { codex_error_info: codexErrorInfo } : {}), + error + }], msgScope) : []; } if (msgType === 'plan_update') { @@ -672,7 +691,9 @@ export class AppServerEventConverter { const statusRaw = asString(paramsRecord.status ?? turn.status); const status = statusRaw?.toLowerCase(); const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id); - const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason); + const turnError = asRecord(paramsRecord.error ?? turn.error); + const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason) + ?? asString(turnError?.message); if (status === 'interrupted' || status === 'cancelled' || status === 'canceled') { events.push(scoped({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) })); @@ -680,7 +701,14 @@ export class AppServerEventConverter { } if (status === 'failed' || status === 'error') { - events.push(scoped({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) })); + const codexErrorInfo = extractCodexErrorInfo(paramsRecord, turnError); + events.push(scoped({ + type: 'task_failed', + ...(turnId ? { turn_id: turnId } : {}), + terminal_source: 'turn_completed', + ...(codexErrorInfo ? { codex_error_info: codexErrorInfo } : {}), + ...(errorMessage ? { error: errorMessage } : {}) + })); return events; } @@ -702,12 +730,66 @@ export class AppServerEventConverter { return events; } + if (method === 'model/safetyBuffering/updated') { + const model = asString(paramsRecord.model); + const showBufferingUi = asBoolean(paramsRecord.showBufferingUi ?? paramsRecord.show_buffering_ui); + if (!model || showBufferingUi === null) { + return events; + } + events.push(scoped({ + type: 'model_safety_buffering', + model, + use_cases: extractStringArray(paramsRecord.useCases ?? paramsRecord.use_cases), + reasons: extractStringArray(paramsRecord.reasons), + show_buffering_ui: showBufferingUi, + faster_model: asString(paramsRecord.fasterModel ?? paramsRecord.faster_model) + })); + return events; + } + + if (method === 'model/rerouted') { + const fromModel = asString(paramsRecord.fromModel ?? paramsRecord.from_model); + const toModel = asString(paramsRecord.toModel ?? paramsRecord.to_model); + const reason = asString(paramsRecord.reason); + if (fromModel && toModel && reason) { + events.push(scoped({ + type: 'model_rerouted', + from_model: fromModel, + to_model: toModel, + reason + })); + } + return events; + } + + if (method === 'model/verification') { + events.push(scoped({ + type: 'model_verification', + verifications: extractStringArray(paramsRecord.verifications) + })); + return events; + } + if (method === 'error') { - const willRetry = asBoolean(paramsRecord.will_retry ?? paramsRecord.willRetry) ?? false; + const errorRecord = asRecord(paramsRecord.error); + const retryable = asBoolean( + paramsRecord.will_retry + ?? paramsRecord.willRetry + ?? errorRecord?.will_retry + ?? errorRecord?.willRetry + ); + const willRetry = retryable ?? false; if (willRetry) return events; - const message = asString(paramsRecord.message) ?? asString(asRecord(paramsRecord.error)?.message); + const message = asString(paramsRecord.message) ?? asString(errorRecord?.message); if (message) { - events.push(scoped({ type: 'task_failed', error: message })); + const codexErrorInfo = extractCodexErrorInfo(paramsRecord, errorRecord); + events.push(scoped({ + type: 'task_failed', + terminal_source: 'error', + ...(retryable !== null ? { retryable } : {}), + ...(codexErrorInfo ? { codex_error_info: codexErrorInfo } : {}), + error: message + })); } return events; } @@ -723,6 +805,7 @@ export class AppServerEventConverter { this.lastAgentMessageDeltaByItemId.set(itemId, delta); const prev = this.agentMessageBuffers.get(itemId) ?? ''; this.agentMessageBuffers.set(itemId, prev + delta); + events.push(scoped({ type: 'agent_message_delta' })); } return events; } diff --git a/cli/src/codex/utils/appServerPermissionAdapter.test.ts b/cli/src/codex/utils/appServerPermissionAdapter.test.ts index 7d0df70687..125b88b79b 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.test.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.test.ts @@ -189,17 +189,21 @@ describe('registerAppServerPermissionHandlers', () => { }); }); - it('accepts non-HAPI MCP elicitation requests when live permission mode is yolo', async () => { + it('keeps structured MCP tool approval forms interactive even in yolo mode', async () => { const { client, handlers } = createClient(); - let permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo' = 'default'; const permissionHandler = { handleToolCall: vi.fn() }; + const onUserInputRequest = vi.fn(async () => ({ + decision: 'accept' as const, + answers: { approval: { answers: ['allow'] } } + })); registerAppServerPermissionHandlers({ client: client as never, permissionHandler: permissionHandler as never, - getPermissionMode: () => permissionMode + getPermissionMode: () => 'yolo', + onUserInputRequest }); const handler = handlers.get('mcpServer/elicitation/request'); @@ -211,13 +215,20 @@ describe('registerAppServerPermissionHandlers', () => { serverName: 'qmd', mode: 'form', message: 'Allow the qmd MCP server to run tool "status"?', - _meta: null, + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'status' + }, requestedSchema: { type: 'object', properties: { approval: { type: 'string', enum: ['allow', 'deny'] + }, + comment: { + type: 'string', + title: 'Optional comment' } }, required: ['approval'] @@ -225,29 +236,376 @@ describe('registerAppServerPermissionHandlers', () => { }; await expect(handler?.(request)).resolves.toEqual({ - action: 'cancel', + action: 'accept', + content: { + approval: 'allow' + }, + _meta: null + }); + + expect(onUserInputRequest).toHaveBeenCalledWith({ + id: expect.any(String), + input: { + questions: [{ + id: 'approval', + header: 'approval', + question: 'Allow the qmd MCP server to run tool "status"?\n\napproval', + required: true, + options: [{ label: 'allow', description: '' }, { label: 'deny', description: '' }] + }, { + id: 'comment', + header: 'Optional comment', + question: 'Allow the qmd MCP server to run tool "status"?\n\nOptional comment', + required: false, + options: [] + }] + } + }); + }); + + it('routes message-only MCP tool approvals through the permission handler in yolo mode', async () => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn(async () => ({ decision: 'approved_for_session' as const })) + }; + const onUserInputRequest = vi.fn(); + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never, + getPermissionMode: () => 'yolo', + onUserInputRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'github', + request: { + elicitationId: 'approval-1', + mode: 'form', + message: 'Allow GitHub search?', + requestedSchema: { + type: 'object', + properties: {} + }, + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'search_issues', + tool_title: 'Search issues', + tool_description: 'Search GitHub issues', + tool_params: { query: 'is:open bug' }, + tool_params_display: { query: 'is:open bug' }, + persist: ['session', 'always'] + } + } + })).resolves.toEqual({ + action: 'accept', + content: null, + _meta: { persist: 'session' } + }); + + expect(permissionHandler.handleToolCall).toHaveBeenCalledWith( + 'approval-1', + 'search_issues', + { + message: 'Allow GitHub search?', + serverName: 'github', + toolTitle: 'Search issues', + toolDescription: 'Search GitHub issues', + toolParams: { query: 'is:open bug' }, + toolParamsDisplay: { query: 'is:open bug' } + } + ); + expect(onUserInputRequest).not.toHaveBeenCalled(); + }); + + it('does not persist a yolo MCP tool approval when session persistence is unavailable', async () => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn(async () => ({ decision: 'approved_for_session' as const })) + }; + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never, + getPermissionMode: () => 'yolo' + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Allow tool?', + elicitationId: 'approval-2', + requestedSchema: null, + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'external_tool', + persist: 'always' + } + })).resolves.toEqual({ + action: 'accept', content: null, _meta: null }); + }); - permissionMode = 'yolo'; - await expect(handler?.(request)).resolves.toEqual({ + it.each([ + ['approved', { action: 'accept', content: null, _meta: null }], + ['denied', { action: 'decline', content: null, _meta: null }], + ['abort', { action: 'cancel', content: null, _meta: null }] + ] as const)('maps %s MCP tool approval decisions without request_user_input', async (decision, expected) => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn(async () => ({ decision })) + }; + const onUserInputRequest = vi.fn(); + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never, + onUserInputRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + message: 'Allow tool?', + elicitationId: 'approval-3', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'external_tool', + persist: ['session'] + } + })).resolves.toEqual(expected); + expect(onUserInputRequest).not.toHaveBeenCalled(); + }); + + it('keeps a selected MCP answer when the user also adds a note', async () => { + const { client, handlers } = createClient(); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest: vi.fn(async () => ({ + decision: 'accept' as const, + answers: { approval: { answers: ['allow', 'user_note: approved for this task'] } } + })) + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Approve?', + requestedSchema: { + type: 'object', + properties: { + approval: { type: 'string', enum: ['allow', 'deny'] } + }, + required: ['approval'] + } + })).resolves.toEqual({ + action: 'accept', + content: { approval: 'allow' }, + _meta: null + }); + }); + + it('does not coerce a note-only MCP choice into a schema value', async () => { + const { client, handlers } = createClient(); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest: vi.fn(async () => ({ + decision: 'accept' as const, + answers: { approved: { answers: ['user_note: please approve'] } } + })) + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Approve?', + requestedSchema: { + type: 'object', + properties: { approved: { type: 'boolean' } }, + required: ['approved'] + } + })).resolves.toEqual({ + action: 'accept', + content: {}, + _meta: null + }); + }); + + it('round-trips array choices and free-text array input', async () => { + const { client, handlers } = createClient(); + const onUserInputRequest = vi.fn(async () => ({ + decision: 'accept' as const, + answers: { + tags: { answers: ['bug'] }, + paths: { answers: ['user_note: src/index.ts'] } + } + })); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Choose metadata', + requestedSchema: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string', enum: ['bug', 'feature'] } }, + paths: { type: 'array', items: { type: 'string' } } + }, + required: ['tags', 'paths'] + } + })).resolves.toEqual({ action: 'accept', content: { - approval: 'allow' + tags: ['bug'], + paths: ['src/index.ts'] }, _meta: null }); + expect(onUserInputRequest).toHaveBeenCalledWith({ + id: expect.any(String), + input: { + questions: [{ + id: 'tags', + header: 'tags', + question: 'Choose metadata\n\ntags', + required: true, + multiple: true, + options: [{ label: 'bug', description: '' }, { label: 'feature', description: '' }] + }, { + id: 'paths', + header: 'paths', + question: 'Choose metadata\n\npaths', + required: true, + multiple: true, + options: [] + }] + } + }); + }); - permissionMode = 'default'; - await expect(handler?.(request)).resolves.toEqual({ - action: 'cancel', - content: null, + it('preserves number, integer, and boolean array item types', async () => { + const { client, handlers } = createClient(); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest: vi.fn(async () => ({ + decision: 'accept' as const, + answers: { + scores: { answers: ['2.5'] }, + indices: { answers: ['3'] }, + flags: { answers: ['true'] } + } + })) + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Choose typed arrays', + requestedSchema: { + type: 'object', + properties: { + scores: { type: 'array', items: { type: 'number', enum: [1.5, 2.5] } }, + indices: { type: 'array', items: { type: 'integer', enum: [2, 3] } }, + flags: { type: 'array', items: { type: 'boolean' } } + }, + required: ['scores', 'indices', 'flags'] + } + })).resolves.toEqual({ + action: 'accept', + content: { + scores: [2.5], + indices: [3], + flags: [true] + }, + _meta: null + }); + }); + + it('treats an omitted MCP elicitation mode as a form request', async () => { + const { client, handlers } = createClient(); + const onUserInputRequest = vi.fn(async () => ({ + decision: 'accept' as const, + answers: { nickname: { answers: ['user_note: Codex'] } } + })); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + message: 'Choose a nickname', + requestedSchema: { + type: 'object', + properties: { nickname: { type: 'string' } }, + required: ['nickname'] + } + })).resolves.toEqual({ + action: 'accept', + content: { nickname: 'Codex' }, + _meta: null + }); + expect(onUserInputRequest).toHaveBeenCalledOnce(); + }); + + it('gives generic message-only MCP forms a display header instead of exposing the internal id', async () => { + const { client, handlers } = createClient(); + const onUserInputRequest = vi.fn(async () => ({ + decision: 'accept' as const, + answers: { __mcp_form_confirmation: { answers: ['Continue'] } } + })); + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: { handleToolCall: vi.fn() } as never, + onUserInputRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + await expect(handler?.({ + serverName: 'external', + mode: 'form', + message: 'Continue with connector setup?', + requestedSchema: { + type: 'object', + properties: {} + } + })).resolves.toEqual({ + action: 'accept', + content: {}, _meta: null }); + expect(onUserInputRequest).toHaveBeenCalledWith({ + id: expect.any(String), + input: { + questions: [{ + id: '__mcp_form_confirmation', + header: 'Confirmation', + question: 'Continue with connector setup?', + options: [{ label: 'Continue', description: '' }] + }] + } + }); }); - it('does not auto-accept non-HAPI MCP elicitation requests in safe-yolo mode', async () => { + it('forwards URL MCP elicitation and preserves a declined response', async () => { const { client, handlers } = createClient(); const permissionHandler = { handleToolCall: vi.fn() @@ -256,7 +614,22 @@ describe('registerAppServerPermissionHandlers', () => { registerAppServerPermissionHandlers({ client: client as never, permissionHandler: permissionHandler as never, - getPermissionMode: () => 'safe-yolo' + getPermissionMode: () => 'yolo', + onUserInputRequest: vi.fn(async ({ input }) => { + expect(input).toEqual({ + url: 'https://example.com/login', + questions: [{ + id: '__mcp_url_confirmation', + header: 'Sign in', + question: 'Sign in to continue', + options: [{ + label: 'Open sign-in page and continue', + description: 'https://example.com/login' + }] + }] + }); + return { decision: 'decline' as const }; + }) }); const handler = handlers.get('mcpServer/elicitation/request'); @@ -266,15 +639,14 @@ describe('registerAppServerPermissionHandlers', () => { threadId: 'thread-1', turnId: 'turn-1', serverName: 'external', - mode: 'form', - message: 'Collect data', - _meta: null, - requestedSchema: { - type: 'object', - properties: {}, + request: { + mode: 'url', + message: 'Sign in to continue', + url: 'https://example.com/login', + elicitationId: 'auth-1' } })).resolves.toEqual({ - action: 'cancel', + action: 'decline', content: null, _meta: null }); diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index b62514c401..08294000b1 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -12,6 +12,8 @@ type PermissionResult = { }; type ElicitationSchemaProperty = { + title?: unknown; + description?: unknown; type?: unknown; default?: unknown; enum?: unknown; @@ -19,6 +21,8 @@ type ElicitationSchemaProperty = { items?: unknown; }; +type UserInputAnswer = Record | Record; + function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object') { return null; @@ -30,6 +34,17 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } +function asWebUrl(value: unknown): string | undefined { + const raw = asString(value); + if (!raw) return undefined; + try { + const url = new URL(raw); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined; + } catch { + return undefined; + } +} + function pickToolName(record: Record): string { return asString(record.toolName) ?? asString(record.tool_name) @@ -150,6 +165,211 @@ function buildAcceptedElicitationContent(params: unknown): Record { + const record = asRecord(params) ?? {}; + return asRecord(record.request) ?? record; +} + +function getMcpToolApprovalMeta(params: unknown): Record | null { + const record = asRecord(params) ?? {}; + const request = unwrapElicitationRequest(params); + const meta = asRecord(request._meta) ?? asRecord(record._meta); + if (meta?.codex_approval_kind !== 'mcp_tool_call') return null; + + const mode = asString(request.mode) ?? 'form'; + if (mode !== 'form') return null; + + const schema = asRecord(request.requestedSchema); + const properties = asRecord(schema?.properties); + if (properties && Object.keys(properties).length > 0) return null; + + return meta; +} + +function mcpApprovalSupportsSessionPersistence(meta: Record): boolean { + if (meta.persist === 'session') return true; + return Array.isArray(meta.persist) && meta.persist.includes('session'); +} + +function buildMcpToolApprovalInput( + params: unknown, + meta: Record +): { toolName: string; input: Record } { + const record = asRecord(params) ?? {}; + const request = unwrapElicitationRequest(params); + const serverName = asString(record.serverName) ?? asString(request.serverName); + const toolTitle = asString(meta.tool_title); + const toolName = asString(meta.tool_name) ?? toolTitle ?? serverName ?? 'MCP tool'; + const input: Record = { + message: asString(request.message) ?? 'Allow MCP tool call?' + }; + + if (serverName) input.serverName = serverName; + if (toolTitle) input.toolTitle = toolTitle; + const toolDescription = asString(meta.tool_description); + if (toolDescription) input.toolDescription = toolDescription; + if (meta.tool_params !== undefined) input.toolParams = meta.tool_params; + if (meta.tool_params_display !== undefined) input.toolParamsDisplay = meta.tool_params_display; + + return { toolName, input }; +} + +function mapMcpToolApprovalDecision( + decision: PermissionDecision, + meta: Record +): { action: 'accept' | 'decline' | 'cancel'; content: null; _meta: { persist: 'session' } | null } { + if (decision === 'denied') { + return { action: 'decline', content: null, _meta: null }; + } + if (decision === 'abort') { + return { action: 'cancel', content: null, _meta: null }; + } + + return { + action: 'accept', + content: null, + _meta: decision === 'approved_for_session' && mcpApprovalSupportsSessionPersistence(meta) + ? { persist: 'session' } + : null + }; +} + +function elicitationChoiceValues(property: Record): unknown[] { + if (Array.isArray(property.enum)) return property.enum; + if (Array.isArray(property.oneOf)) { + return property.oneOf.map((item) => asRecord(item)?.const).filter((item) => item !== undefined); + } + if (property.type === 'array') { + const items = asRecord(property.items); + if (Array.isArray(items?.enum)) return items.enum; + if (Array.isArray(items?.oneOf)) { + return items.oneOf.map((item) => asRecord(item)?.const).filter((item) => item !== undefined); + } + if (items?.type === 'boolean') return [true, false]; + } + if (property.type === 'boolean') return [true, false]; + return []; +} + +function elicitationOptions(property: Record): Array<{ label: string; description: string }> { + return elicitationChoiceValues(property).map((value) => ({ + label: String(value), + description: '' + })); +} + +function buildElicitationUserInput(params: unknown): { questions: unknown[]; url?: string } | null { + const request = unwrapElicitationRequest(params); + const mode = asString(request.mode) ?? 'form'; + const message = asString(request.message) ?? 'MCP server requires input'; + + if (mode === 'url') { + const url = asWebUrl(request.url); + if (!url) return null; + return { + url, + questions: [{ + id: '__mcp_url_confirmation', + header: 'Sign in', + question: message, + options: [{ label: 'Open sign-in page and continue', description: url }] + }] + }; + } + + if (mode !== 'form') return null; + const schema = asRecord(request.requestedSchema); + const properties = asRecord(schema?.properties); + if (!properties) return null; + const required = new Set( + Array.isArray(schema?.required) + ? schema.required.filter((value): value is string => typeof value === 'string') + : [] + ); + + const questions = Object.entries(properties).map(([id, rawProperty]) => { + const property = asRecord(rawProperty) ?? {}; + const fieldQuestion = asString(property.title) ?? asString(property.description) ?? id; + return { + id, + header: fieldQuestion, + question: `${message}\n\n${fieldQuestion}`, + required: required.has(id), + ...(property.type === 'array' ? { multiple: true } : {}), + options: elicitationOptions(property) + }; + }); + return { + questions: questions.length > 0 ? questions : [{ + id: '__mcp_form_confirmation', + header: 'Confirmation', + question: message, + options: [{ label: 'Continue', description: '' }] + }] + }; +} + +function answerValues(answers: UserInputAnswer, id: string): string[] { + const value = answers[id]; + if (Array.isArray(value)) return value; + return asRecord(value)?.answers instanceof Array + ? (asRecord(value)?.answers as unknown[]).filter((item): item is string => typeof item === 'string') + : []; +} + +function coerceSchemaValue(value: string, schema: Record): unknown { + if (schema.type === 'boolean') { + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; + } + if (schema.type === 'number') { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + } + if (schema.type === 'integer') { + const number = Number(value); + return Number.isInteger(number) ? number : undefined; + } + return value; +} + +function buildElicitationContent(params: unknown, answers: UserInputAnswer): Record { + const request = unwrapElicitationRequest(params); + if (request.mode === 'url') return {}; + const properties = asRecord(asRecord(request.requestedSchema)?.properties) ?? {}; + const content: Record = {}; + + for (const [id, rawProperty] of Object.entries(properties)) { + const property = asRecord(rawProperty) ?? {}; + const values = answerValues(answers, id); + const selectedValues = values.filter((value) => !value.startsWith('user_note: ')); + const selected = selectedValues[0]; + const note = values.find((value) => value.startsWith('user_note: '))?.slice('user_note: '.length); + const hasSchemaChoices = elicitationChoiceValues(property).length > 0; + if (hasSchemaChoices && selected === undefined) continue; + const value = selected ?? note; + if (value === undefined) continue; + + if (property.type === 'array') { + const itemSchema = asRecord(property.items) ?? {}; + const rawValues = selectedValues.length > 0 + ? selectedValues + : note !== undefined + ? [note] + : []; + content[id] = rawValues + .map((item) => coerceSchemaValue(item, itemSchema)) + .filter((item) => item !== undefined); + } else { + const coerced = coerceSchemaValue(value, property); + if (coerced !== undefined) content[id] = coerced; + } + } + + return content; +} + function isHapiBridgeElicitation(params: unknown): boolean { const record = asRecord(params); return record?.serverName === 'hapi'; @@ -160,7 +380,7 @@ export function registerAppServerPermissionHandlers(args: { permissionHandler: CodexPermissionHandler; getPermissionMode?: () => CodexPermissionMode | undefined; onUserInputRequest?: (request: { id: string; input: unknown }) => Promise< - | { decision: 'accept'; answers: Record | Record } + | { decision: 'accept'; answers: UserInputAnswer } | { decision: 'decline' | 'cancel' } >; }): void { @@ -259,16 +479,36 @@ export function registerAppServerPermissionHandlers(args: { client.registerRequestHandler('mcpServer/elicitation/request', async (params) => { const record = asRecord(params) ?? {}; + const request = unwrapElicitationRequest(params); - const currentPermissionMode = getPermissionMode?.(); - const shouldAccept = isHapiBridgeElicitation(params) || currentPermissionMode === 'yolo'; + // HAPI's own bridge only asks for values whose safe defaults are defined by HAPI. + if (isHapiBridgeElicitation(params)) { + return { + action: 'accept', + content: buildAcceptedElicitationContent(request), + _meta: null + }; + } - if (!shouldAccept) { + const approvalMeta = getMcpToolApprovalMeta(params); + if (approvalMeta) { + const requestId = asString(request.elicitationId) ?? randomUUID(); + const approval = buildMcpToolApprovalInput(params, approvalMeta); + const result = await permissionHandler.handleToolCall( + requestId, + approval.toolName, + approval.input + ) as PermissionResult; + return mapMcpToolApprovalDecision(result.decision, approvalMeta); + } + + const input = buildElicitationUserInput(params); + if (!onUserInputRequest || !input) { logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', { serverName: record.serverName, - mode: record.mode, - message: record.message, - permissionMode: currentPermissionMode ?? 'unknown' + mode: request.mode, + message: request.message, + permissionMode: getPermissionMode?.() ?? 'unknown' }); return { @@ -278,16 +518,15 @@ export function registerAppServerPermissionHandlers(args: { }; } - logger.debug('[CodexAppServer] Accepting MCP elicitation request', { - serverName: record.serverName, - mode: record.mode, - message: record.message, - permissionMode: currentPermissionMode ?? 'unknown' - }); + const requestId = asString(request.elicitationId) ?? randomUUID(); + const result = await onUserInputRequest({ id: requestId, input }); + if (result.decision !== 'accept') { + return { action: result.decision === 'decline' ? 'decline' : 'cancel', content: null, _meta: null }; + } return { action: 'accept', - content: buildAcceptedElicitationContent(params), + content: buildElicitationContent(params, result.answers), _meta: null }; }); diff --git a/cli/src/codex/utils/codexCliOverrides.test.ts b/cli/src/codex/utils/codexCliOverrides.test.ts index c236bf86c2..06d14960e9 100644 --- a/cli/src/codex/utils/codexCliOverrides.test.ts +++ b/cli/src/codex/utils/codexCliOverrides.test.ts @@ -41,6 +41,16 @@ describe('parseCodexCliOverrides', () => { expect(parseCodexCliOverrides(['-a', 'untrusted', '-a', 'on-failure'])).toEqual({ approvalPolicy: 'on-failure' }); + + expect(parseCodexCliOverrides(['-C', 'first', '--cd=second'])).toEqual({ + cwd: 'second' + }); + }); + + it('parses cwd overrides before the argument terminator', () => { + expect(parseCodexCliOverrides(['--cd', '../project'])).toEqual({ cwd: '../project' }); + expect(parseCodexCliOverrides(['-C=/tmp/project'])).toEqual({ cwd: '/tmp/project' }); + expect(parseCodexCliOverrides(['--', '--cd', '/tmp/ignored'])).toEqual({}); }); it('ignores invalid values and stops at terminator', () => { diff --git a/cli/src/codex/utils/codexCliOverrides.ts b/cli/src/codex/utils/codexCliOverrides.ts index f2d6eeed6f..6e06014c56 100644 --- a/cli/src/codex/utils/codexCliOverrides.ts +++ b/cli/src/codex/utils/codexCliOverrides.ts @@ -1,6 +1,7 @@ export type CodexCliOverrides = { sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never'; + cwd?: string; }; const SANDBOX_VALUES = new Set([ @@ -46,6 +47,23 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides { continue; } + if (arg === '-C' || arg === '--cd') { + const value = args[i + 1]; + if (value && value !== '--') { + overrides.cwd = value; + i += 1; + } + continue; + } + + if (arg.startsWith('--cd=') || arg.startsWith('-C=')) { + const value = arg.slice(arg.indexOf('=') + 1); + if (value) { + overrides.cwd = value; + } + continue; + } + if (arg === '-s' || arg === '--sandbox') { const value = args[i + 1]; if (SANDBOX_VALUES.has(value as CodexCliOverrides['sandbox'])) { diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 13afe113a9..d7cf10081a 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -32,35 +32,93 @@ describe('convertCodexEvent', () => { expect(result?.userMessage).toBe('hello user'); }); - it('converts response_item user messages', () => { + it('converts completed plan items into proposed plan messages', () => { const result = convertCodexEvent({ - type: 'response_item', + type: 'event_msg', payload: { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'hello from response_item user' }] + type: 'item_completed', + turn_id: 'turn-1', + item: { type: 'Plan', id: 'plan-1', text: '## Plan\n\n1. Inspect\n2. Implement' } + } + }); + + expect(result?.message).toMatchObject({ + type: 'proposed_plan', + plan: '## Plan\n\n1. Inspect\n2. Implement', + id: 'plan-1', + turnId: 'turn-1' + }); + }); + + it('ignores empty completed plan items', () => { + const result = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-1', + item: { type: 'Plan', id: 'plan-1', text: ' ' } } }); - expect(result).toEqual({ - userMessage: 'hello from response_item user' + expect(result).toBeNull(); + }); + + it('ignores completed plan items without a turn id', () => { + const result = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'Plan', id: 'plan-1', text: '## Plan' } + } }); + + expect(result).toBeNull(); }); - it('converts response_item assistant messages', () => { + it.each(['task_complete', 'turn_aborted', 'task_failed'])('converts %s into a turn boundary', (type) => { const result = convertCodexEvent({ + type: 'event_msg', + payload: { type, turn_id: 'turn-1' } + }); + + expect(result).toEqual({ finishedTurnId: 'turn-1' }); + }); + + it.each([ + ['user text', { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello from response_item user' }] + } + }], + ['user image', { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }] + } + }], + ['assistant text', { type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hello from response_item assistant' }] } - }); - - expect(result?.message).toMatchObject({ - type: 'message', - message: 'hello from response_item assistant' - }); + }], + ['injected user context', { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '# AGENTS.md\nhidden context' }] + } + }] + ])('ignores %s response_item messages', (_name, event) => { + expect(convertCodexEvent(event)).toBeNull(); }); it('converts reasoning events', () => { diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index efd7394cc0..11024ccc2d 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -14,6 +14,11 @@ export type CodexMessage = { type: 'message'; message: string; id: string; +} | { + type: 'proposed_plan'; + plan: string; + id: string; + turnId: string; } | { type: 'reasoning'; message: string; @@ -42,6 +47,8 @@ export type CodexConversionResult = { sessionId?: string; message?: CodexMessage; userMessage?: string; + userActivity?: true; + finishedTurnId?: string; }; function asRecord(value: unknown): Record | null { @@ -55,30 +62,6 @@ function asString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } -function extractCodexText(value: unknown): string { - if (typeof value === 'string') { - return value.trim(); - } - if (Array.isArray(value)) { - return value - .map((item) => { - const record = asRecord(item); - if (record?.type === 'input_text' && typeof record.text === 'string') return record.text; - if (record?.type === 'output_text' && typeof record.text === 'string') return record.text; - if (record?.type === 'text' && typeof record.text === 'string') return record.text; - return null; - }) - .filter((part): part is string => Boolean(part)) - .join(' ') - .trim(); - } - const record = asRecord(value); - if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim(); - if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim(); - if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim(); - return ''; -} - function parseArguments(value: unknown): unknown { if (typeof value !== 'string') { return value; @@ -146,11 +129,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu const message = asString(payloadRecord.message) ?? asString(payloadRecord.text) ?? asString(payloadRecord.content); - if (!message) { - return null; - } return { - userMessage: message + userActivity: true, + ...(message ? { userMessage: message } : {}) }; } @@ -168,6 +149,29 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu }; } + if (eventType === 'item_completed') { + const item = asRecord(payloadRecord.item); + const itemType = asString(item?.type)?.toLowerCase(); + const message = itemType === 'plan' ? asString(item?.text) : null; + const turnId = asString(payloadRecord.turn_id); + if (!message || message.trim().length === 0 || !turnId) { + return null; + } + return { + message: { + type: 'proposed_plan', + plan: message, + id: asString(item?.id) ?? randomUUID(), + turnId + } + }; + } + + if (eventType === 'task_complete' || eventType === 'turn_aborted' || eventType === 'task_failed') { + const turnId = asString(payloadRecord.turn_id); + return turnId ? { finishedTurnId: turnId } : null; + } + if (eventType === 'agent_reasoning') { const message = asString(payloadRecord.text) ?? asString(payloadRecord.message); if (!message) { @@ -219,23 +223,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu } if (itemType === 'message') { - const role = asString(payloadRecord.role); - const text = extractCodexText(payloadRecord.content); - if (!text) { - return null; - } - if (role === 'user') { - return { userMessage: text }; - } - if (role === 'assistant') { - return { - message: { - type: 'message', - message: text, - id: randomUUID() - } - }; - } + // Response messages are model conversation state; event_msg carries visible chat. return null; } diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index d6a7db8032..d78d436bcc 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createCodexSessionScanner } from './codexSessionScanner'; +import { createCodexSessionScanner, readTranscriptRange } from './codexSessionScanner'; import type { CodexSessionEvent } from './codexEventConverter'; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -59,6 +59,21 @@ describe('codexSessionScanner', () => { expect(events[0]?.type).toBe('event_msg'); }); + it('reads exactly the requested transcript byte range', async () => { + const initial = 'existing transcript\n'; + const appended = 'new event\n'; + await writeFile(transcriptPath, initial); + await appendFile(transcriptPath, appended); + + const content = await readTranscriptRange( + transcriptPath, + Buffer.byteLength(initial), + Buffer.byteLength(appended) + ); + + expect(content.toString('utf-8')).toBe(appended); + }); + it('can replay existing transcript history on first attach', async () => { await writeFile( transcriptPath, @@ -163,4 +178,54 @@ describe('codexSessionScanner', () => { expect(events).toHaveLength(1); expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'after-truncate' }); }); + + it('resets the byte cursor when the transcript file is replaced at the same size', async () => { + const before = JSON.stringify({ + type: 'event_msg', + payload: { type: 'agent_message', message: 'before-replace' } + }) + '\n'; + const after = before.replace('before-replace', 'after-replace!'); + expect(Buffer.byteLength(after)).toBe(Buffer.byteLength(before)); + await writeFile(transcriptPath, before); + + scanner = await createCodexSessionScanner({ + transcriptPath, + onEvent: (event) => events.push(event) + }); + const replacementPath = join(testDir, 'replacement.jsonl'); + await writeFile(replacementPath, after); + await rename(replacementPath, transcriptPath); + // A watcher bound to the old inode may not observe an atomic replacement; + // the periodic scan must still detect it by file identity. + await wait(2200); + + expect(events).toHaveLength(1); + expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'after-replace!' }); + }); + + it('retries an unterminated final record after it is completed', async () => { + await writeFile( + transcriptPath, + JSON.stringify({ type: 'session_meta', payload: { id: 'session-partial' } }) + '\n' + ); + scanner = await createCodexSessionScanner({ + transcriptPath, + onEvent: (event) => events.push(event) + }); + const event = JSON.stringify({ + type: 'event_msg', + payload: { type: 'agent_message', message: 'completed later' } + }); + const splitAt = Math.floor(event.length / 2); + + await appendFile(transcriptPath, event.slice(0, splitAt)); + await wait(300); + expect(events).toEqual([]); + + await appendFile(transcriptPath, event.slice(splitAt)); + await wait(700); + + expect(events).toHaveLength(1); + expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'completed later' }); + }); }); diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 06bd667014..a593e813fe 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { open, stat } from 'node:fs/promises'; import { BaseSessionScanner, SessionFileScanEntry, SessionFileScanResult, SessionFileScanStats } from '@/modules/common/session/BaseSessionScanner'; import { logger } from '@/ui/logger'; import type { CodexSessionEvent } from './codexEventConverter'; @@ -34,7 +34,12 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly onEvent: (event: CodexSessionEvent) => void; private readonly onSessionId?: (sessionId: string) => void; private readonly fileEpochByPath = new Map(); - private readonly fileSizeByPath = new Map(); + private readonly fileStateByPath = new Map(); private replayExistingHistoryOnNextAttach: boolean; private observedSessionId: string | null = null; @@ -110,66 +115,94 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.setCursor(filePath, nextCursor); } - private async readSessionFile(filePath: string, startLine: number): Promise> { - let content: string; + private async readSessionFile(filePath: string, startOffset: number): Promise> { + let fileStats; try { - content = await readFile(filePath, 'utf-8'); + fileStats = await stat(filePath); } catch (error) { - logger.debug(`[codex-session-scanner] Failed to read transcript ${filePath}: ${error}`); - return { events: [], nextCursor: startLine }; + logger.debug(`[codex-session-scanner] Failed to stat transcript ${filePath}: ${error}`); + return { events: [], nextCursor: startOffset }; } - const lines = content.split('\n'); - const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === ''; - const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length; - const currentSize = Buffer.byteLength(content); - const previousSize = this.fileSizeByPath.get(filePath); - let effectiveStartLine = startLine; - - if ((previousSize !== undefined && currentSize < previousSize) || effectiveStartLine > totalLines) { - effectiveStartLine = 0; + const previousState = this.fileStateByPath.get(filePath); + const identityChanged = Boolean( + previousState + && (previousState.device !== fileStats.dev || previousState.inode !== fileStats.ino) + ); + let effectiveStartOffset = startOffset; + let partialLine = previousState?.partialLine ?? Buffer.alloc(0); + let nextLineIndex = previousState?.nextLineIndex ?? 0; + + if (identityChanged || fileStats.size < effectiveStartOffset) { + effectiveStartOffset = 0; + partialLine = Buffer.alloc(0); + nextLineIndex = 0; const nextEpoch = (this.fileEpochByPath.get(filePath) ?? 0) + 1; this.fileEpochByPath.set(filePath, nextEpoch); } - this.fileSizeByPath.set(filePath, currentSize); - const events: SessionFileScanEntry[] = []; - for (let lineIndex = 0; lineIndex < totalLines; lineIndex += 1) { - const line = lines[lineIndex]; - if (!line || line.trim().length === 0) { - continue; - } - - let parsed: unknown; + const bytesToRead = fileStats.size - effectiveStartOffset; + let appended: Buffer = Buffer.alloc(0); + if (bytesToRead > 0) { try { - parsed = JSON.parse(line); + appended = await readTranscriptRange(filePath, effectiveStartOffset, bytesToRead); } catch (error) { - logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`); - continue; + logger.debug(`[codex-session-scanner] Failed to read transcript ${filePath}: ${error}`); + return { events: [], nextCursor: startOffset }; } + } - const event = parseCodexSessionEvent(parsed); - if (!event) { - continue; - } + const content = partialLine.length > 0 + ? Buffer.concat([partialLine, appended]) + : appended; + const events: SessionFileScanEntry[] = []; - if (event.type === 'session_meta') { - const sessionId = extractSessionId(event); - if (sessionId) { - this.updateSessionId(sessionId); + const parseLine = (lineBuffer: Buffer, lineIndex: number, allowIncomplete: boolean): boolean => { + const line = lineBuffer.toString('utf-8'); + if (!line || line.trim().length === 0) return true; + try { + const event = parseCodexSessionEvent(JSON.parse(line)); + if (!event) return true; + if (event.type === 'session_meta') { + const sessionId = extractSessionId(event); + if (sessionId) this.updateSessionId(sessionId); + } + events.push({ event, lineIndex }); + return true; + } catch (error) { + if (!allowIncomplete) { + logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`); } + return false; } + }; - if (lineIndex < effectiveStartLine) { - continue; - } + let lineStart = 0; + for (let index = 0; index < content.length; index += 1) { + if (content[index] !== 0x0a) continue; + parseLine(content.subarray(lineStart, index), nextLineIndex, false); + nextLineIndex += 1; + lineStart = index + 1; + } - events.push({ event, lineIndex }); + const trailing = content.subarray(lineStart); + if (trailing.length > 0 && parseLine(trailing, nextLineIndex, true)) { + partialLine = Buffer.alloc(0); + nextLineIndex += 1; + } else { + partialLine = Buffer.from(trailing); } + this.fileStateByPath.set(filePath, { + device: fileStats.dev, + inode: fileStats.ino, + partialLine, + nextLineIndex + }); + return { events, - nextCursor: totalLines + nextCursor: effectiveStartOffset + appended.length }; } @@ -182,6 +215,22 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } } +export async function readTranscriptRange(filePath: string, startOffset: number, length: number): Promise { + const content = Buffer.allocUnsafe(length); + let bytesRead = 0; + const handle = await open(filePath, 'r'); + try { + while (bytesRead < length) { + const result = await handle.read(content, bytesRead, length - bytesRead, startOffset + bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + } finally { + await handle.close(); + } + return bytesRead === content.length ? content : content.subarray(0, bytesRead); +} + function parseCodexSessionEvent(value: unknown): CodexSessionEvent | null { if (!value || typeof value !== 'object') { return null; diff --git a/cli/src/codex/utils/codexTranscriptLocator.test.ts b/cli/src/codex/utils/codexTranscriptLocator.test.ts new file mode 100644 index 0000000000..0a75bc14f9 --- /dev/null +++ b/cli/src/codex/utils/codexTranscriptLocator.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './codexTranscriptLocator'; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('codexTranscriptLocator', () => { + let codexHome: string; + let sessionDirectory: string; + let locator: CodexTranscriptLocator | null = null; + const originalCodexHome = process.env.CODEX_HOME; + + beforeEach(async () => { + codexHome = join(tmpdir(), `codex-transcript-locator-${Date.now()}-${Math.random()}`); + const now = new Date(); + sessionDirectory = join( + codexHome, + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ); + await mkdir(sessionDirectory, { recursive: true }); + process.env.CODEX_HOME = codexHome; + }); + + afterEach(async () => { + await locator?.cleanup(); + locator = null; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await rm(codexHome, { recursive: true, force: true }); + }); + + it('does not attach for session metadata alone', async () => { + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + const transcriptPath = await createTranscript('thread-meta-only', '/tmp/project'); + + await wait(150); + expect(located).toEqual([]); + expect(transcriptPath).toContain('thread-meta-only'); + }); + + it('attaches after fresh real user activity in the matching cwd', async () => { + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 25, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + const transcriptPath = await createTranscript('thread-user', '/tmp/project'); + + await appendFile(transcriptPath, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'hello' } + })}\n`); + await wait(150); + + expect(located).toEqual([transcriptPath]); + }); + + it('attaches after image-only user activity', async () => { + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 0, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + const transcriptPath = await createTranscript('thread-image', '/tmp/project'); + + await appendFile(transcriptPath, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { + type: 'user_message', + message: '', + images: ['data:image/png;base64,abc'] + } + })}\n`); + await wait(100); + + expect(located).toEqual([transcriptPath]); + }); + + it('refuses fallback when fresh activity is ambiguous', async () => { + const located: string[] = []; + const ambiguous: string[][] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 50, + onLocated: (result) => located.push(result.transcriptPath), + onAmbiguous: (paths) => ambiguous.push(paths) + }); + await locator.ready; + const first = await createTranscript('thread-a', '/tmp/project'); + const second = await createTranscript('thread-b', '/tmp/project'); + + const userEvent = `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'hello' } + })}\n`; + await Promise.all([appendFile(first, userEvent), appendFile(second, userEvent)]); + await wait(150); + + expect(located).toEqual([]); + expect(ambiguous).toHaveLength(1); + expect(new Set(ambiguous[0])).toEqual(new Set([first, second])); + }); + + it('rejects candidates whose activity arrives in adjacent polling cycles', async () => { + const located: string[] = []; + const ambiguous: string[][] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 150, + onLocated: (result) => located.push(result.transcriptPath), + onAmbiguous: (paths) => ambiguous.push(paths) + }); + await locator.ready; + const first = await createTranscript('thread-staggered-a', '/tmp/project'); + const second = await createTranscript('thread-staggered-b', '/tmp/project'); + const userEvent = (message: string) => JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message } + }); + + await appendFile(first, `${userEvent('first')}\n`); + await wait(60); + await appendFile(second, `${userEvent('second')}\n`); + await wait(150); + + expect(located).toEqual([]); + expect(ambiguous).toHaveLength(1); + expect(new Set(ambiguous[0])).toEqual(new Set([first, second])); + }); + + it('retries an unterminated final JSON record after it is completed', async () => { + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 0, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + const transcriptPath = await createTranscript('thread-partial', '/tmp/project'); + const userEvent = JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'completed later' } + }); + const splitAt = Math.floor(userEvent.length / 2); + + await appendFile(transcriptPath, userEvent.slice(0, splitAt)); + await wait(75); + expect(located).toEqual([]); + + await appendFile(transcriptPath, userEvent.slice(splitAt)); + await wait(100); + expect(located).toEqual([transcriptPath]); + }); + + it('ignores pre-existing fresh transcripts even when they receive new activity', async () => { + const transcriptPath = await createTranscript('thread-existing', '/tmp/project'); + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 0, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + + await appendFile(transcriptPath, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'other terminal' } + })}\n`); + await wait(100); + + expect(located).toEqual([]); + }); + + it('polls only the exact resume transcript once it is found', async () => { + const unrelated = await createTranscript('thread-unrelated', '/tmp/project'); + const target = await createTranscript('thread-resume', '/tmp/original-project'); + await appendFile(unrelated, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'unrelated activity' } + })}\n`); + const located: string[] = []; + const ambiguous: string[][] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/current-project', + startupTimestampMs: Date.now(), + resumeSessionId: 'thread-resume', + intervalMs: 25, + onLocated: (result) => located.push(result.transcriptPath), + onAmbiguous: (paths) => ambiguous.push(paths) + }); + await locator.ready; + + await appendFile(target, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { type: 'user_message', message: 'resume activity' } + })}\n`); + await wait(100); + + expect(located).toEqual([target]); + expect(ambiguous).toEqual([]); + }); + + async function createTranscript(sessionId: string, cwd: string): Promise { + const transcriptPath = join(sessionDirectory, `rollout-${sessionId}.jsonl`); + await writeFile(transcriptPath, `${JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd } + })}\n`); + return transcriptPath; + } +}); diff --git a/cli/src/codex/utils/codexTranscriptLocator.ts b/cli/src/codex/utils/codexTranscriptLocator.ts new file mode 100644 index 0000000000..6d5bcd5000 --- /dev/null +++ b/cli/src/codex/utils/codexTranscriptLocator.ts @@ -0,0 +1,380 @@ +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { open, readdir, stat } from 'node:fs/promises'; +import { logger } from '@/ui/logger'; +import { convertCodexEvent, type CodexSessionEvent } from './codexEventConverter'; + +export type LocatedCodexTranscript = { + sessionId: string; + transcriptPath: string; +}; + +export type CodexTranscriptLocator = { + ready: Promise; + cleanup: () => Promise; +}; + +type TranscriptState = { + offset: number; + size: number; + mtimeMs: number; + ino: number; + sessionId: string | null; + cwd: string | null; +}; + +type CodexTranscriptLocatorOptions = { + cwd: string; + startupTimestampMs: number; + resumeSessionId?: string | null; + intervalMs?: number; + settlementMs?: number; + onLocated: (located: LocatedCodexTranscript) => void; + onAmbiguous?: (paths: string[]) => void; +}; + +const DEFAULT_LOCATOR_INTERVAL_MS = 500; + +export function createCodexTranscriptLocator(options: CodexTranscriptLocatorOptions): CodexTranscriptLocator { + const locator = new CodexTranscriptLocatorImpl(options); + const ready = locator.start().catch((error) => { + logger.debug('[codex-transcript-locator] Failed to initialize transcript fallback', error); + }); + return { + ready, + cleanup: async () => { + await locator.cleanup(); + await ready; + } + }; +} + +class CodexTranscriptLocatorImpl { + private readonly sessionsRoot: string; + private readonly targetCwd: string; + private readonly startupTimestampMs: number; + private readonly resumeSessionId: string | null; + private readonly intervalMs: number; + private readonly settlementMs: number; + private readonly onLocated: CodexTranscriptLocatorOptions['onLocated']; + private readonly onAmbiguous?: CodexTranscriptLocatorOptions['onAmbiguous']; + private readonly states = new Map(); + private readonly initialFreshPaths = new Set(); + private readonly pendingCandidates = new Map(); + private resumeTranscriptPaths: string[] | null = null; + private firstCandidateTimestampMs: number | null = null; + private interval: ReturnType | null = null; + private scanPromise: Promise | null = null; + private stopped = false; + + constructor(options: CodexTranscriptLocatorOptions) { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + this.sessionsRoot = join(codexHome, 'sessions'); + this.targetCwd = normalizePath(options.cwd); + this.startupTimestampMs = options.startupTimestampMs; + this.resumeSessionId = options.resumeSessionId ?? null; + this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS; + this.settlementMs = options.settlementMs ?? this.intervalMs; + this.onLocated = options.onLocated; + this.onAmbiguous = options.onAmbiguous; + } + + async start(): Promise { + if (!this.resumeSessionId) { + const existingPaths = await this.listNearbyTranscriptFiles(); + for (const transcriptPath of existingPaths) { + this.initialFreshPaths.add(transcriptPath); + } + } + if (this.stopped) return; + + void this.scan(); + this.interval = setInterval(() => void this.scan(), this.intervalMs); + this.interval.unref?.(); + } + + async cleanup(): Promise { + this.stopped = true; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + await this.scanPromise?.catch(() => {}); + } + + private async scan(): Promise { + if (this.stopped || this.scanPromise) { + return this.scanPromise ?? Promise.resolve(); + } + + this.scanPromise = this.runScan(); + try { + await this.scanPromise; + } finally { + this.scanPromise = null; + } + } + + private async runScan(): Promise { + const files = await this.listCandidateFiles(); + for (const transcriptPath of files) { + if (this.stopped) return; + const candidate = await this.scanFile(transcriptPath); + if (candidate) { + this.pendingCandidates.set(candidate.transcriptPath, candidate); + } + } + + if (this.stopped || this.pendingCandidates.size === 0) { + return; + } + + if (this.pendingCandidates.size > 1) { + const paths = [...this.pendingCandidates.keys()]; + logger.warn('[codex-transcript-locator] Ambiguous Codex transcript activity; refusing fallback attachment', paths); + this.stopPolling(); + this.onAmbiguous?.(paths); + return; + } + + const [located] = this.pendingCandidates.values(); + if (!located) return; + + if (!this.resumeSessionId) { + if (this.firstCandidateTimestampMs === null) { + this.firstCandidateTimestampMs = Date.now(); + } + if (Date.now() - this.firstCandidateTimestampMs < this.settlementMs) { + return; + } + } + + logger.debug(`[codex-transcript-locator] Located ${located.sessionId} at ${located.transcriptPath}`); + this.stopPolling(); + this.onLocated(located); + } + + private async scanFile(transcriptPath: string): Promise { + let fileStats: Awaited>; + try { + fileStats = await stat(transcriptPath); + } catch { + return null; + } + if (!fileStats.isFile()) return null; + + const previous = this.states.get(transcriptPath); + let state: TranscriptState = previous ?? { + offset: 0, + size: 0, + mtimeMs: 0, + ino: fileStats.ino, + sessionId: null, + cwd: null + }; + + const replaced = previous && previous.ino !== fileStats.ino; + const truncated = previous && fileStats.size < previous.offset; + const rewrittenAtSameSize = previous + && fileStats.size === previous.size + && fileStats.mtimeMs !== previous.mtimeMs + && previous.offset === previous.size; + if (replaced || truncated || rewrittenAtSameSize) { + state = { + offset: 0, + size: 0, + mtimeMs: 0, + ino: fileStats.ino, + sessionId: null, + cwd: null + }; + } else if (previous + && fileStats.size === previous.size + && fileStats.mtimeMs === previous.mtimeMs) { + return null; + } + + if (fileStats.size <= state.offset) { + state.size = fileStats.size; + state.mtimeMs = fileStats.mtimeMs; + state.ino = fileStats.ino; + this.states.set(transcriptPath, state); + return null; + } + + let content: Buffer; + try { + content = await readBytes(transcriptPath, state.offset, fileStats.size - state.offset); + } catch { + return null; + } + + const startOffset = state.offset; + const text = content.toString('utf8'); + const lines = text.split('\n'); + let consumedBytes = 0; + let sawFreshUserActivity = false; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + const terminated = index < lines.length - 1; + const recordBytes = Buffer.byteLength(line) + (terminated ? 1 : 0); + + if (!line.trim()) { + if (terminated) consumedBytes += recordBytes; + continue; + } + + let event: CodexSessionEvent; + try { + event = JSON.parse(line) as CodexSessionEvent; + } catch { + if (terminated) consumedBytes += recordBytes; + continue; + } + + if (event.type === 'session_meta') { + const metadata = asRecord(event.payload); + state.sessionId = asString(metadata?.id) ?? state.sessionId; + const eventCwd = asString(metadata?.cwd); + state.cwd = eventCwd ? normalizePath(eventCwd) : state.cwd; + } + + if (convertCodexEvent(event)?.userActivity) { + const eventTimestamp = parseTimestamp(event.timestamp); + if (eventTimestamp !== null && eventTimestamp >= this.startupTimestampMs) { + sawFreshUserActivity = true; + } + } + + consumedBytes += recordBytes; + } + + state.offset = startOffset + consumedBytes; + state.size = startOffset + content.length; + state.mtimeMs = fileStats.mtimeMs; + state.ino = fileStats.ino; + this.states.set(transcriptPath, state); + + if (!sawFreshUserActivity || !state.sessionId) { + return null; + } + if (this.resumeSessionId) { + if (state.sessionId !== this.resumeSessionId) { + return null; + } + } else if (state.cwd !== this.targetCwd) { + return null; + } + + return { sessionId: state.sessionId, transcriptPath }; + } + + private async listCandidateFiles(): Promise { + if (this.resumeSessionId) { + if (this.resumeTranscriptPaths) { + return this.resumeTranscriptPaths; + } + const suffix = `-${this.resumeSessionId}.jsonl`; + const matches = await listJsonlFiles(this.sessionsRoot, (name) => name.endsWith(suffix)); + if (matches.length > 0) { + this.resumeTranscriptPaths = matches; + } + return matches; + } + + const files = await this.listNearbyTranscriptFiles(); + return files.filter((transcriptPath) => !this.initialFreshPaths.has(transcriptPath)); + } + + private async listNearbyTranscriptFiles(): Promise { + const roots = getNearbyDateRoots(this.sessionsRoot, this.startupTimestampMs); + const groups = await Promise.all(roots.map((root) => listJsonlFiles(root))); + return groups.flat(); + } + + private stopPolling(): void { + this.stopped = true; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +async function readBytes(filePath: string, offset: number, length: number): Promise { + const handle = await open(filePath, 'r'); + try { + const buffer = Buffer.alloc(length); + let totalBytesRead = 0; + while (totalBytesRead < length) { + const { bytesRead } = await handle.read( + buffer, + totalBytesRead, + length - totalBytesRead, + offset + totalBytesRead + ); + if (bytesRead === 0) break; + totalBytesRead += bytesRead; + } + return buffer.subarray(0, totalBytesRead); + } finally { + await handle.close(); + } +} + +async function listJsonlFiles( + directory: string, + matchesName: (name: string) => boolean = () => true +): Promise { + try { + const entries = await readdir(directory, { withFileTypes: true }); + const groups = await Promise.all(entries.map(async (entry) => { + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + return await listJsonlFiles(fullPath, matchesName); + } + return entry.isFile() && entry.name.endsWith('.jsonl') && matchesName(entry.name) + ? [fullPath] + : []; + })); + return groups.flat(); + } catch { + return []; + } +} + +function getNearbyDateRoots(sessionsRoot: string, timestampMs: number): string[] { + const roots: string[] = []; + for (const offsetDays of [-1, 0, 1]) { + const date = new Date(timestampMs + offsetDays * 24 * 60 * 60 * 1000); + roots.push(join( + sessionsRoot, + String(date.getUTCFullYear()), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0') + )); + } + return roots; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value !== 'string') return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function normalizePath(value: string): string { + const normalized = resolve(value); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} diff --git a/cli/src/codex/utils/codexVersion.test.ts b/cli/src/codex/utils/codexVersion.test.ts index e2b5757103..47efcbbd2f 100644 --- a/cli/src/codex/utils/codexVersion.test.ts +++ b/cli/src/codex/utils/codexVersion.test.ts @@ -18,6 +18,7 @@ vi.mock('cross-spawn', () => ({ import { assertCodexLocalSupported, + CODEX_VERSION_TIMEOUT_MS, isCodexVersionAtLeast, MIN_CODEX_HOOKS_VERSION, parseCodexVersion @@ -74,7 +75,8 @@ describe('codexVersion', () => { 'node', [codexScriptPath, '--version'], expect.objectContaining({ - encoding: 'utf8' + encoding: 'utf8', + timeout: CODEX_VERSION_TIMEOUT_MS }) ) }) diff --git a/cli/src/codex/utils/codexVersion.ts b/cli/src/codex/utils/codexVersion.ts index aa976b78c8..deeb5b0b66 100644 --- a/cli/src/codex/utils/codexVersion.ts +++ b/cli/src/codex/utils/codexVersion.ts @@ -3,6 +3,7 @@ import { withBunRuntimeEnv } from '@/utils/bunRuntime' import { resolveCodexCommand } from './codexExecutable' export const MIN_CODEX_HOOKS_VERSION = '0.124.0' +export const CODEX_VERSION_TIMEOUT_MS = 3_000 const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/ @@ -59,6 +60,7 @@ export function assertCodexLocalSupported(): void { const result = spawn.sync(codexCommand.command, [...codexCommand.args, '--version'], { encoding: 'utf8', env: withBunRuntimeEnv(), + timeout: CODEX_VERSION_TIMEOUT_MS, windowsHide: process.platform === 'win32' }) diff --git a/cli/src/codex/utils/permissionHandler.test.ts b/cli/src/codex/utils/permissionHandler.test.ts index 8de0adca4e..f141b66133 100644 --- a/cli/src/codex/utils/permissionHandler.test.ts +++ b/cli/src/codex/utils/permissionHandler.test.ts @@ -172,4 +172,31 @@ describe('CodexPermissionHandler', () => { } }); }); + + it('cancels one request_user_input without resetting other pending requests', async () => { + const { handler, getAgentState } = createHarness('default'); + const first = handler.handleUserInputRequest('input-1', { + questions: [{ id: 'action', question: 'First?' }] + }); + const second = handler.handleUserInputRequest('input-2', { + questions: [{ id: 'action', question: 'Second?' }] + }); + + handler.cancelUserInputRequest('input-1', 'No longer relevant'); + + await expect(first).rejects.toThrow('No longer relevant'); + expect(getAgentState().requests).toMatchObject({ + 'input-2': { tool: 'request_user_input' } + }); + expect(getAgentState().requests).not.toHaveProperty('input-1'); + expect(getAgentState().completedRequests).toMatchObject({ + 'input-1': { + status: 'canceled', + reason: 'No longer relevant' + } + }); + + handler.reset(); + await expect(second).rejects.toThrow('Session reset'); + }); }); diff --git a/cli/src/codex/utils/permissionHandler.ts b/cli/src/codex/utils/permissionHandler.ts index 89e2d73ae1..e37fd39b12 100644 --- a/cli/src/codex/utils/permissionHandler.ts +++ b/cli/src/codex/utils/permissionHandler.ts @@ -155,6 +155,22 @@ export class CodexPermissionHandler extends BasePermissionHandler { + it('normalizes non-empty model-reported values', () => { + expect(parseReasoningEffortValue(' EXTREME ')).toBe('extreme'); + }); + + it('maps null to the default effort', () => { + expect(parseReasoningEffortValue(null)).toBeUndefined(); + }); + + it('rejects empty and non-string values', () => { + expect(() => parseReasoningEffortValue(' ')).toThrow('Invalid model reasoning effort'); + expect(() => parseReasoningEffortValue(42)).toThrow('Invalid model reasoning effort'); + }); +}); diff --git a/cli/src/codex/utils/reasoningEffort.ts b/cli/src/codex/utils/reasoningEffort.ts new file mode 100644 index 0000000000..6e744239ab --- /dev/null +++ b/cli/src/codex/utils/reasoningEffort.ts @@ -0,0 +1,16 @@ +import type { ReasoningEffort } from '../appServerTypes'; + +export function parseReasoningEffortValue(value: unknown): ReasoningEffort | undefined { + if (value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error('Invalid model reasoning effort'); + } + + const effort = value.trim().toLowerCase(); + if (!effort) { + throw new Error('Invalid model reasoning effort'); + } + return effort; +} diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index aac27c181c..c07fc5e1bd 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -41,6 +41,12 @@ describe('resolveCodexSlashCommand', () => { expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({ updates: { modelReasoningEffort: 'low' } }); + expect(resolveCodexSlashCommand('/reasoning max', state)).toMatchObject({ + updates: { modelReasoningEffort: 'max' } + }); + expect(resolveCodexSlashCommand('/reasoning EXTREME', state)).toMatchObject({ + updates: { modelReasoningEffort: 'extreme' } + }); expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({ updates: { permissionMode: 'yolo' } }); diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index 62c714a70b..7c481fe100 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -3,8 +3,8 @@ import type { CodexPermissionMode } from '@hapi/protocol/types'; import type { ReasoningEffort } from '../appServerTypes'; import type { EnhancedMode } from '../loop'; import type { SlashCommand } from '@/modules/common/slashCommands'; +import { parseReasoningEffortValue } from './reasoningEffort'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']); export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000; const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ @@ -186,16 +186,11 @@ export function resolveCodexSlashCommand( updates: { modelReasoningEffort: null } }; } - if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) { - return { - kind: 'handled', - message: `Unknown Codex reasoning effort: ${rest}` - }; - } + const effort = parseReasoningEffortValue(rest); return { kind: 'handled', - message: `Codex reasoning effort set to ${rest}`, - updates: { modelReasoningEffort: rest as ReasoningEffort } + message: `Codex reasoning effort set to ${effort}`, + updates: { modelReasoningEffort: effort } }; } @@ -259,7 +254,7 @@ export function resolveCodexSlashCommand( '- `/compact` — compact current Codex thread context', '- `/status` — show current Codex session config', '- `/model [name|auto]` — show or set model', - '- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort', + '- `/reasoning [level|default]` — show or set reasoning effort', '- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)', '- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode', '', diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 6b72a51776..367a0d12d6 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -88,7 +88,6 @@ ${chalk.bold('Usage:')} hapi auth Manage authentication hapi codex Start Codex mode hapi cursor Start Cursor Agent mode - hapi gemini Start Gemini ACP mode hapi opencode Start OpenCode ACP mode hapi resume [id] Resume an existing HAPI session locally hapi mcp Start MCP stdio bridge diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index 7e04fc33b6..058b242020 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -62,6 +62,20 @@ describe('codexCommand', () => { expect(runCodexMock).toHaveBeenCalledWith({}) }) + it('does not block local Codex startup on Hub auto-start readiness', async () => { + maybeAutoStartServerMock.mockImplementationOnce(async () => { + await new Promise(() => {}) + }) + + await codexCommand.run(createCommandContext([])) + + expect(runCodexMock).toHaveBeenCalledOnce() + expect(maybeAutoStartServerMock).toHaveBeenCalledWith({ + waitForReady: false, + quiet: true + }) + }) + it('checks Codex version before resuming a local session', async () => { await codexCommand.run(createCommandContext(['resume', 'session-123'])) @@ -107,6 +121,20 @@ describe('codexCommand', () => { } }) + it('accepts and normalizes a dynamic model reasoning effort', async () => { + await codexCommand.run(createCommandContext([ + '--started-by', + 'runner', + '--model-reasoning-effort', + ' EXTREME ' + ])) + + expect(runCodexMock).toHaveBeenCalledWith({ + startedBy: 'runner', + modelReasoningEffort: 'extreme' + }) + }) + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 196adc9457..a57cce5cdf 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -7,20 +7,7 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort } from '@/codex/appServerTypes' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' - -function parseReasoningEffort(value: string): ReasoningEffort { - switch (value) { - case 'none': - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'xhigh': - return value - default: - throw new Error('Invalid --model-reasoning-effort value') - } -} +import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort' // Mirror the web /service-tier endpoint's enum so the internal resume spawn // path can never seed/persist an unsupported tier string. @@ -86,7 +73,7 @@ export const codexCommand: CommandDefinition = { if (!effort) { throw new Error('Missing --model-reasoning-effort value') } - options.modelReasoningEffort = parseReasoningEffort(effort) + options.modelReasoningEffort = parseReasoningEffortValue(effort) } else if (arg === '--service-tier') { const tier = commandArgs[++i] if (!tier) { @@ -106,7 +93,11 @@ export const codexCommand: CommandDefinition = { } await initializeToken() - await maybeAutoStartServer() + if (options.startedBy === 'runner') { + await maybeAutoStartServer() + } else { + void maybeAutoStartServer({ waitForReady: false, quiet: true }) + } await authAndSetupMachineIfNeeded() await runCodex(options) } catch (error) { diff --git a/cli/src/commands/cursor.test.ts b/cli/src/commands/cursor.test.ts new file mode 100644 index 0000000000..a392b01224 --- /dev/null +++ b/cli/src/commands/cursor.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { parseCursorCommandArgs } from './cursor' + +describe('parseCursorCommandArgs', () => { + it('accepts --mode autoReview', () => { + expect(parseCursorCommandArgs(['--mode', 'autoReview']).permissionMode).toBe('autoReview') + }) + + it('accepts all Cursor permission modes via --mode', () => { + for (const mode of ['default', 'plan', 'ask', 'debug', 'autoReview', 'yolo'] as const) { + expect(parseCursorCommandArgs(['--mode', mode]).permissionMode).toBe(mode) + } + }) + + it('rejects invalid --mode values', () => { + expect(() => parseCursorCommandArgs(['--mode', 'not-a-mode'])).toThrow('Invalid --mode value') + expect(() => parseCursorCommandArgs(['--mode'])).toThrow('Invalid --mode value') + }) + + it('accepts --auto-review shorthand', () => { + expect(parseCursorCommandArgs(['--auto-review']).permissionMode).toBe('autoReview') + }) + + it('does not let --auto-review override an earlier --mode', () => { + expect( + parseCursorCommandArgs(['--mode', 'plan', '--auto-review']).permissionMode + ).toBe('plan') + }) + + it('parses --cursor-worktree and --cursor-add-dir', () => { + const opts = parseCursorCommandArgs([ + '--cursor-worktree', + 'feature-x', + '--cursor-add-dir', + '../shared' + ]) + expect(opts.cursorWorktree).toBe('feature-x') + expect(opts.cursorAddDirs).toEqual(['../shared']) + }) +}) diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts index f8b0df7a55..66e5533d07 100644 --- a/cli/src/commands/cursor.ts +++ b/cli/src/commands/cursor.ts @@ -6,84 +6,109 @@ import type { CommandDefinition } from './types' import { CURSOR_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CursorPermissionMode } from '@hapi/protocol/types' +export type ParsedCursorCommandOptions = { + startedBy?: 'runner' | 'terminal' + cursorArgs?: string[] + cursorWorktree?: boolean | string + cursorAddDirs?: string[] + permissionMode?: CursorPermissionMode + resumeSessionId?: string + model?: string +} + +/** Pure argv parser for `hapi cursor` — exported for unit tests. */ +export function parseCursorCommandArgs(commandArgs: string[]): ParsedCursorCommandOptions { + const options: ParsedCursorCommandOptions = {} + const unknownArgs: string[] = [] + let hasExplicitPermissionMode = false + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (i === 0 && arg === 'resume') { + const candidate = commandArgs[i + 1] + if (!candidate || candidate.startsWith('-')) { + throw new Error('resume requires a chat id') + } + options.resumeSessionId = candidate + i += 1 + continue + } + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CursorPermissionMode + hasExplicitPermissionMode = true + } else if ((arg === '--yolo' || arg === '--force') && !hasExplicitPermissionMode) { + options.permissionMode = 'yolo' + } else if (arg === '--auto-review' && !hasExplicitPermissionMode) { + options.permissionMode = 'autoReview' + } else if (arg === '--mode') { + const mode = commandArgs[++i] + if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CursorPermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--plan') { + options.permissionMode = 'plan' + hasExplicitPermissionMode = true + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } else if (arg === '--cursor-worktree') { + const next = commandArgs[i + 1] + if (next && !next.startsWith('-')) { + options.cursorWorktree = next + i += 1 + } else { + options.cursorWorktree = true + } + } else if (arg === '--cursor-add-dir') { + const dir = commandArgs[++i] + if (!dir || dir.startsWith('-')) { + throw new Error('Missing --cursor-add-dir value') + } + options.cursorAddDirs = [...(options.cursorAddDirs ?? []), dir] + } else if (arg === '--resume') { + const chatId = commandArgs[i + 1] + if (chatId && !chatId.startsWith('-')) { + options.resumeSessionId = chatId + i += 1 + } else { + unknownArgs.push(arg) + } + } else if (arg === '--continue') { + unknownArgs.push(arg) + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value !== 'local' && value !== 'remote') { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + continue + } else { + unknownArgs.push(arg) + } + } + if (unknownArgs.length > 0) { + options.cursorArgs = unknownArgs + } + return options +} + export const cursorCommand: CommandDefinition = { name: 'cursor', requiresRuntimeAssets: true, run: async ({ commandArgs }) => { try { const { runCursor } = await import('@/cursor/runCursor') - - const options: { - startedBy?: 'runner' | 'terminal' - cursorArgs?: string[] - permissionMode?: CursorPermissionMode - resumeSessionId?: string - model?: string - } = {} - const unknownArgs: string[] = [] - let hasExplicitPermissionMode = false - - for (let i = 0; i < commandArgs.length; i++) { - const arg = commandArgs[i] - if (i === 0 && arg === 'resume') { - const candidate = commandArgs[i + 1] - if (!candidate || candidate.startsWith('-')) { - throw new Error('resume requires a chat id') - } - options.resumeSessionId = candidate - i += 1 - continue - } - if (arg === '--started-by') { - options.startedBy = commandArgs[++i] as 'runner' | 'terminal' - } else if (arg === '--permission-mode') { - const mode = commandArgs[++i] - if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { - throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) - } - options.permissionMode = mode as CursorPermissionMode - hasExplicitPermissionMode = true - } else if ((arg === '--yolo' || arg === '--force') && !hasExplicitPermissionMode) { - options.permissionMode = 'yolo' - } else if (arg === '--mode') { - const mode = commandArgs[++i] - if (!mode) { - throw new Error('Missing --mode value') - } - if (mode === 'plan' || mode === 'ask' || mode === 'debug') { - options.permissionMode = mode - } - } else if (arg === '--plan') { - options.permissionMode = 'plan' - } else if (arg === '--model') { - const model = commandArgs[++i] - if (!model) { - throw new Error('Missing --model value') - } - options.model = model - } else if (arg === '--resume') { - const chatId = commandArgs[i + 1] - if (chatId && !chatId.startsWith('-')) { - options.resumeSessionId = chatId - i += 1 - } else { - unknownArgs.push(arg) - } - } else if (arg === '--continue') { - unknownArgs.push(arg) - } else if (arg === '--hapi-starting-mode') { - const value = commandArgs[++i] - if (value !== 'local' && value !== 'remote') { - throw new Error('Invalid --hapi-starting-mode (expected local or remote)') - } - continue - } else { - unknownArgs.push(arg) - } - } - if (unknownArgs.length > 0) { - options.cursorArgs = unknownArgs - } + const options = parseCursorCommandArgs(commandArgs) await initializeToken() await maybeAutoStartServer() diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts deleted file mode 100644 index fb7e9bfaa3..0000000000 --- a/cli/src/commands/gemini.ts +++ /dev/null @@ -1,30 +0,0 @@ -import chalk from 'chalk' -import { authAndSetupMachineIfNeeded } from '@/ui/auth' -import { initializeToken } from '@/ui/tokenInit' -import { maybeAutoStartServer } from '@/utils/autoStartServer' -import type { CommandDefinition } from './types' -import { GEMINI_PERMISSION_MODES } from '@hapi/protocol/modes' -import { parseRemoteAgentCommandOptions } from './agentCommandOptions' - -export const geminiCommand: CommandDefinition = { - name: 'gemini', - requiresRuntimeAssets: true, - run: async ({ commandArgs }) => { - try { - const options = parseRemoteAgentCommandOptions(commandArgs, GEMINI_PERMISSION_MODES) - - await initializeToken() - await maybeAutoStartServer() - await authAndSetupMachineIfNeeded() - - const { runGemini } = await import('@/gemini/runGemini') - await runGemini(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/grok.ts b/cli/src/commands/grok.ts new file mode 100644 index 0000000000..7edbd7b600 --- /dev/null +++ b/cli/src/commands/grok.ts @@ -0,0 +1,34 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import { GROK_PERMISSION_MODES } from '@hapi/protocol/modes' +import { parseRemoteAgentCommandOptions } from './agentCommandOptions' + +export const grokCommand: CommandDefinition = { + name: 'grok', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const hasExplicitPermissionMode = commandArgs.includes('--permission-mode') + const normalizedArgs = hasExplicitPermissionMode + ? commandArgs + : commandArgs.flatMap((arg) => arg === '--yolo' + ? ['--permission-mode', 'bypassPermissions'] + : [arg]) + const options = parseRemoteAgentCommandOptions(normalizedArgs, GROK_PERMISSION_MODES) + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runGrok } = await import('@/grok/runGrok') + await runGrok(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 7e93ca4fca..9d3b7d2480 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk' import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' @@ -6,8 +7,8 @@ import { connectCommand } from './connect' import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' -import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' +import { grokCommand } from './grok' import { opencodeCommand } from './opencode' import { piCommand } from './pi' import { hookForwarderCommand } from './hookForwarder' @@ -16,12 +17,29 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import type { CommandContext, CommandDefinition } from './types' +// Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on +// 2026-06-18) so the agent is no longer launchable. Keep an explicit tombstone +// command so `hapi gemini` reports a clear error instead of falling through to +// the default Claude command with "gemini" as a forwarded argument. +const removedGeminiCommand: CommandDefinition = { + name: 'gemini', + requiresRuntimeAssets: false, + run: async () => { + console.error( + chalk.red('Error:'), + 'Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.' + ) + process.exit(1) + } +} + const COMMANDS: CommandDefinition[] = [ authCommand, connectCommand, codexCommand, cursorCommand, - geminiCommand, + removedGeminiCommand, + grokCommand, kimiCommand, opencodeCommand, piCommand, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index 73b0de0a2a..c97553d00d 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -10,6 +10,7 @@ const { renderMock, runCodexMock, runClaudeMock, + runGrokMock, runPiMock, assertCodexLocalSupportedMock, existsSyncMock @@ -23,6 +24,7 @@ const { renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), + runGrokMock: vi.fn(async () => {}), runPiMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) @@ -46,6 +48,7 @@ vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) +vi.mock('@/grok/runGrok', () => ({ runGrok: runGrokMock })) vi.mock('@/pi/runPi', () => ({ runPi: runPiMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) @@ -75,6 +78,7 @@ describe('resumeCommand', () => { }) runCodexMock.mockClear() runClaudeMock.mockClear() + runGrokMock.mockClear() runPiMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) @@ -142,6 +146,66 @@ describe('resumeCommand', () => { }) }) + it('resumes a Grok target in the native local TUI', async () => { + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-grok', + flavor: 'grok', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + }) + + await resumeCommand.run(createContext(['hapi-session-grok'])) + + expect(runGrokMock).toHaveBeenCalledWith({ + existingSessionId: 'hapi-session-grok', + workingDirectory: '/tmp/project', + resumeSessionId: 'grok-session-1', + startedBy: 'terminal', + permissionMode: 'plan', + startingMode: 'local', + model: 'grok-4.5', + effort: 'low' + }) + }) + + it('rejects an active Gemini target before handoff (no longer supported, leaves running session alone)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-gemini', + flavor: 'gemini', + directory: '/tmp/project', + machineId: 'machine-1', + active: true, + thinking: false, + controlledByUser: false, + agentSessionId: 'gemini-conv-1', + model: 'gemini-2.5-pro', + permissionMode: 'default' + }) + + try { + await expect(resumeCommand.run(createContext(['hapi-session-gemini']))).rejects.toThrow('process.exit:1') + // Regression (#953): the gemini guard must fire BEFORE handoff so an + // active Gemini session is not stopped and then left failing locally. + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('no longer supported')) + } finally { + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) + it('fails before launching when the target belongs to another machine', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index cf2af593fd..50b18223f7 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -7,7 +7,7 @@ import type { ClaudePermissionMode, CodexPermissionMode, CursorPermissionMode, - GeminiPermissionMode, + GrokPermissionMode, KimiPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' @@ -104,29 +104,34 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { } if (target.flavor === 'gemini') { - const { runGemini } = await import('@/gemini/runGemini') - await runGemini({ + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') + } + + if (target.flavor === 'opencode') { + const { runOpencode } = await import('@/opencode/runOpencode') + await runOpencode({ existingSessionId: base.existingSessionId, workingDirectory: base.workingDirectory, resumeSessionId: base.resumeSessionId, startedBy: base.startedBy, - permissionMode: base.permissionMode as GeminiPermissionMode | undefined, + permissionMode: base.permissionMode as OpencodePermissionMode | undefined, startingMode: 'local', model: target.model ?? undefined }) return } - if (target.flavor === 'opencode') { - const { runOpencode } = await import('@/opencode/runOpencode') - await runOpencode({ + if (target.flavor === 'grok') { + const { runGrok } = await import('@/grok/runGrok') + await runGrok({ existingSessionId: base.existingSessionId, workingDirectory: base.workingDirectory, resumeSessionId: base.resumeSessionId, startedBy: base.startedBy, - permissionMode: base.permissionMode as OpencodePermissionMode | undefined, + permissionMode: base.permissionMode as GrokPermissionMode | undefined, startingMode: 'local', - model: target.model ?? undefined + model: target.model ?? undefined, + effort: target.effort ?? undefined }) return } @@ -209,6 +214,14 @@ export const resumeCommand: CommandDefinition = { assertTargetMachine(target, machineId) assertDirectoryExists(target) + // Gemini CLI is no longer launchable (Google sunset the consumer + // Gemini CLI on 2026-06-18). Reject BEFORE the handoff below so an + // active Gemini session is left running/readable rather than being + // stopped by handoffSessionToLocal and then failing locally. + if (target.flavor === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') + } + if (target.active && target.controlledByUser) { throw new Error('Session is already controlled by a local terminal') } diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 5e3eaf75b5..0023dc9731 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -12,11 +12,21 @@ import { import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; import type { CursorSession } from './session'; import type { PermissionMode } from './loop'; -import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './utils/cursorAcpBackend'; +import { + createCursorAcpBackend, + CURSOR_ACP_REQUIRED_MESSAGE, + resolveCursorNativeWorktreePath +} from './utils/cursorAcpBackend'; import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge'; import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot'; import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter'; -import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig'; +import { + applyCursorAcpMode, + applyCursorAcpModel, + isCursorAutoReviewMode, + wireIdForCursorSessionState +} from './utils/cursorModeConfig'; +import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; @@ -33,6 +43,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; private modelApplySeq = 0; + /** True when ACP process was spawned with `--auto-review`. */ + private spawnedWithAutoReview = false; + /** Avoid re-queueing `/auto-review` on every mid-session mode sync. */ + private autoReviewSlashQueued = false; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -57,8 +71,17 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); this.happyServer = happyServer; - const backend = createCursorAcpBackend({ cwd: session.path, model: session.model }); + const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode); + this.spawnedWithAutoReview = autoReview; + const backend = createCursorAcpBackend({ + cwd: session.path, + model: session.model, + autoReview, + worktree: session.cursorWorktree, + addDirs: session.cursorAddDirs + }); this.backend = backend; + this.recordCursorNativeWorktreeMetadata(); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); @@ -209,6 +232,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { await applyCursorAcpMode(backend, acpSessionId, batch.mode.permissionMode as PermissionMode); this.applyDisplayMode(batch.mode.permissionMode as PermissionMode); + + const specialCommand = parseCursorSpecialCommand(batch.message); + if (specialCommand.type === 'pass-through') { + messageBuffer.addMessage(cursorPassThroughStatusMessage(specialCommand.command), 'status'); + } messageBuffer.addMessage(batch.message, 'user'); const promptContent: PromptContent[] = [{ @@ -315,6 +343,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { void applyCursorAcpMode(backend, acpSessionId, mode).then(() => { this.applyDisplayMode(mode); }); + this.maybeQueueAutoReviewSlash(mode); }; this.unregisterModelApplyHandler = session.registerModelApplyHandler(async (model) => ( @@ -435,6 +464,52 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } + /** + * Mid-session Auto-review: ACP has no config option, so when the process was + * not spawned with `--auto-review`, queue an isolated `/auto-review` slash once. + */ + private maybeQueueAutoReviewSlash(mode: PermissionMode): void { + if (!isCursorAutoReviewMode(mode)) { + return; + } + if (this.spawnedWithAutoReview || this.autoReviewSlashQueued) { + return; + } + this.autoReviewSlashQueued = true; + this.session.queue.pushIsolated( + '/auto-review', + { + permissionMode: mode, + model: this.session.model + } + ); + this.messageBuffer.addMessage(cursorPassThroughStatusMessage('auto-review'), 'status'); + } + + private recordCursorNativeWorktreeMetadata(): void { + const worktree = this.session.cursorWorktree; + if (worktree === undefined || worktree === false) { + return; + } + const name = typeof worktree === 'string' ? worktree.trim() : ''; + if (!name) { + this.messageBuffer.addMessage('Cursor native worktree enabled', 'status'); + return; + } + const worktreePath = resolveCursorNativeWorktreePath(this.session.path, name); + this.session.client.updateMetadata((metadata) => ({ + ...metadata, + worktree: { + basePath: this.session.path, + branch: name, + name, + worktreePath, + createdAt: Date.now() + } + })); + this.messageBuffer.addMessage(`Cursor worktree: ${worktreePath}`, 'status'); + } + private async handleAbort(): Promise { const backend = this.backend; const sessionId = this.session.sessionId; diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 7e222886db..e0448c479d 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -75,6 +75,7 @@ function buildAgentArgs(opts: { mode?: string; model?: string; yolo?: boolean; + autoReview?: boolean; }): string[] { const args = ['-p', opts.message, '--output-format', 'stream-json', '--trust', '--workspace', opts.cwd]; @@ -90,15 +91,19 @@ function buildAgentArgs(opts: { if (opts.yolo) { args.push('--yolo'); } + if (opts.autoReview) { + args.push('--auto-review'); + } return args; } -function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean } { +function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean; autoReview?: boolean } { if (mode === 'plan') return { mode: 'plan' }; if (mode === 'ask') return { mode: 'ask' }; if (mode === 'debug') return { mode: 'debug' }; if (mode === 'yolo') return { yolo: true }; + if (mode === 'autoReview') return { autoReview: true }; return {}; } @@ -155,7 +160,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { const { message, mode, isolate: batchIsolated } = batch; const specialCommand = parseCursorSpecialCommand(message); - const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); + const { mode: agentMode, yolo, autoReview } = permissionModeToAgentArgs(mode.permissionMode as string); this.applyDisplayMode(mode.permissionMode as string); messageBuffer.addMessage(message, 'user'); @@ -170,7 +175,8 @@ class CursorRemoteLauncher extends RemoteLauncherBase { sessionId: cursorSessionId, mode: agentMode, model: mode.model, - yolo + yolo, + autoReview }); logger.debug(`[cursor-remote] Spawning agent with args: ${args.join(' ')}`); diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts index 36c5ac1784..7291c60708 100644 --- a/cli/src/cursor/cursorLocal.ts +++ b/cli/src/cursor/cursorLocal.ts @@ -26,6 +26,9 @@ export async function cursorLocal(opts: { model?: string; mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean; + autoReview?: boolean; + worktree?: boolean | string; + addDirs?: readonly string[]; onChatFound?: (chatId: string) => void; cursorArgs?: string[]; }): Promise { @@ -48,6 +51,24 @@ export async function cursorLocal(opts: { args.push('--yolo'); } + if (opts.autoReview) { + args.push('--auto-review'); + } + + if (opts.worktree !== undefined && opts.worktree !== false) { + args.push('--worktree'); + if (typeof opts.worktree === 'string' && opts.worktree.trim()) { + args.push(opts.worktree.trim()); + } + } + + for (const dir of opts.addDirs ?? []) { + const trimmed = dir.trim(); + if (trimmed) { + args.push('--add-dir', trimmed); + } + } + if (opts.cursorArgs) { const safeArgs = filterResumeSubcommand(opts.cursorArgs); args.push(...safeArgs); diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index b08ce2aa37..4e971b1042 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -4,7 +4,11 @@ import { CursorSession } from './session'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; import { convertAgentMessage } from '@/agent/messageConverter'; -function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } { +function permissionModeToCursorArgs(mode?: string): { + mode?: 'plan' | 'ask' | 'debug'; + yolo?: boolean; + autoReview?: boolean; +} { if (mode === 'plan') { return { mode: 'plan' }; } @@ -17,6 +21,9 @@ function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'd if (mode === 'yolo') { return { yolo: true }; } + if (mode === 'autoReview') { + return { autoReview: true }; + } return {}; } @@ -25,7 +32,7 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit if (resumeChatId) { session.onSessionFound(resumeChatId); } - const { mode, yolo } = permissionModeToCursorArgs(session.getPermissionMode() as string); + const { mode, yolo, autoReview } = permissionModeToCursorArgs(session.getPermissionMode() as string); const launcher = new BaseLocalLauncher({ label: 'cursor-local', @@ -43,6 +50,9 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit model: session.model, mode, yolo, + autoReview, + worktree: session.cursorWorktree, + addDirs: session.cursorAddDirs, onChatFound: (chatId) => session.onSessionFound(chatId) }); }, diff --git a/cli/src/cursor/cursorSpecialCommands.test.ts b/cli/src/cursor/cursorSpecialCommands.test.ts index 8cd6aa0fe9..20af62eb07 100644 --- a/cli/src/cursor/cursorSpecialCommands.test.ts +++ b/cli/src/cursor/cursorSpecialCommands.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; +import { + cursorPassThroughStatusMessage, + parseCursorSpecialCommand +} from './cursorSpecialCommands'; describe('parseCursorSpecialCommand', () => { it('accepts /compress with optional instructions', () => { @@ -15,14 +18,54 @@ describe('parseCursorSpecialCommand', () => { }); }); - it('ignores removed or unknown slash commands', () => { - expect(parseCursorSpecialCommand('/context')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/context now')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/summarize')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/clear')).toEqual({ type: null }); + it('accepts summarize/compact aliases', () => { + expect(parseCursorSpecialCommand('/summarize')).toMatchObject({ + type: 'pass-through', + command: 'summarize' + }); + expect(parseCursorSpecialCommand('/compact keep bullets')).toMatchObject({ + type: 'pass-through', + command: 'compact', + message: '/compact keep bullets' + }); + }); + + it('accepts multitask / worktree / add-dir / auto-review', () => { + expect(parseCursorSpecialCommand('/multitask fix lint and tests')).toEqual({ + type: 'pass-through', + command: 'multitask', + message: '/multitask fix lint and tests' + }); + expect(parseCursorSpecialCommand('/worktree feature-x')).toMatchObject({ + type: 'pass-through', + command: 'worktree' + }); + expect(parseCursorSpecialCommand('/add-dir ../shared')).toMatchObject({ + type: 'pass-through', + command: 'add-dir' + }); + expect(parseCursorSpecialCommand('/auto-review')).toMatchObject({ + type: 'pass-through', + command: 'auto-review' + }); + expect(parseCursorSpecialCommand('/best-of-n compare approaches')).toMatchObject({ + type: 'pass-through', + command: 'best-of-n' + }); + }); + + it('rejects interactive TUI-only commands', () => { + expect(parseCursorSpecialCommand('/config')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/mcp')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/sandbox')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/btw why')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/rewind')).toEqual({ type: null }); expect(parseCursorSpecialCommand('/debug')).toEqual({ type: null }); + }); + + it('rejects prefix collisions', () => { expect(parseCursorSpecialCommand('/compressor')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/contextual')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/multitasking')).toEqual({ type: null }); }); }); @@ -30,4 +73,8 @@ describe('cursorPassThroughStatusMessage', () => { it('returns a status line for compress', () => { expect(cursorPassThroughStatusMessage('compress')).toContain('compression'); }); + + it('returns a status line for multitask', () => { + expect(cursorPassThroughStatusMessage('multitask')).toMatch(/multitask/i); + }); }); diff --git a/cli/src/cursor/cursorSpecialCommands.ts b/cli/src/cursor/cursorSpecialCommands.ts index 75b5b11c50..e557083760 100644 --- a/cli/src/cursor/cursorSpecialCommands.ts +++ b/cli/src/cursor/cursorSpecialCommands.ts @@ -1,4 +1,27 @@ -export const CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS = ['compress', 'model'] as const; +/** + * Cursor slash commands that are safe to isolate + pass through as ACP/prompt text. + * + * Exclude interactive TUI / IDE-only commands (`/config`, `/mcp`, `/sandbox`, `/btw`, + * `/rewind`, …) — those need a real terminal surface and will not work over remote ACP. + * + * Mode switches (`/ask`, `/plan`, `/debug`) are handled by HAPI permission-mode UI via ACP + * `session/set_config_option`, not by pass-through. + */ +export const CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS = [ + 'compress', + 'summarize', + 'compact', + 'model', + 'multitask', + 'best-of-n', + 'worktree', + 'apply-worktree', + 'delete-worktree', + 'add-dir', + 'context', + 'fork', + 'auto-review', +] as const; export type CursorPassThroughCommand = typeof CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS[number]; @@ -38,9 +61,29 @@ export function parseCursorSpecialCommand(message: string): CursorSpecialCommand export function cursorPassThroughStatusMessage(command: CursorPassThroughCommand): string { switch (command) { case 'compress': + case 'summarize': + case 'compact': return 'Context compression requested'; case 'model': return 'Model change requested'; + case 'multitask': + return 'Multitask (async subagents) requested'; + case 'best-of-n': + return 'Best-of-N comparison requested'; + case 'worktree': + return 'Cursor worktree requested'; + case 'apply-worktree': + return 'Apply Cursor worktree requested'; + case 'delete-worktree': + return 'Delete Cursor worktree requested'; + case 'add-dir': + return 'Add workspace directory requested'; + case 'context': + return 'Context breakdown requested'; + case 'fork': + return 'Fork conversation requested'; + case 'auto-review': + return 'Auto-review mode toggle requested'; default: { const exhaustive: never = command; return exhaustive; diff --git a/cli/src/cursor/loop.ts b/cli/src/cursor/loop.ts index c286f8dc5a..ecffca76a7 100644 --- a/cli/src/cursor/loop.ts +++ b/cli/src/cursor/loop.ts @@ -24,6 +24,8 @@ interface LoopOptions { session: ApiSessionClient; api: ApiClient; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; permissionMode?: PermissionMode; resumeSessionId?: string; model?: string; @@ -47,6 +49,8 @@ export async function loop(opts: LoopOptions): Promise { startedBy, startingMode, cursorArgs: opts.cursorArgs, + cursorWorktree: opts.cursorWorktree, + cursorAddDirs: opts.cursorAddDirs, model: opts.model, permissionMode: opts.permissionMode ?? 'default' }); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index a855a071fa..8bc3a1af34 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -28,6 +28,8 @@ const formatFailureReason = (message: string): string => { export async function runCursor(opts: { startedBy?: 'runner' | 'terminal'; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; permissionMode?: PermissionMode; resumeSessionId?: string; model?: string; @@ -173,6 +175,8 @@ export async function runCursor(opts: { api, session, cursorArgs: opts.cursorArgs, + cursorWorktree: opts.cursorWorktree, + cursorAddDirs: opts.cursorAddDirs, startedBy, permissionMode: currentPermissionMode, resumeSessionId: opts.resumeSessionId, diff --git a/cli/src/cursor/session.ts b/cli/src/cursor/session.ts index 06851ce918..6fbfc4a984 100644 --- a/cli/src/cursor/session.ts +++ b/cli/src/cursor/session.ts @@ -14,6 +14,10 @@ type CursorModelApplyHandler = (model: string | null | undefined) => Promise { readonly cursorArgs?: string[]; + /** Cursor-native `--worktree` name (`true` = flag without name). */ + readonly cursorWorktree?: boolean | string; + /** Extra `--add-dir` roots for Cursor ACP spawn. */ + readonly cursorAddDirs?: readonly string[]; model?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; @@ -32,6 +36,8 @@ export class CursorSession extends AgentSessionBase { startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; model?: string; permissionMode?: PermissionMode; }) { @@ -55,6 +61,8 @@ export class CursorSession extends AgentSessionBase { }); this.cursorArgs = opts.cursorArgs; + this.cursorWorktree = opts.cursorWorktree; + this.cursorAddDirs = opts.cursorAddDirs; this.model = opts.model; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; diff --git a/cli/src/cursor/utils/cursorAcpBackend.test.ts b/cli/src/cursor/utils/cursorAcpBackend.test.ts index 2ee4374f70..43cfe308b0 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.test.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.test.ts @@ -1,25 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './cursorAcpBackend'; +import { + buildCursorAcpArgs, + createCursorAcpBackend, + CURSOR_ACP_REQUIRED_MESSAGE, + resolveCursorNativeWorktreePath +} from './cursorAcpBackend'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; describe('createCursorAcpBackend', () => { it('uses agent acp command, not stream-json flags', () => { const backend = createCursorAcpBackend({ cwd: '/tmp' }); - const internal = backend as unknown as { options: { command: string; args?: string[] } }; - - expect(internal.options.command).toBe('agent'); - expect(internal.options.args).toEqual(['acp']); - expect(internal.options.args).not.toContain('-p'); - expect(internal.options.args).not.toContain('stream-json'); + const internal = backend as unknown as { transport: null; options?: unknown }; + // Backend stores args on the transport after initialize; inspect via build helper. + expect(buildCursorAcpArgs({})).toEqual(['acp']); + expect(CURSOR_ACP_REQUIRED_MESSAGE).toContain('agent help acp'); + expect(internal).toBeTruthy(); }); it('passes --model before acp when a concrete model is requested', () => { - const backend = createCursorAcpBackend({ - cwd: '/tmp', - model: 'composer-2.5[fast=true]' - }); - const internal = backend as unknown as { options: { args?: string[] } }; - - expect(internal.options.args).toEqual([ + expect( + buildCursorAcpArgs({ + model: 'composer-2.5[fast=true]' + }) + ).toEqual([ '--model', 'composer-2.5[fast=true]', 'acp' @@ -27,17 +31,41 @@ describe('createCursorAcpBackend', () => { }); it('omits --model for default/auto spawn selection', () => { - const backend = createCursorAcpBackend({ cwd: '/tmp', model: 'auto' }); - const internal = backend as unknown as { options: { args?: string[] } }; + expect(buildCursorAcpArgs({ model: 'auto' })).toEqual(['acp']); + }); + + it('adds --auto-review, --worktree, and --add-dir before acp', () => { + expect( + buildCursorAcpArgs({ + autoReview: true, + worktree: 'feature-x', + addDirs: ['/tmp/a', ' /tmp/b '], + model: 'composer-2.5' + }) + ).toEqual([ + '--auto-review', + '--worktree', + 'feature-x', + '--add-dir', + '/tmp/a', + '--add-dir', + '/tmp/b', + '--model', + 'composer-2.5', + 'acp' + ]); + }); - expect(internal.options.args).toEqual(['acp']); + it('emits bare --worktree when name is omitted', () => { + expect(buildCursorAcpArgs({ worktree: true })).toEqual(['--worktree', 'acp']); + expect(buildCursorAcpArgs({ worktree: '' })).toEqual(['--worktree', 'acp']); }); }); -describe('CURSOR_ACP_REQUIRED_MESSAGE', () => { - it('documents that stream-json is not a fallback for new sessions', () => { - expect(CURSOR_ACP_REQUIRED_MESSAGE).toMatch(/ACP/i); - expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/stream-json/i); - expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/fallback/i); +describe('resolveCursorNativeWorktreePath', () => { + it('matches ~/.cursor/worktrees//', () => { + expect(resolveCursorNativeWorktreePath('/home/u/proj/hapi', 'feature-x')).toBe( + join(homedir(), '.cursor', 'worktrees', 'hapi', 'feature-x') + ); }); }); diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts index 119ab57feb..f66304d07b 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -1,3 +1,5 @@ +import { basename, join } from 'node:path'; +import { homedir } from 'node:os'; import { AcpSdkBackend } from '@/agent/backends/acp'; function filterEnv(env: NodeJS.ProcessEnv): Record { @@ -16,15 +18,69 @@ function isDefaultSpawnModel(model: string | null | undefined): boolean { return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; } -export function createCursorAcpBackend(opts: { cwd: string; model?: string | null }): AcpSdkBackend { - const args = ['acp']; +export type CursorAcpBackendOptions = { + cwd: string; + model?: string | null; + /** When true, spawn with `--auto-review` (Cursor Smart Auto). */ + autoReview?: boolean; + /** + * Cursor-native worktree. `true` / `''` → `--worktree` (agent picks a name). + * Non-empty string → `--worktree `. + */ + worktree?: boolean | string; + /** Extra workspace roots (`--add-dir`, repeatable). */ + addDirs?: readonly string[]; +}; + +/** Build `agent … acp` argv (global flags before the `acp` subcommand). */ +export function buildCursorAcpArgs(opts: Omit): string[] { + const args: string[] = []; + + if (opts.autoReview) { + args.push('--auto-review'); + } + + if (opts.worktree !== undefined && opts.worktree !== false) { + args.push('--worktree'); + if (typeof opts.worktree === 'string') { + const name = opts.worktree.trim(); + if (name) { + args.push(name); + } + } + } + + for (const dir of opts.addDirs ?? []) { + const trimmed = dir.trim(); + if (trimmed) { + args.push('--add-dir', trimmed); + } + } + if (!isDefaultSpawnModel(opts.model)) { - args.unshift('--model', opts.model!.trim()); + args.push('--model', opts.model!.trim()); } + args.push('acp'); + return args; +} + +/** + * Resolve the on-disk path Cursor uses for a named `--worktree`. + * Matches CLI output: `~/.cursor/worktrees//`. + */ +export function resolveCursorNativeWorktreePath(repoPath: string, worktreeName: string): string { + const name = worktreeName.trim(); + if (!name) { + throw new Error('Cursor worktree name is required to resolve path'); + } + return join(homedir(), '.cursor', 'worktrees', basename(repoPath), name); +} + +export function createCursorAcpBackend(opts: CursorAcpBackendOptions): AcpSdkBackend { return new AcpSdkBackend({ command: 'agent', - args, + args: buildCursorAcpArgs(opts), env: filterEnv(process.env) }); } diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts index 2aaa652d6a..8b4903c3a9 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts @@ -47,4 +47,50 @@ describe('buildCursorModelsSnapshotFromAcp', () => { expect(snapshot?.availableModels).toHaveLength(2); }); + + it('synthesizes Composer fast variants from parameterized model + fast config options', () => { + const backend = { + getSessionModelsMetadata: () => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + }), + getConfigOptionByCategory: (_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }, + getSessionConfigOptions: () => [ + { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }, + { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + } + ] + }; + + const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1'); + + expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([ + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]' + ]); + expect(snapshot?.currentModelId).toBe('composer-2.5[fast=false]'); + }); }); diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts index 3d836da197..5c3b27e269 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts @@ -6,6 +6,18 @@ export type CursorModelsSnapshot = { currentModelId: string | null; }; +type CursorAcpModelSnapshotBackend = Pick + & Partial>; + +function findConfigOption( + backend: CursorAcpModelSnapshotBackend, + sessionId: string, + key: string +) { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + function mergeModelEntries( target: Map, entries: Iterable<{ modelId: string; name?: string | null }> @@ -31,19 +43,43 @@ function mergeModelEntries( * `availableModels` alone is often one variant per base family. */ export function buildCursorModelsSnapshotFromAcp( - backend: Pick, + backend: CursorAcpModelSnapshotBackend, sessionId: string ): CursorModelsSnapshot | null { const metadata = backend.getSessionModelsMetadata(sessionId); - const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + const modelOption = findConfigOption(backend, sessionId, 'model'); + const fastOption = findConfigOption(backend, sessionId, 'fast'); if (!metadata && !modelOption) { return null; } const merged = new Map(); + const parameterizedFastModels: CursorModelSummary[] = []; + + if (modelOption?.options?.length && fastOption?.options?.length) { + const fastValues = fastOption.options + .map((option) => option.value.trim()) + .filter((value) => value === 'false' || value === 'true'); + if (fastValues.length > 0) { + for (const option of modelOption.options) { + const modelId = option.value.trim(); + if (!modelId || modelId.includes('[')) { + continue; + } + for (const fast of fastValues) { + parameterizedFastModels.push({ + modelId: `${modelId}[fast=${fast}]`, + name: option.name + }); + } + } + } + } - if (modelOption?.options?.length) { + if (parameterizedFastModels.length > 0) { + mergeModelEntries(merged, parameterizedFastModels); + } else if (modelOption?.options?.length) { mergeModelEntries(merged, modelOption.options.map((option) => ({ modelId: option.value, name: option.name @@ -51,16 +87,23 @@ export function buildCursorModelsSnapshotFromAcp( } if (metadata?.availableModels?.length) { - mergeModelEntries(merged, metadata.availableModels); + mergeModelEntries( + merged, + parameterizedFastModels.length > 0 + ? metadata.availableModels.filter((entry) => entry.modelId.includes('[')) + : metadata.availableModels + ); } if (merged.size === 0) { return null; } - const currentModelId = metadata?.currentModelId - ?? modelOption?.currentValue - ?? null; + const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue + ? `${modelOption.currentValue}[fast=${fastOption.currentValue}]` + : metadata?.currentModelId + ?? modelOption?.currentValue + ?? null; return { availableModels: [...merged.values()], diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts index 7cee2942e7..be2b7c379f 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts @@ -162,7 +162,8 @@ describe('CursorExtensionAdapter', () => { expect.objectContaining({ type: 'tool_call', id: 'task-1', - name: 'CursorTask' + name: 'CursorTask', + status: 'completed' }), expect.objectContaining({ type: 'tool_result', @@ -172,6 +173,24 @@ describe('CursorExtensionAdapter', () => { ]); }); + it('keeps CursorTask running when status is in_progress', async () => { + const { handlers, getMessages } = createHarness(); + await handlers.get('cursor/task')!({ + toolCallId: 'task-2', + title: 'Subagent', + status: 'in_progress' + }, null); + + expect(getMessages()).toEqual([ + expect.objectContaining({ + type: 'tool_call', + id: 'task-2', + name: 'CursorTask', + status: 'in_progress' + }) + ]); + }); + it('cancelAll resolves pending extension requests as cancelled', async () => { const { handlers, adapter, getAgentState } = createHarness(); const askPending = handlers.get('cursor/ask_question')!({ toolCallId: 'q-cancel' }, null); diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.ts b/cli/src/cursor/utils/cursorExtensionAdapter.ts index f1370018c1..cad638bf36 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.ts @@ -164,19 +164,22 @@ export class CursorExtensionAdapter { if (!isObject(params)) return; const toolCallId = extractToolCallId(params) ?? `cursor-task-${randomUUID()}`; const title = asString(params.title) ?? asString(params.description) ?? 'Cursor task'; + const status = normalizeTaskStatus(asString(params.status)); this.onMessage({ type: 'tool_call', id: toolCallId, name: 'CursorTask', - input: params, - status: 'completed' - }); - this.onMessage({ - type: 'tool_result', - id: toolCallId, - output: params, - status: 'completed' + input: { ...params, title }, + status }); + if (status === 'completed' || status === 'failed') { + this.onMessage({ + type: 'tool_result', + id: toolCallId, + output: params, + status + }); + } } private handleGenerateImage(params: unknown): void { @@ -254,3 +257,18 @@ function normalizeTodoStatus(status: string | null): PlanItem['status'] { } return 'pending'; } + +function normalizeTaskStatus(status: string | null): 'in_progress' | 'completed' | 'failed' { + if (!status) { + // Cursor often emits task notifications without an explicit status when done. + return 'completed'; + } + const normalized = status.trim().toLowerCase(); + if (normalized === 'running' || normalized === 'in_progress' || normalized === 'pending' || normalized === 'started') { + return 'in_progress'; + } + if (normalized === 'failed' || normalized === 'error' || normalized === 'cancelled' || normalized === 'canceled') { + return 'failed'; + } + return 'completed'; +} diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts index ea4c39dbf1..e827a4bac5 100644 --- a/cli/src/cursor/utils/cursorModeConfig.test.ts +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -3,6 +3,7 @@ import type { AcpSdkBackend } from '@/agent/backends/acp'; import { applyCursorAcpModel, applyCursorAcpMode, + isCursorAutoReviewMode, resolveCursorAcpWireId, toCursorAcpMode, wireIdForCursorSessionState @@ -19,9 +20,12 @@ describe('toCursorAcpMode', () => { it('maps HAPI cursor modes to Cursor ACP modes', () => { expect(toCursorAcpMode('default')).toBe('agent'); expect(toCursorAcpMode('yolo')).toBe('agent'); + expect(toCursorAcpMode('autoReview')).toBe('agent'); expect(toCursorAcpMode('plan')).toBe('plan'); expect(toCursorAcpMode('ask')).toBe('ask'); expect(toCursorAcpMode('debug')).toBe('debug'); + expect(isCursorAutoReviewMode('autoReview')).toBe(true); + expect(isCursorAutoReviewMode('yolo')).toBe(false); expect(toCursorAcpMode(undefined)).toBe('agent'); }); }); @@ -194,6 +198,109 @@ describe('applyCursorAcpModel', () => { expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]'); }); + it('applies parameterized Cursor Composer fast=false for base CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + currentValue: 'true', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=false]', + requestedWireId: 'composer-2.5' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false'); + expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=false]'); + }); + + it('applies parameterized Cursor Composer fast=true for -fast CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { id: 'model', category: 'model', options: [{ value: 'composer-2.5' }] }; + } + if (category === 'fast') { + return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5-fast')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'composer-2.5[fast=true]', + requestedWireId: 'composer-2.5-fast' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true'); + }); + + it('does not fall back to base-only model apply when parameterized fast update fails', async () => { + const setConfigOption = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fast update failed')); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: false + }); + expect(setConfigOption).toHaveBeenCalledTimes(2); + expect(backend.pinSessionModelWireId).not.toHaveBeenCalled(); + }); + it('retries set_config_option once before failing apply', async () => { const setConfigOption = vi.fn() .mockRejectedValueOnce(new Error('transient')) diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts index 2f21a3b35d..77488bddea 100644 --- a/cli/src/cursor/utils/cursorModeConfig.ts +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -1,5 +1,5 @@ import type { CursorPermissionMode } from '@hapi/protocol/types'; -import { matchCliSkuToAcpWireId } from '@hapi/protocol'; +import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol'; import type { AcpSdkBackend } from '@/agent/backends/acp'; import { logger } from '@/ui/logger'; @@ -14,9 +14,15 @@ export function toCursorAcpMode(mode: CursorPermissionMode | undefined): CursorA if (mode === 'plan') return 'plan'; if (mode === 'ask') return 'ask'; if (mode === 'debug') return 'debug'; + // autoReview / yolo / default map to agent; auto-review is a spawn flag + slash, not ACP mode. return 'agent'; } +/** True when HAPI permission mode should spawn/toggle Cursor Auto-review. */ +export function isCursorAutoReviewMode(mode: CursorPermissionMode | undefined): boolean { + return mode === 'autoReview'; +} + function resolveAcpModeConfigValue( mode: CursorPermissionMode | undefined, backend: AcpSdkBackend, @@ -28,7 +34,7 @@ function resolveAcpModeConfigValue( if (optionValues.includes(acpMode)) { return acpMode; } - if (mode === 'yolo' || mode === 'default') { + if (mode === 'yolo' || mode === 'default' || mode === 'autoReview') { if (optionValues.includes('agent')) { return 'agent'; } @@ -71,6 +77,9 @@ export type ApplyCursorAcpModelResult = { requestedWireId?: string; }; +type ConfigOption = NonNullable>; +type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported' | 'failed'; + /** Wire id stored on session + keepalive (preserve explicit variant picks). */ export function wireIdForCursorSessionState(requested: string, resolved: string): string { const trimmed = requested.trim(); @@ -101,6 +110,61 @@ export function resolveCursorAcpWireId( return matchCliSkuToAcpWireId(trimmed, available); } +function findConfigOption(backend: AcpSdkBackend, sessionId: string, key: string): ConfigOption | undefined { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + +function optionHasValue(option: ConfigOption | undefined, value: string): boolean { + return Boolean(option?.options?.some((entry) => entry.value === value)); +} + +function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' { + const lower = modelId.trim().toLowerCase(); + const fastMatch = lower.match(/[\[,](?:\s*)fast=(true|false)(?:\s*)[\],]/) + ?? lower.match(/\[\s*fast=(true|false)\s*\]/); + if (fastMatch?.[1] === 'true' || fastMatch?.[1] === 'false') { + return fastMatch[1]; + } + return lower.includes('-fast') ? 'true' : 'false'; +} + +function cursorRequestBaseId(modelId: string): string { + return modelId.includes('[') + ? cursorModelBaseId(modelId) + : cursorCliSkuBaseId(modelId); +} + +async function applyParameterizedCursorModel( + backend: AcpSdkBackend, + sessionId: string, + requested: string +): Promise { + const modelOption = findConfigOption(backend, sessionId, 'model'); + const fastOption = findConfigOption(backend, sessionId, 'fast'); + if (!modelOption || !fastOption || !backend.setConfigOption) { + return 'unsupported'; + } + + const baseModel = cursorRequestBaseId(requested); + const fast = fastHintForCursorSkuOrWire(requested); + if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) { + return 'unsupported'; + } + + try { + await backend.setConfigOption(sessionId, modelOption.id, baseModel); + await backend.setConfigOption(sessionId, fastOption.id, fast); + } catch (error) { + logger.debug('[cursor-acp] parameterized model config failed', error); + return 'failed'; + } + + const resolved = `${baseModel}[fast=${fast}]`; + backend.pinSessionModelWireId(sessionId, resolved); + return { applied: true, resolvedWireId: resolved, requestedWireId: requested }; +} + /** * Apply a model from the live ACP configOptions list (Zed-style). * Only wire ids present in `availableModels` are accepted. @@ -118,6 +182,15 @@ export async function applyCursorAcpModel( const metadata = backend.getSessionModelsMetadata(sessionId); const available = metadata?.availableModels ?? []; const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + + const parameterized = await applyParameterizedCursorModel(backend, sessionId, trimmed); + if (parameterized === 'failed') { + return { applied: false }; + } + if (parameterized !== 'unsupported') { + return parameterized; + } + const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? []; const catalog = [...available, ...optionWireIds]; const resolved = resolveCursorAcpWireId(trimmed, catalog); diff --git a/cli/src/gemini/geminiLocal.ts b/cli/src/gemini/geminiLocal.ts deleted file mode 100644 index 551145df7f..0000000000 --- a/cli/src/gemini/geminiLocal.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { logger } from '@/ui/logger'; -import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; - -export async function geminiLocal(opts: { - path: string; - sessionId: string | null; - abort: AbortSignal; - model?: string; - approvalMode?: string; - allowedTools?: string[]; - hookSettingsPath?: string; -}): Promise { - const args: string[] = []; - - if (opts.sessionId) { - args.push('--resume', opts.sessionId); - } - if (opts.model) { - args.push('--model', opts.model); - } - if (opts.approvalMode) { - args.push('--approval-mode', opts.approvalMode); - } - if (opts.allowedTools && opts.allowedTools.length > 0) { - args.push('--allowed-tools', ...opts.allowedTools); - } - - const env: NodeJS.ProcessEnv = { - ...process.env, - GEMINI_PROJECT_DIR: opts.path - }; - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - logger.debug(`[GeminiLocal] Spawning gemini with args: ${JSON.stringify(args)}`); - - await spawnWithTerminalGuard({ - command: 'gemini', - args, - cwd: opts.path, - env, - signal: opts.abort, - shell: process.platform === 'win32', - logLabel: 'GeminiLocal', - spawnName: 'gemini', - installHint: 'Gemini CLI', - includeCause: true, - logExit: true - }); -} diff --git a/cli/src/gemini/geminiLocalLauncher.ts b/cli/src/gemini/geminiLocalLauncher.ts deleted file mode 100644 index adfb8f25a4..0000000000 --- a/cli/src/gemini/geminiLocalLauncher.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { geminiLocal } from './geminiLocal'; -import { GeminiSession } from './session'; -import { createGeminiSessionScanner } from './utils/sessionScanner'; -import type { PermissionMode } from './types'; -import { randomUUID } from 'node:crypto'; -import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; - -type GeminiScannerHandle = Awaited>; - -function mapApprovalMode(mode: PermissionMode | undefined): string | undefined { - if (!mode || mode === 'default' || mode === 'read-only') { - return 'default'; - } - if (mode === 'safe-yolo') { - return 'auto_edit'; - } - return 'yolo'; -} - -export async function geminiLocalLauncher( - session: GeminiSession, - opts: { - model?: string; - allowedTools?: string[]; - hookSettingsPath?: string; - } -): Promise<'switch' | 'exit'> { - const launcher = new BaseLocalLauncher({ - label: 'gemini-local', - failureLabel: 'Local Gemini process failed', - queue: session.queue, - rpcHandlerManager: session.client.rpcHandlerManager, - startedBy: session.startedBy, - startingMode: session.startingMode, - launch: async (abortSignal) => { - await geminiLocal({ - path: session.path, - sessionId: session.sessionId, - abort: abortSignal, - model: opts.model, - approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }); - }, - sendFailureMessage: (message) => { - session.sendSessionEvent({ type: 'message', message }); - }, - recordLocalLaunchFailure: (message, exitReason) => { - session.recordLocalLaunchFailure(message, exitReason); - } - }); - - let scanner: GeminiScannerHandle | null = null; - - const handleTranscriptMessage = (message: { type?: string; content?: string }) => { - if (message.type === 'user' && typeof message.content === 'string') { - session.sendUserMessage(message.content); - return; - } - if (message.type === 'gemini' && typeof message.content === 'string') { - session.sendAgentMessage({ - type: 'message', - message: message.content, - id: randomUUID() - }); - } - }; - - const ensureScanner = async (transcriptPath: string): Promise => { - if (scanner) { - scanner.onNewSession(transcriptPath); - return; - } - scanner = await createGeminiSessionScanner({ - transcriptPath, - onMessage: handleTranscriptMessage, - onSessionId: (sessionId) => session.onSessionFound(sessionId) - }); - }; - - const handleTranscriptPath = (transcriptPath: string) => { - void ensureScanner(transcriptPath); - }; - - const hadTranscriptPath = Boolean(session.transcriptPath); - if (hadTranscriptPath && session.transcriptPath) { - await ensureScanner(session.transcriptPath); - } else { - session.addTranscriptPathCallback(handleTranscriptPath); - } - - try { - return await launcher.run(); - } finally { - if (!hadTranscriptPath) { - session.removeTranscriptPathCallback(handleTranscriptPath); - } - - if (scanner !== null) { - await (scanner as GeminiScannerHandle).cleanup(); - } - } -} diff --git a/cli/src/gemini/geminiRemoteLauncher.test.ts b/cli/src/gemini/geminiRemoteLauncher.test.ts deleted file mode 100644 index 4b1aab676c..0000000000 --- a/cli/src/gemini/geminiRemoteLauncher.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { GeminiMode, PermissionMode } from './types'; - -const harness = vi.hoisted(() => ({ - setModelArgs: [] as Array<{ sessionId: string; modelId: string }>, - promptCount: 0, - events: [] as string[], - setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise) -})); - -vi.mock('./utils/geminiBackend', () => ({ - createGeminiBackend: vi.fn(() => ({ - initialize: vi.fn(async () => {}), - newSession: vi.fn(async () => 'acp-session-1'), - loadSession: vi.fn(async () => 'acp-session-1'), - setModel: vi.fn(async (sessionId: string, modelId: string) => { - harness.events.push(`setModel:${modelId}`); - harness.setModelArgs.push({ sessionId, modelId }); - if (harness.setModelImpl) { - await harness.setModelImpl(sessionId, modelId); - } - }), - prompt: vi.fn(async () => { - harness.events.push('prompt:start'); - harness.promptCount++; - await new Promise((resolve) => setImmediate(resolve)); - harness.events.push('prompt:end'); - }), - cancelPrompt: vi.fn(async () => {}), - respondToPermission: vi.fn(async () => {}), - onStderrError: vi.fn(), - onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}) - })) -})); - -vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ - buildHapiMcpBridge: async () => ({ - server: { stop: () => {} }, - mcpServers: {} - }) -})); - -vi.mock('./utils/permissionHandler', () => ({ - GeminiPermissionHandler: class { - async cancelAll(): Promise {} - } -})); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: () => ({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }) -})); - -vi.mock('@/ui/ink/GeminiDisplay', () => ({ - GeminiDisplay: () => null -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn(), - warn: vi.fn(), - info: vi.fn() - } -})); - -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; - -function createMode(model?: string): GeminiMode { - return { - permissionMode: 'default' as PermissionMode, - model - }; -} - -function createSessionStub(items: Array<{ message: string; mode: GeminiMode }>) { - const queue = new MessageQueue2((mode) => JSON.stringify(mode)); - items.forEach(({ message, mode }, index) => { - if (index === 0 && items.length > 1) { - queue.pushIsolateAndClear(message, mode); - } else { - queue.push(message, mode); - } - }); - queue.close(); - - const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; - const rpcHandlers = new Map unknown>(); - - const client = { - rpcHandlerManager: { - registerHandler(method: string, handler: (params: unknown) => unknown) { - rpcHandlers.set(method, handler); - } - }, - sendAgentMessage(_message: unknown) {}, - sendUserMessage(_text: string) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - sessionEvents.push(event); - } - }; - - const session = { - path: '/tmp/hapi-gemini-test', - logPath: '/tmp/hapi-gemini-test/test.log', - client, - queue, - sessionId: null as string | null, - thinking: false, - getPermissionMode() { - return 'default' as const; - }, - setModel(_model: string | null) {}, - onThinkingChange(thinking: boolean) { - session.thinking = thinking; - }, - onSessionFound(id: string) { - session.sessionId = id; - }, - sendAgentMessage(_message: unknown) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - client.sendSessionEvent(event); - }, - sendUserMessage(_text: string) {} - }; - - return { session, sessionEvents, rpcHandlers }; -} - -describe('geminiRemoteLauncher inline model switch', () => { - afterEach(() => { - harness.setModelArgs = []; - harness.promptCount = 0; - harness.events = []; - harness.setModelImpl = null; - }); - - it('calls setModel between turns when the queued model differs from the running backend model', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - expect(harness.promptCount).toBe(2); - }); - - it('does not call setModel when the model is unchanged across turns', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-3-flash-preview') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([]); - expect(harness.promptCount).toBe(2); - }); - - it('latches inline switching off after a method-not-found response and notifies the user once', async () => { - harness.setModelImpl = async () => { - throw new Error('Method not found: session/set_model'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') }, - { message: 'third', mode: createMode('gemini-2.5-flash') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Only one setModel attempt — latched off after the first method-not-found - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - const unsupportedMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('does not support inline model switching') - ); - expect(unsupportedMessages.length).toBe(1); - expect(harness.promptCount).toBe(3); - }); - - it('reports a transient setModel error and continues with the previous model', async () => { - let attempts = 0; - harness.setModelImpl = async () => { - attempts++; - throw new Error('Transient backend failure'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(attempts).toBe(1); - const failureMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('Failed to switch model') - ); - expect(failureMessages.length).toBe(1); - expect(failureMessages[0]?.message).toContain('gemini-2.5-pro'); - expect(harness.promptCount).toBe(2); - }); - - it('serializes setModel after the previous prompt resolves', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Order must be: prompt(1) start/end → setModel → prompt(2) start/end - expect(harness.events).toEqual([ - 'prompt:start', - 'prompt:end', - 'setModel:gemini-2.5-pro', - 'prompt:start', - 'prompt:end' - ]); - }); -}); diff --git a/cli/src/gemini/geminiRemoteLauncher.ts b/cli/src/gemini/geminiRemoteLauncher.ts deleted file mode 100644 index fa6e3cb5c8..0000000000 --- a/cli/src/gemini/geminiRemoteLauncher.ts +++ /dev/null @@ -1,306 +0,0 @@ -import React from 'react'; -import { logger } from '@/ui/logger'; -import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; -import { convertAgentMessage } from '@/agent/messageConverter'; -import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; -import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; -import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; -import type { GeminiSession } from './session'; -import type { PermissionMode } from './types'; -import { createGeminiBackend } from './utils/geminiBackend'; -import { GeminiPermissionHandler } from './utils/permissionHandler'; -import { resolveGeminiRuntimeConfig } from './utils/config'; - -class GeminiRemoteLauncher extends RemoteLauncherBase { - private readonly session: GeminiSession; - private readonly model?: string; - private readonly hookSettingsPath?: string; - private backend: ReturnType | null = null; - private permissionHandler: GeminiPermissionHandler | null = null; - private happyServer: { stop: () => void } | null = null; - private abortController = new AbortController(); - private displayModel: string | null = null; - private displayPermissionMode: PermissionMode | null = null; - private currentBackendModel: string | null = null; - private setModelSupported: boolean | undefined = undefined; - - constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) { - super(process.env.DEBUG ? session.logPath : undefined); - this.session = session; - this.model = opts.model; - this.hookSettingsPath = opts.hookSettingsPath; - } - - public async launch(): Promise { - return this.start({ - onExit: () => this.handleExitFromUi(), - onSwitchToLocal: () => this.handleSwitchFromUi() - }); - } - - protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { - return React.createElement(GeminiDisplay, context); - } - - protected async runMainLoop(): Promise { - const session = this.session; - const messageBuffer = this.messageBuffer; - - const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); - this.happyServer = happyServer; - - const runtimeConfig = resolveGeminiRuntimeConfig({ model: this.model }); - this.displayModel = runtimeConfig.model; - messageBuffer.addMessage(`[MODEL:${runtimeConfig.model}]`, 'system'); - - const backend = createGeminiBackend({ - model: runtimeConfig.model, - token: runtimeConfig.token, - hookSettingsPath: this.hookSettingsPath, - cwd: session.path, - permissionMode: session.getPermissionMode() as string | undefined - }); - this.backend = backend; - - backend.onStderrError((error) => { - logger.debug('[gemini-remote] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); - messageBuffer.addMessage(error.message, 'status'); - }); - - await backend.initialize(); - - const resumeSessionId = session.sessionId; - const acpMcpServers = toAcpMcpServers(mcpServers); - let acpSessionId: string; - if (resumeSessionId) { - try { - acpSessionId = await backend.loadSession({ - sessionId: resumeSessionId, - cwd: session.path, - mcpServers: acpMcpServers - }); - } catch (error) { - logger.warn('[gemini-remote] resume failed, starting new session', error); - session.sendSessionEvent({ - type: 'message', - message: 'Gemini resume failed; starting a new session.' - }); - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - } else { - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - session.onSessionFound(acpSessionId); - - this.permissionHandler = new GeminiPermissionHandler( - session.client, - backend, - () => session.getPermissionMode() as PermissionMode | undefined - ); - this.currentBackendModel = runtimeConfig.model; - this.applyDisplayMode(session.getPermissionMode() as PermissionMode, this.currentBackendModel); - - this.setupAbortHandlers(session.client.rpcHandlerManager, { - onAbort: () => this.handleAbort(), - onSwitch: () => this.handleSwitchRequest() - }); - - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - }; - - while (!this.shouldExit) { - const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal); - if (!batch) { - if (this.abortController.signal.aborted && !this.shouldExit) { - continue; - } - break; - } - - // Inline model change via RPC. If the running gemini-cli build does not - // implement session/set_model, we learn that from the first method-not-found - // response and stop attempting it for the rest of this session. - if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { - if (!backend.setModel || this.setModelSupported === false) { - batch.mode.model = this.currentBackendModel!; - } else { - logger.debug(`[gemini-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`); - try { - await backend.setModel(acpSessionId, batch.mode.model); - this.currentBackendModel = batch.mode.model; - this.setModelSupported = true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const methodNotFound = /method not found/i.test(message); - if (methodNotFound && this.setModelSupported === undefined) { - this.setModelSupported = false; - logger.warn('[gemini-remote] Gemini CLI build does not support session/set_model; inline switching disabled for this session'); - session.sendSessionEvent({ - type: 'message', - message: 'This Gemini CLI build does not support inline model switching. Restart the session to apply a different model.' - }); - } else { - logger.warn('[gemini-remote] Inline model switch failed', error); - session.sendSessionEvent({ - type: 'message', - message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel}.` - }); - } - batch.mode.model = this.currentBackendModel!; - } - } - } - - this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); - messageBuffer.addMessage(batch.message, 'user'); - - const promptContent: PromptContent[] = [{ - type: 'text', - text: batch.message - }]; - - session.onThinkingChange(true); - - try { - await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { - this.handleAgentMessage(message); - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn('[gemini-remote] prompt failed', { message: errorMessage }); - session.sendSessionEvent({ - type: 'message', - message: `Gemini prompt failed: ${errorMessage}` - }); - messageBuffer.addMessage(`Gemini prompt failed: ${errorMessage}`, 'status'); - } finally { - session.onThinkingChange(false); - await this.permissionHandler?.cancelAll('Prompt finished'); - if (session.queue.size() === 0 && !this.shouldExit) { - sendReady(); - } - } - } - } - - protected async cleanup(): Promise { - this.clearAbortHandlers(this.session.client.rpcHandlerManager); - - if (this.permissionHandler) { - await this.permissionHandler.cancelAll('Session ended'); - this.permissionHandler = null; - } - - if (this.backend) { - await this.backend.disconnect(); - this.backend = null; - } - - if (this.happyServer) { - this.happyServer.stop(); - this.happyServer = null; - } - } - - private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); - if (converted) { - this.session.sendAgentMessage(converted); - } - - switch (message.type) { - case 'text': - this.messageBuffer.addMessage(message.text, 'assistant'); - break; - case 'reasoning': - if (message.live) { - break; - } - this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system'); - break; - case 'tool_call': - this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); - break; - case 'tool_result': - this.messageBuffer.addMessage('Tool result received', 'result'); - break; - case 'usage': - break; - case 'plan': - this.messageBuffer.addMessage('Plan updated', 'status'); - break; - case 'error': - this.messageBuffer.addMessage(message.message, 'status'); - break; - case 'turn_complete': - this.messageBuffer.addMessage('Turn complete', 'status'); - break; - default: { - const _exhaustive: never = message; - return _exhaustive; - } - } - } - - private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void { - if (permissionMode && permissionMode !== this.displayPermissionMode) { - this.displayPermissionMode = permissionMode; - this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); - } - if (model && model !== this.displayModel) { - this.displayModel = model; - this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); - } - } - - private async handleAbort(): Promise { - const backend = this.backend; - if (backend && this.session.sessionId) { - await backend.cancelPrompt(this.session.sessionId); - } - await this.permissionHandler?.cancelAll('User aborted'); - this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' }); - this.session.queue.reset(); - this.session.onThinkingChange(false); - this.abortController.abort(); - this.abortController = new AbortController(); - this.messageBuffer.addMessage('Turn aborted', 'status'); - } - - private async handleExitFromUi(): Promise { - await this.requestExit('exit', () => this.handleAbort()); - } - - private async handleSwitchFromUi(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } - - private async handleSwitchRequest(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } -} - -function toAcpMcpServers(config: Record): McpServerStdio[] { - return Object.entries(config).map(([name, entry]) => ({ - name, - command: entry.command, - args: entry.args, - env: [] - })); -} - -export async function geminiRemoteLauncher( - session: GeminiSession, - opts: { model?: string; hookSettingsPath?: string } -): Promise<'switch' | 'exit'> { - const launcher = new GeminiRemoteLauncher(session, opts); - return launcher.launch(); -} diff --git a/cli/src/gemini/loop.ts b/cli/src/gemini/loop.ts deleted file mode 100644 index 453e55261e..0000000000 --- a/cli/src/gemini/loop.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { logger } from '@/ui/logger'; -import { runLocalRemoteSession } from '@/agent/loopBase'; -import { GeminiSession } from './session'; -import { geminiLocalLauncher } from './geminiLocalLauncher'; -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; -import { ApiClient, ApiSessionClient } from '@/lib'; -import type { GeminiMode, PermissionMode } from './types'; - -interface GeminiLoopOptions { - path: string; - startingMode?: 'local' | 'remote'; - startedBy?: 'runner' | 'terminal'; - onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; - session: ApiSessionClient; - api: ApiClient; - permissionMode?: PermissionMode; - model?: string; - hookSettingsPath?: string; - allowedTools?: string[]; - resumeSessionId?: string; - onSessionReady?: (session: GeminiSession) => void; -} - -export async function geminiLoop(opts: GeminiLoopOptions): Promise { - const logPath = logger.getLogPath(); - const startedBy = opts.startedBy ?? 'terminal'; - const startingMode = opts.startingMode ?? 'local'; - - const session = new GeminiSession({ - api: opts.api, - client: opts.session, - path: opts.path, - sessionId: opts.resumeSessionId ?? null, - logPath, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: startingMode, - startedBy, - startingMode, - permissionMode: opts.permissionMode ?? 'default' - }); - - if (opts.resumeSessionId) { - session.onSessionFound(opts.resumeSessionId); - } - - const getCurrentModel = (): string | undefined => { - const sessionModel = session.getModel(); - return sessionModel != null ? sessionModel : opts.model; - }; - - await runLocalRemoteSession({ - session, - startingMode: opts.startingMode, - logTag: 'gemini-loop', - runLocal: (instance) => geminiLocalLauncher(instance, { - model: getCurrentModel(), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }), - runRemote: (instance) => geminiRemoteLauncher(instance, { - model: getCurrentModel(), - hookSettingsPath: opts.hookSettingsPath - }), - onSessionReady: opts.onSessionReady - }); -} diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts deleted file mode 100644 index 9b9b3be4b5..0000000000 --- a/cli/src/gemini/runGemini.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockGeminiSession = vi.hoisted(() => ({ - setModel: vi.fn(), - setPermissionMode: vi.fn(), - pushKeepAlive: vi.fn(), - thinking: false, - stopKeepAlive: vi.fn() -})); - -const harness = vi.hoisted(() => ({ - bootstrapArgs: [] as Array>, - geminiLoopArgs: [] as Array>, - geminiLoopError: null as Error | null, - session: { - onUserMessage: vi.fn(), - onCancelQueuedMessage: vi.fn(), - rpcHandlerManager: { - registerHandler: vi.fn() - } - } -})); - -vi.mock('@/agent/sessionFactory', () => ({ - bootstrapSession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push(options); - return { - api: {}, - session: harness.session - }; - }) -})); - -vi.mock('./loop', () => ({ - geminiLoop: vi.fn(async (options: Record) => { - harness.geminiLoopArgs.push(options); - if (harness.geminiLoopError) { - throw harness.geminiLoopError; - } - const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; - if (onSessionReady) { - onSessionReady(mockGeminiSession); - } - }) -})); - -vi.mock('@/claude/registerKillSessionHandler', () => ({ - registerKillSessionHandler: vi.fn() -})); - -const lifecycleMock = vi.hoisted(() => ({ - registerProcessHandlers: vi.fn(), - cleanupAndExit: vi.fn(async () => {}), - markCrash: vi.fn(), - setExitCode: vi.fn(), - setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn(), - hasExplicitSessionEndReason: vi.fn(() => false) -})); - -vi.mock('@/agent/runnerLifecycle', () => ({ - createModeChangeHandler: vi.fn(() => vi.fn()), - createRunnerLifecycle: vi.fn(() => lifecycleMock), - setControlledByUser: vi.fn() -})); - -vi.mock('@/claude/utils/startHookServer', () => ({ - startHookServer: vi.fn(async () => ({ - port: 1234, - token: 'token', - stop: vi.fn() - })) -})); - -vi.mock('@/modules/common/hooks/generateHookSettings', () => ({ - cleanupHookSettingsFile: vi.fn(), - generateHookSettingsFile: vi.fn(() => '/tmp/gemini-hooks.json') -})); - -const resolveGeminiRuntimeConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: resolveGeminiRuntimeConfigMock -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn() - } -})); - -vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) -})); - -import { runGemini } from './runGemini'; - -describe('runGemini', () => { - beforeEach(() => { - harness.bootstrapArgs.length = 0; - harness.geminiLoopArgs.length = 0; - harness.geminiLoopError = null; - mockGeminiSession.setModel.mockReset(); - mockGeminiSession.setPermissionMode.mockReset(); - harness.session.onUserMessage.mockReset(); - harness.session.rpcHandlerManager.registerHandler.mockReset(); - lifecycleMock.registerProcessHandlers.mockClear(); - lifecycleMock.cleanupAndExit.mockClear(); - lifecycleMock.markCrash.mockClear(); - lifecycleMock.setExitCode.mockClear(); - lifecycleMock.setArchiveReason.mockClear(); - lifecycleMock.setSessionEndReason.mockClear(); - resolveGeminiRuntimeConfigMock.mockReset(); - }); - - it('persists a resolved config model before bootstrapping the session', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-pro-preview', - modelSource: 'local' - }); - - await runGemini({}); - - expect(harness.bootstrapArgs[0]?.model).toBe('gemini-3-pro-preview'); - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-pro-preview'); - }); - - it('does not persist the hardcoded default fallback model so it floats with machine config', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - expect(harness.bootstrapArgs[0]?.model).toBeUndefined(); - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-flash-preview'); - }); - - it('applies model change via set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - expect(configHandler).toBeDefined(); - - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ model: 'gemini-2.5-flash' }) as Record; - const applied = result.applied as Record; - expect(applied.model).toBe('gemini-2.5-flash'); - }); - - it('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - // Reset to ignore pushKeepAlive fired from initial onSessionReady setup - mockGeminiSession.pushKeepAlive.mockClear(); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - await handler({ model: 'gemini-2.5-flash' }); - - expect(mockGeminiSession.pushKeepAlive).toHaveBeenCalledTimes(1); - }); - - it('rejects invalid model in set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - await expect(handler({ model: 123 })).rejects.toThrow(); - }); - - it('accepts null model (Auto) in set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ model: null }) as Record; - const applied = result.applied as Record; - // null (Default) should be passed through to hub for DB clearing - expect(applied.model).toBeNull(); - }); - - it('only includes changed fields in applied response', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ permissionMode: 'default' }) as Record; - const applied = result.applied as Record; - expect(applied.permissionMode).toBe('default'); - expect(applied).not.toHaveProperty('model'); - }); - - it('stores null model in session on Default selection for keepalive', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - - // First set an explicit model - await handler({ model: 'gemini-2.5-flash' }); - expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith('gemini-2.5-flash'); - - // Then select Default (null) — session should store null, not concrete model - await handler({ model: null }); - expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); - }); - - it('passes machine default (not startup model) to geminiLoop for fallback', async () => { - // Session started with explicit model, but machine default differs - resolveGeminiRuntimeConfigMock.mockImplementation((opts?: { model?: string }) => { - if (opts?.model) { - return { model: opts.model, modelSource: 'explicit' }; - } - return { model: 'gemini-2.5-pro', modelSource: 'default' }; - }); - - await runGemini({ model: 'gemini-2.5-flash' }); - - // geminiLoop should receive machine default as fallback, not the explicit startup model - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro'); - }); - - it('passes resumeSessionId through to geminiLoop', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({ resumeSessionId: 'a6157ffa-f692-4b73-82d5-63d42177f4f9' }); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBe('a6157ffa-f692-4b73-82d5-63d42177f4f9'); - }); - - it('does not set resumeSessionId when not provided', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({}); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBeUndefined(); - }); - - it('preserves crash session end reason instead of overwriting it as completed', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - harness.geminiLoopError = new Error('loop failed'); - - await runGemini({}); - - expect(lifecycleMock.markCrash).toHaveBeenCalledWith(harness.geminiLoopError); - expect(lifecycleMock.setSessionEndReason).not.toHaveBeenCalledWith('completed'); - expect(lifecycleMock.cleanupAndExit).toHaveBeenCalled(); - }); -}); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts deleted file mode 100644 index c2e667225e..0000000000 --- a/cli/src/gemini/runGemini.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { logger } from '@/ui/logger'; -import { geminiLoop } from './loop'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { hashObject } from '@/utils/deterministicJson'; -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import type { AgentState } from '@/api/types'; -import type { GeminiSession } from './session'; -import type { GeminiMode, PermissionMode } from './types'; -import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; -import { registerLocalHandoffHandler } from '@/agent/localHandoff'; -import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { startHookServer } from '@/claude/utils/startHookServer'; -import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; -import { resolveGeminiRuntimeConfig } from './utils/config'; -import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { getInvokedCwd } from '@/utils/invokedCwd'; - -export async function runGemini(opts: { - startedBy?: 'runner' | 'terminal'; - startingMode?: 'local' | 'remote'; - permissionMode?: PermissionMode; - model?: string; - resumeSessionId?: string; - existingSessionId?: string; - workingDirectory?: string; -} = {}): Promise { - const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); - const startedBy = opts.startedBy ?? 'terminal'; - - logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); - - if (startedBy === 'runner' && opts.startingMode === 'local') { - logger.debug('[gemini] Runner spawn requested with local mode; forcing remote mode'); - opts.startingMode = 'remote'; - } - - const initialState: AgentState = { - controlledByUser: false - }; - - const machineDefault = resolveGeminiRuntimeConfig().model; - const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model }); - // Persist only when the user (or env/local config) chose the model. The hardcoded - // default remains undefined in the DB so it floats with the machine config across - // gemini-cli upgrades. Mid-session selections are persisted by the hub via the - // set-session-config RPC, not by this initial bootstrap. - const persistedModel = runtimeConfig.modelSource === 'default' - ? undefined - : runtimeConfig.model; - - const bootstrap = opts.existingSessionId - ? await bootstrapExistingSession({ - sessionId: opts.existingSessionId, - flavor: 'gemini', - startedBy, - workingDirectory - }) - : await bootstrapSession({ - flavor: 'gemini', - startedBy, - workingDirectory, - agentState: initialState, - model: persistedModel - }); - const { api, session } = bootstrap; - - const startingMode: 'local' | 'remote' = opts.startingMode - ?? (startedBy === 'runner' ? 'remote' : 'local'); - - setControlledByUser(session, startingMode); - - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model - })); - - const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; - let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - let sessionModel: string | null = persistedModel ?? null; - let resolvedModel = sessionModel ?? machineDefault; - - const hookServer = await startHookServer({ - onSessionHook: (sessionId, data) => { - logger.debug(`[gemini] Session hook received: ${sessionId}`); - const currentSession = sessionWrapperRef.current; - if (!currentSession) { - return; - } - if (currentSession.sessionId !== sessionId) { - currentSession.onSessionFound(sessionId); - } - if (typeof data.transcript_path === 'string') { - currentSession.onTranscriptPathFound(data.transcript_path); - } - } - }); - - const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { - filenamePrefix: 'gemini-session-hook', - logLabel: 'gemini-hook-settings', - hooksEnabled: true - }); - - const lifecycle = createRunnerLifecycle({ - session, - logTag: 'gemini', - stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(), - onAfterClose: () => { - hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath, 'gemini-hook-settings'); - } - }); - - lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle); - registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); - - const syncSessionMode = () => { - const sessionInstance = sessionWrapperRef.current; - if (!sessionInstance) { - return; - } - sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModel(sessionModel); - - // Notify hub immediately to reflect changes in UI - sessionInstance.pushKeepAlive(); - - logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); - }; - - session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - const mode: GeminiMode = { - permissionMode: currentPermissionMode, - model: resolvedModel - }; - messageQueue.push(formattedText, mode, localId); - }); - - session.onCancelQueuedMessage((localId) => { - const removed = messageQueue.cancelByLocalId(localId); - logger.debug(`[gemini] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); - return removed; - }); - - registerSessionConfigRpc({ - rpcHandlerManager: session.rpcHandlerManager, - flavor: 'gemini', - modelMode: 'nullable', - onApply: (config) => { - if (config.permissionMode !== undefined) { - currentPermissionMode = config.permissionMode; - } - if (config.model !== undefined) { - sessionModel = config.model; - resolvedModel = sessionModel ?? machineDefault; - } - }, - onAfterApply: syncSessionMode - }); - - let crashed = false; - - try { - await geminiLoop({ - path: workingDirectory, - startingMode, - startedBy, - messageQueue, - session, - api, - permissionMode: currentPermissionMode, - model: machineDefault, - hookSettingsPath, - resumeSessionId: opts.resumeSessionId, - onModeChange: createModeChangeHandler(session), - onSessionReady: (instance) => { - sessionWrapperRef.current = instance; - syncSessionMode(); - } - }); - } catch (error) { - crashed = true; - lifecycle.markCrash(error); - logger.debug('[gemini] Loop error:', error); - } finally { - const localFailure = sessionWrapperRef.current?.localLaunchFailure; - if (localFailure?.exitReason === 'exit') { - lifecycle.setExitCode(1); - lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); - lifecycle.setSessionEndReason('error'); - } else if (!crashed) { - lifecycle.setSessionEndReason('completed'); - } - await lifecycle.cleanupAndExit(); - } -} diff --git a/cli/src/gemini/session.ts b/cli/src/gemini/session.ts deleted file mode 100644 index 800d5658a1..0000000000 --- a/cli/src/gemini/session.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ApiClient, ApiSessionClient } from '@/lib'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { AgentSessionBase } from '@/agent/sessionBase'; -import type { GeminiMode, PermissionMode } from './types'; -import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; - -type LocalLaunchFailure = { - message: string; - exitReason: LocalLaunchExitReason; -}; - -export class GeminiSession extends AgentSessionBase { - transcriptPath: string | null = null; - readonly startedBy: 'runner' | 'terminal'; - readonly startingMode: 'local' | 'remote'; - localLaunchFailure: LocalLaunchFailure | null = null; - - private transcriptPathCallbacks: Array<(path: string) => void> = []; - - constructor(opts: { - api: ApiClient; - client: ApiSessionClient; - path: string; - logPath: string; - sessionId: string | null; - messageQueue: MessageQueue2; - onModeChange: (mode: 'local' | 'remote') => void; - mode?: 'local' | 'remote'; - startedBy: 'runner' | 'terminal'; - startingMode: 'local' | 'remote'; - permissionMode?: PermissionMode; - }) { - super({ - api: opts.api, - client: opts.client, - path: opts.path, - logPath: opts.logPath, - sessionId: opts.sessionId, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: opts.mode, - sessionLabel: 'GeminiSession', - sessionIdLabel: 'Gemini', - applySessionIdToMetadata: (metadata, sessionId) => ({ - ...metadata, - geminiSessionId: sessionId - }), - permissionMode: opts.permissionMode - }); - - this.startedBy = opts.startedBy; - this.startingMode = opts.startingMode; - this.permissionMode = opts.permissionMode; - } - - onTranscriptPathFound(path: string): void { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - for (const callback of this.transcriptPathCallbacks) { - callback(path); - } - } - - addTranscriptPathCallback(cb: (path: string) => void): void { - this.transcriptPathCallbacks.push(cb); - } - - removeTranscriptPathCallback(cb: (path: string) => void): void { - const index = this.transcriptPathCallbacks.indexOf(cb); - if (index !== -1) { - this.transcriptPathCallbacks.splice(index, 1); - } - } - - setPermissionMode = (mode: PermissionMode): void => { - this.permissionMode = mode; - }; - - setModel = (model: string | null): void => { - this.model = model; - }; - - recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { - this.localLaunchFailure = { message, exitReason }; - }; - - sendAgentMessage = (message: unknown): void => { - this.client.sendAgentMessage(message); - }; - - sendUserMessage = (text: string): void => { - this.client.sendUserMessage(text); - }; - - sendSessionEvent = (event: Parameters[0]): void => { - this.client.sendSessionEvent(event); - }; -} diff --git a/cli/src/gemini/types.ts b/cli/src/gemini/types.ts deleted file mode 100644 index 9f361b5876..0000000000 --- a/cli/src/gemini/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GeminiPermissionMode } from '@hapi/protocol/types'; - -export type PermissionMode = GeminiPermissionMode; - -export interface GeminiMode { - permissionMode: PermissionMode; - model?: string; -} diff --git a/cli/src/gemini/utils/config.ts b/cli/src/gemini/utils/config.ts deleted file mode 100644 index 4426f52c46..0000000000 --- a/cli/src/gemini/utils/config.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { logger } from '@/ui/logger'; -import { DEFAULT_GEMINI_MODEL } from '@hapi/protocol'; - -export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; -export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY'; -export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; -export { DEFAULT_GEMINI_MODEL }; - -export type GeminiLocalConfig = { - token?: string; - model?: string; -}; - -export type GeminiModelSource = 'explicit' | 'env' | 'local' | 'default'; - -const GEMINI_DIR = join(homedir(), '.gemini'); -const SETTINGS_PATH = join(GEMINI_DIR, 'settings.json'); -const CONFIG_PATH = join(GEMINI_DIR, 'config.json'); -const OAUTH_PATH = join(GEMINI_DIR, 'oauth_creds.json'); - -function readJsonFile(path: string): Record | null { - if (!existsSync(path)) { - return null; - } - - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch (error) { - logger.debug(`[gemini-config] Failed to read ${path}: ${error}`); - } - - return null; -} - -function extractModel(settings: Record): string | undefined { - const modelEntry = settings.model; - if (modelEntry && typeof modelEntry === 'object') { - const name = (modelEntry as Record).name; - if (typeof name === 'string' && name.trim().length > 0) { - return name.trim(); - } - } - - const model = settings.model; - if (typeof model === 'string' && model.trim().length > 0) { - return model.trim(); - } - - return undefined; -} - -function extractToken(settings: Record): string | undefined { - const tokenKeys = ['access_token', 'token', 'apiKey', GEMINI_API_KEY_ENV, GOOGLE_API_KEY_ENV]; - for (const key of tokenKeys) { - const value = settings[key]; - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return undefined; -} - -export function readGeminiLocalConfig(): GeminiLocalConfig { - const settingsFile = readJsonFile(SETTINGS_PATH); - const configFile = readJsonFile(CONFIG_PATH); - const oauthFile = readJsonFile(OAUTH_PATH); - - const model = settingsFile ? extractModel(settingsFile) : undefined; - const token = oauthFile - ? extractToken(oauthFile) - : configFile - ? extractToken(configFile) - : undefined; - - return { - model, - token - }; -} - -export function resolveGeminiRuntimeConfig(opts: { - model?: string; - token?: string; -} = {}): { model: string; token?: string; modelSource: GeminiModelSource } { - const local = readGeminiLocalConfig(); - - let modelSource: GeminiModelSource = 'default'; - let model: string = DEFAULT_GEMINI_MODEL; - - if (opts.model) { - model = opts.model; - modelSource = 'explicit'; - } else if (process.env[GEMINI_MODEL_ENV]) { - model = process.env[GEMINI_MODEL_ENV]!; - modelSource = 'env'; - } else if (local.model) { - model = local.model; - modelSource = 'local'; - } - - const token = opts.token - ?? process.env[GEMINI_API_KEY_ENV] - ?? process.env[GOOGLE_API_KEY_ENV] - ?? local.token; - - return { model, token, modelSource }; -} - -export function buildGeminiEnv(opts: { - model?: string; - token?: string; - hookSettingsPath?: string; - cwd?: string; -}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...process.env - }; - - if (opts.model) { - env[GEMINI_MODEL_ENV] = opts.model; - } - - if (opts.token && !env[GEMINI_API_KEY_ENV] && !env[GOOGLE_API_KEY_ENV]) { - env[GEMINI_API_KEY_ENV] = opts.token; - } - - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - if (opts.cwd) { - env.GEMINI_PROJECT_DIR = opts.cwd; - } - - return env; -} diff --git a/cli/src/gemini/utils/geminiBackend.ts b/cli/src/gemini/utils/geminiBackend.ts deleted file mode 100644 index 392f8fc9a6..0000000000 --- a/cli/src/gemini/utils/geminiBackend.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AcpSdkBackend } from '@/agent/backends/acp'; -import { buildGeminiEnv, resolveGeminiRuntimeConfig } from './config'; - -function filterEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -export function createGeminiBackend(opts: { - model?: string; - token?: string; - resumeSessionId?: string | null; - hookSettingsPath?: string; - cwd?: string; - permissionMode?: string; -}): AcpSdkBackend { - const { model, token } = resolveGeminiRuntimeConfig({ - model: opts.model, - token: opts.token - }); - - const args = ['--experimental-acp']; - if (opts.resumeSessionId) { - args.push('--resume', opts.resumeSessionId); - } - if (model) { - args.push('--model', model); - } - if (opts.permissionMode === 'yolo' || opts.permissionMode === 'safe-yolo') { - args.push('--yolo'); - } - - const env = buildGeminiEnv({ - model, - token, - hookSettingsPath: opts.hookSettingsPath, - cwd: opts.cwd - }); - - return new AcpSdkBackend({ - command: 'gemini', - args, - env: filterEnv(env) - }); -} diff --git a/cli/src/gemini/utils/permissionHandler.ts b/cli/src/gemini/utils/permissionHandler.ts deleted file mode 100644 index a73629f981..0000000000 --- a/cli/src/gemini/utils/permissionHandler.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { ApiSessionClient } from '@/api/apiSession'; -import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; -import type { GeminiPermissionMode } from '@hapi/protocol/types'; -import { deriveToolName } from '@/agent/utils'; -import { logger } from '@/ui/logger'; -import { - BasePermissionHandler, - type AutoApprovalDecision, - type PendingPermissionRequest, - type PermissionCompletion -} from '@/modules/common/permission/BasePermissionHandler'; - -interface PermissionResponseMessage { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; - reason?: string; -} - -function deriveToolInput(request: PermissionRequest): unknown { - if (request.rawInput !== undefined) { - return request.rawInput; - } - return request.rawOutput; -} - -function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { - for (const kind of preferredKinds) { - const match = request.options.find((option) => option.kind === kind); - if (match) { - return match.optionId; - } - } - return request.options.length > 0 ? request.options[0].optionId : null; -} - -function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse { - if (decision === 'abort') { - return { outcome: 'cancelled' }; - } - - if (decision === 'approved_for_session') { - const optionId = pickOptionId(request, ['allow_always', 'allow_once']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - if (decision === 'approved') { - const optionId = pickOptionId(request, ['allow_once', 'allow_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - const optionId = pickOptionId(request, ['reject_once', 'reject_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; -} - -export class GeminiPermissionHandler extends BasePermissionHandler { - private readonly pendingBackendRequests = new Map(); - - constructor( - session: ApiSessionClient, - private readonly backend: AgentBackend, - private readonly getPermissionMode: () => GeminiPermissionMode | undefined - ) { - super(session); - this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); - } - - private handlePermissionRequest(request: PermissionRequest): void { - const toolName = deriveToolName({ - title: request.title, - kind: request.kind, - rawInput: request.rawInput - }); - const toolInput = deriveToolInput(request); - const mode = this.getPermissionMode() ?? 'default'; - - const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); - if (autoDecision) { - void this.autoApprove(request, toolName, toolInput, autoDecision); - return; - } - - this.pendingBackendRequests.set(request.id, request); - this.addPendingRequest(request.id, toolName, toolInput, { - resolve: () => {}, - reject: () => {} - }); - - logger.debug(`[Gemini] Permission request queued for ${toolName} (${request.id})`); - } - - private async autoApprove( - request: PermissionRequest, - toolName: string, - toolInput: unknown, - decision: AutoApprovalDecision - ): Promise { - const outcome = mapDecisionToOutcome(request, decision); - await this.backend.respondToPermission(request.sessionId, request, outcome); - - this.client.updateAgentState((currentState) => ({ - ...currentState, - completedRequests: { - ...currentState.completedRequests, - [request.id]: { - tool: toolName, - arguments: toolInput, - createdAt: Date.now(), - completedAt: Date.now(), - status: 'approved', - decision - } - } - })); - - logger.debug(`[Gemini] Auto-approved ${toolName} (${request.id}) mode=${decision}`); - } - - protected async handlePermissionResponse( - response: PermissionResponseMessage, - pending: PendingPermissionRequest - ): Promise { - const pendingRequest = this.pendingBackendRequests.get(response.id); - if (pendingRequest) { - this.pendingBackendRequests.delete(response.id); - } else { - logger.debug('[Gemini] Permission response missing backend request', response.id); - } - - const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); - - if (decision === 'abort' && pendingRequest) { - await this.backend.cancelPrompt(pendingRequest.sessionId); - } - - if (pendingRequest) { - const outcome = mapDecisionToOutcome(pendingRequest, decision); - await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); - } - - pending.resolve(); - - logger.debug(`[Gemini] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - - return { - status: response.approved ? 'approved' : 'denied', - decision, - reason: response.reason - }; - } - - protected handleMissingPendingResponse(response: PermissionResponseMessage): void { - logger.debug('[Gemini] Permission response received for unknown request', response.id); - } - - async cancelAll(reason: string): Promise { - const pending = Array.from(this.pendingBackendRequests.values()); - this.pendingBackendRequests.clear(); - - for (const request of pending) { - await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); - } - - this.cancelPendingRequests({ - completedReason: reason, - rejectMessage: reason, - decision: 'abort' - }); - } -} diff --git a/cli/src/gemini/utils/sessionScanner.ts b/cli/src/gemini/utils/sessionScanner.ts deleted file mode 100644 index a33b014a45..0000000000 --- a/cli/src/gemini/utils/sessionScanner.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { logger } from '@/ui/logger'; -import { - BaseSessionScanner, - SessionFileScanEntry, - SessionFileScanResult, - SessionFileScanStats -} from '@/modules/common/session/BaseSessionScanner'; - -type GeminiTranscriptMessage = { - id?: string; - type?: string; - content?: string; - [key: string]: unknown; -}; - -type GeminiTranscript = { - sessionId?: string; - messages?: GeminiTranscriptMessage[]; - [key: string]: unknown; -}; - -export async function createGeminiSessionScanner(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; -}) { - const scanner = new GeminiSessionScanner({ - transcriptPath: opts.transcriptPath, - onMessage: opts.onMessage, - onSessionId: opts.onSessionId - }); - - await scanner.start(); - - return { - cleanup: async () => { - await scanner.cleanup(); - }, - onNewSession: (transcriptPath: string) => { - void scanner.setTranscriptPath(transcriptPath); - } - }; -} - -class GeminiSessionScanner extends BaseSessionScanner { - private transcriptPath: string | null; - private readonly onMessage: (message: GeminiTranscriptMessage) => void; - private readonly onSessionId?: (sessionId: string) => void; - private observedSessionId: string | null = null; - - constructor(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; - }) { - super({ intervalMs: 2000 }); - this.transcriptPath = opts.transcriptPath; - this.onMessage = opts.onMessage; - this.onSessionId = opts.onSessionId; - } - - async setTranscriptPath(path: string): Promise { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - await this.primeTranscript(path); - this.invalidate(); - } - - protected async initialize(): Promise { - if (this.transcriptPath) { - await this.primeTranscript(this.transcriptPath); - } - } - - protected async findSessionFiles(): Promise { - if (!this.transcriptPath) { - return []; - } - return [this.transcriptPath]; - } - - protected shouldWatchFile(filePath: string): boolean { - return Boolean(this.transcriptPath && filePath === this.transcriptPath); - } - - protected async parseSessionFile(filePath: string, cursor: number): Promise> { - const transcript = await readTranscript(filePath); - if (!transcript) { - return { events: [], nextCursor: cursor }; - } - - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - let startIndex = cursor; - if (startIndex > messages.length) { - startIndex = 0; - } - - const events: SessionFileScanEntry[] = []; - for (let index = startIndex; index < messages.length; index += 1) { - events.push({ event: messages[index], lineIndex: index }); - } - - return { - events, - nextCursor: messages.length - }; - } - - protected generateEventKey(event: GeminiTranscriptMessage, context: { filePath: string; lineIndex?: number }): string { - if (event.id && event.id.length > 0) { - return `${context.filePath}:${event.id}`; - } - return `${context.filePath}:${context.lineIndex ?? -1}`; - } - - protected async handleFileScan(stats: SessionFileScanStats): Promise { - for (const message of stats.events) { - this.onMessage(message); - } - if (stats.newCount > 0) { - logger.debug(`[gemini-session-scanner] ${stats.newCount} new messages from ${stats.filePath}`); - } - this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []); - } - - private updateSessionId(sessionId: string | undefined): void { - if (!sessionId || sessionId.length === 0) { - return; - } - if (this.observedSessionId === sessionId) { - return; - } - this.observedSessionId = sessionId; - this.onSessionId?.(sessionId); - } - - private async primeTranscript(filePath: string): Promise { - const transcript = await readTranscript(filePath); - if (!transcript) { - return; - } - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - const keys = messages.map((message, index) => this.generateEventKey(message, { filePath, lineIndex: index })); - this.seedProcessedKeys(keys); - this.setCursor(filePath, messages.length); - } -} - -async function readTranscript(filePath: string): Promise { - try { - const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return null; - } - const record = parsed as Record; - const messages = Array.isArray(record.messages) - ? record.messages.filter((value): value is GeminiTranscriptMessage => Boolean(value && typeof value === 'object')) - : []; - const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined; - return { - sessionId, - messages - }; - } catch (error) { - logger.debug(`[gemini-session-scanner] Failed to read transcript ${filePath}: ${error}`); - return null; - } -} diff --git a/cli/src/grok/grokLocal.test.ts b/cli/src/grok/grokLocal.test.ts new file mode 100644 index 0000000000..73706f3c67 --- /dev/null +++ b/cli/src/grok/grokLocal.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { buildGrokLocalArgs } from './grokLocal' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function setWindowsPlatform(): void { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) +} + +describe('buildGrokLocalArgs', () => { + it('uses a caller-supplied id for a new native session', () => { + expect(buildGrokLocalArgs({ + sessionId: '11111111-1111-4111-8111-111111111111', + resume: false + })).toEqual([ + '--session-id', '11111111-1111-4111-8111-111111111111' + ]) + }) + + it('resumes with low effort and plan mode using official Grok flags', () => { + expect(buildGrokLocalArgs({ + sessionId: 'grok-session-1', + resume: true, + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + })).toEqual([ + '--resume', 'grok-session-1', + '--model', 'grok-4.5', + '--reasoning-effort', 'low', + '--permission-mode', 'plan' + ]) + }) + + it('rejects shell metacharacters in dynamic Windows arguments', () => { + setWindowsPlatform() + + expect(() => buildGrokLocalArgs({ + sessionId: 'session&whoami', + resume: true + })).toThrow('Invalid sessionId') + expect(() => buildGrokLocalArgs({ + sessionId: 'session-1', + resume: true, + model: 'grok|whoami' + })).toThrow('Invalid model') + expect(() => buildGrokLocalArgs({ + sessionId: 'session-1', + resume: true, + effort: 'low%PATH%' + })).toThrow('Invalid effort') + }) +}) diff --git a/cli/src/grok/grokLocal.ts b/cli/src/grok/grokLocal.ts new file mode 100644 index 0000000000..0f5b853ee9 --- /dev/null +++ b/cli/src/grok/grokLocal.ts @@ -0,0 +1,59 @@ +import { logger } from '@/ui/logger' +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard' +import type { PermissionMode } from './types' +import { assertSafeWindowsShellArg } from './utils/windowsShellArgs' + +type GrokLocalOptions = { + sessionId: string + resume: boolean + model?: string + effort?: string + permissionMode?: PermissionMode +} + +export function buildGrokLocalArgs(opts: GrokLocalOptions): string[] { + assertSafeWindowsShellArg(opts.sessionId, 'sessionId') + if (opts.model) assertSafeWindowsShellArg(opts.model, 'model') + if (opts.effort) assertSafeWindowsShellArg(opts.effort, 'effort') + + const args: string[] = [] + + if (opts.resume) { + args.push('--resume', opts.sessionId) + } else { + args.push('--session-id', opts.sessionId) + } + if (opts.model) { + args.push('--model', opts.model) + } + if (opts.effort) { + args.push('--reasoning-effort', opts.effort) + } + if (opts.permissionMode && opts.permissionMode !== 'default') { + args.push('--permission-mode', opts.permissionMode) + } + return args +} + +export async function grokLocal(opts: GrokLocalOptions & { + path: string + abort: AbortSignal +}): Promise { + const args = buildGrokLocalArgs(opts) + + logger.debug(`[GrokLocal] Spawning grok with args: ${JSON.stringify(args)}`) + + await spawnWithTerminalGuard({ + command: 'grok', + args, + cwd: opts.path, + env: process.env, + signal: opts.abort, + shell: process.platform === 'win32', + logLabel: 'GrokLocal', + spawnName: 'grok', + installHint: 'Grok Build CLI (https://docs.x.ai/build/overview)', + includeCause: true, + logExit: true + }) +} diff --git a/cli/src/grok/grokLocalLauncher.ts b/cli/src/grok/grokLocalLauncher.ts new file mode 100644 index 0000000000..c2b27fab23 --- /dev/null +++ b/cli/src/grok/grokLocalLauncher.ts @@ -0,0 +1,42 @@ +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher' +import { grokLocal } from './grokLocal' +import type { GrokSession } from './session' +import type { PermissionMode } from './types' + +export async function grokLocalLauncher( + session: GrokSession, + opts: { model?: string; effort?: string } +): Promise<'switch' | 'exit'> { + const launcher = new BaseLocalLauncher({ + label: 'grok-local', + failureLabel: 'Local Grok process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + if (!session.sessionId) { + throw new Error('Grok session id is missing') + } + const resume = session.shouldResumeNativeSession() + session.markNativeSessionStarted() + await grokLocal({ + path: session.path, + sessionId: session.sessionId, + resume, + abort: abortSignal, + model: opts.model, + effort: opts.effort, + permissionMode: session.getPermissionMode() as PermissionMode | undefined + }) + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }) + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason) + } + }) + + return await launcher.run() +} diff --git a/cli/src/grok/grokRemoteLauncher.test.ts b/cli/src/grok/grokRemoteLauncher.test.ts new file mode 100644 index 0000000000..726d00fbdd --- /dev/null +++ b/cli/src/grok/grokRemoteLauncher.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import type { GrokMode } from './types' + +const harness = vi.hoisted(() => ({ + setModels: [] as Array<{ sessionId: string; modelId: string; flavor?: string }>, + setModes: [] as Array<{ sessionId: string; modeId: string }>, + prompts: [] as unknown[][], + autoCommandAvailable: true, + stderrHandler: null as null | ((error: { message: string; raw: string }) => void), + sessionInfoUpdateHandler: null as null | ((update: { title?: string | null }) => void), + nativeTitle: null as string | null, + nativeTitleSent: false, +})) + +vi.mock('./utils/grokBackend', () => ({ + createGrokBackend: vi.fn(() => ({ + initialize: vi.fn(async () => {}), + newSession: vi.fn(async () => 'grok-session-1'), + loadSession: vi.fn(async () => 'grok-session-1'), + setModel: vi.fn(async (sessionId: string, modelId: string, opts?: { flavor?: string }) => { + harness.setModels.push({ sessionId, modelId, flavor: opts?.flavor }) + }), + setMode: vi.fn(async (sessionId: string, modeId: string) => { + harness.setModes.push({ sessionId, modeId }) + }), + prompt: vi.fn(async (_sessionId: string, content: unknown[]) => { + harness.prompts.push(content) + if (harness.nativeTitle !== null && !harness.nativeTitleSent) { + harness.nativeTitleSent = true + harness.sessionInfoUpdateHandler?.({ title: harness.nativeTitle }) + } + if (harness.prompts.length === 1) { + harness.stderrHandler?.({ + message: 'status=402 Payment Required model_id=grok-build spending-limit', + raw: 'status=402 Payment Required model_id=grok-build spending-limit' + }) + } + }), + cancelPrompt: vi.fn(async () => {}), + respondToPermission: vi.fn(async () => {}), + onStderrError: vi.fn((handler) => { harness.stderrHandler = handler }), + setSessionInfoUpdateListener: vi.fn((handler) => { harness.sessionInfoUpdateHandler = handler }), + onPermissionRequest: vi.fn(), + disconnect: vi.fn(async () => {}), + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'grok-a' }, { modelId: 'grok-b' }], + currentModelId: 'grok-a' + })), + getThoughtLevelConfigOption: vi.fn(() => ({ + id: 'x.ai/reasoning-effort', + currentValue: 'low', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] + })), + hasAvailableCommand: vi.fn((_sessionId: string, command: string) => ( + command === 'auto' && harness.autoCommandAvailable + )) + })), + formatGrokError: (error: unknown) => error instanceof Error ? error.message : String(error), + isGrokBuildAuxiliaryQuotaError: (value: string, activeModel?: string | null) => ( + activeModel !== 'grok-build' + && value.includes('402 Payment Required') + && value.includes('model_id=grok-build') + ) +})) + +vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ + buildHapiMcpBridge: async () => ({ server: { stop: () => {} }, mcpServers: {} }) +})) +vi.mock('./utils/permissionHandler', () => ({ + GrokPermissionHandler: class { async cancelAll(): Promise {} } +})) +vi.mock('@/ui/ink/GrokDisplay', () => ({ GrokDisplay: () => null })) +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() } +})) + +import { grokRemoteLauncher } from './grokRemoteLauncher' + +function createSession() { + const queue = new MessageQueue2((mode) => JSON.stringify(mode)) + queue.pushIsolateAndClear('first', { permissionMode: 'default', model: 'grok-a', effort: 'low' }) + queue.push('second', { permissionMode: 'default', model: 'grok-b', effort: 'medium' }) + queue.close() + const rpcHandlers = new Map unknown>() + const session = { + path: '/tmp/grok-test', + logPath: '/tmp/grok-test/test.log', + client: { + rpcHandlerManager: { + registerHandler(method: string, handler: () => unknown) { rpcHandlers.set(method, handler) } + }, + sendAgentMessage: vi.fn(), + sendSessionEvent: vi.fn(), + sendClaudeSessionMessage: vi.fn() + }, + queue, + sessionId: null as string | null, + thinking: false, + getPermissionMode: () => 'default' as const, + registerExistingNativeSession(id: string) { session.sessionId = id }, + setModel: vi.fn(), + setEffort: vi.fn(), + setPermissionMode: vi.fn(), + pushKeepAlive: vi.fn(), + onThinkingChange(thinking: boolean) { session.thinking = thinking }, + sendAgentMessage: vi.fn(), + sendSessionEvent: vi.fn() + } + return { session, rpcHandlers } +} + +function createPermissionSession(modes: GrokMode['permissionMode'][]) { + const { session, rpcHandlers } = createSession() + session.queue.reset() + modes.forEach((permissionMode, index) => { + session.queue.push(`permission-${index + 1}`, { + permissionMode, + model: 'grok-a', + effort: 'low' + }) + }) + session.queue.close() + return { session, rpcHandlers } +} + +describe('grokRemoteLauncher runtime config', () => { + afterEach(() => { + harness.setModels = [] + harness.setModes = [] + harness.prompts = [] + harness.stderrHandler = null + harness.sessionInfoUpdateHandler = null + harness.nativeTitle = null + harness.nativeTitleSent = false + harness.autoCommandAvailable = true + }) + + it('switches model and effort between turns and exposes session catalogs', async () => { + const { session, rpcHandlers } = createSession() + const discovered: unknown[] = [] + + await grokRemoteLauncher(session as never, { + model: 'grok-a', + effort: 'low', + onConfigDiscovered: (config) => discovered.push(config) + }) + + expect(discovered).toEqual([{ model: 'grok-a', effort: 'low' }]) + expect(harness.setModels).toEqual([ + { sessionId: 'grok-session-1', modelId: 'grok-b', flavor: 'grok' } + ]) + expect(harness.setModes).toEqual([ + { sessionId: 'grok-session-1', modeId: 'medium' } + ]) + expect(harness.prompts).toHaveLength(3) + expect(session.sendSessionEvent).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('402 Payment Required') + })) + expect(JSON.stringify(harness.prompts[0])).toContain('/always-approve off') + expect(JSON.stringify(harness.prompts[1])).toContain('hapi_change_title') + expect(JSON.stringify(harness.prompts[2])).not.toContain('hapi_change_title') + expect(await rpcHandlers.get('listGrokModels')?.()).toMatchObject({ success: true, currentModelId: 'grok-a' }) + expect(await rpcHandlers.get('listGrokReasoningEffortOptions')?.()).toMatchObject({ success: true, currentValue: 'low' }) + }) + + it('uses Grok slash commands to enter and leave Auto permission mode without model turns', async () => { + const { session } = createPermissionSession(['auto', 'default']) + + await grokRemoteLauncher(session as never, { model: 'grok-a', effort: 'low' }) + + expect(harness.prompts.map((prompt) => JSON.stringify(prompt))).toEqual([ + expect.stringContaining('/auto'), + expect.stringContaining('permission-1'), + expect.stringContaining('/always-approve off'), + expect.stringContaining('permission-2') + ]) + }) + + it('forwards ACP native titles while retaining the prompt fallback', async () => { + harness.nativeTitle = 'Native Grok title' + const { session } = createSession() + + await grokRemoteLauncher(session as never, { model: 'grok-a', effort: 'low' }) + + expect(session.client.sendClaudeSessionMessage).toHaveBeenCalledWith({ + type: 'summary', + summary: 'Native Grok title', + leafUuid: expect.any(String) + }) + expect(JSON.stringify(harness.prompts[1])).toContain('hapi_change_title') + }) + + it('rolls Auto back to Default when Grok does not advertise the feature', async () => { + harness.autoCommandAvailable = false + const { session } = createPermissionSession(['auto']) + const rollbacks: string[] = [] + + await grokRemoteLauncher(session as never, { + model: 'grok-a', + effort: 'low', + onPermissionModeRollback: (mode) => rollbacks.push(mode) + }) + + expect(rollbacks).toEqual(['default']) + expect(harness.prompts).toHaveLength(2) + expect(JSON.stringify(harness.prompts[0])).toContain('/always-approve off') + expect(session.sendSessionEvent).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('not enabled') + })) + }) +}) diff --git a/cli/src/grok/grokRemoteLauncher.ts b/cli/src/grok/grokRemoteLauncher.ts new file mode 100644 index 0000000000..f132267670 --- /dev/null +++ b/cli/src/grok/grokRemoteLauncher.ts @@ -0,0 +1,432 @@ +import { randomUUID } from 'node:crypto' +import React from 'react' +import { logger } from '@/ui/logger' +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge' +import { convertAgentMessage } from '@/agent/messageConverter' +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types' +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase' +import { GrokDisplay } from '@/ui/ink/GrokDisplay' +import type { GrokSession } from './session' +import type { PermissionMode } from './types' +import { + createGrokBackend, + formatGrokError, + isGrokBuildAuxiliaryQuotaError +} from './utils/grokBackend' +import { GrokPermissionHandler } from './utils/permissionHandler' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { GROK_TITLE_INSTRUCTION } from './utils/systemPrompt' + +const PLAN_MODE_INSTRUCTION = + 'Work in plan-only mode. Analyze and propose a plan, but do not execute commands or modify files.' + +type GrokRemoteLauncherOptions = { + model?: string + effort?: string + onModelRollback?: (model: string | null) => void + onEffortRollback?: (effort: string | null) => void + onPermissionModeRollback?: (mode: PermissionMode) => void + onConfigDiscovered?: (config: { model: string | null; effort: string | null }) => void +} + +class GrokRemoteLauncher extends RemoteLauncherBase { + private backend: ReturnType | null = null + private permissionHandler: GrokPermissionHandler | null = null + private happyServer: { stop: () => void } | null = null + private abortController = new AbortController() + private displayPermissionMode: PermissionMode | null = null + private readonly lastDisplayedToolCall = new Map() + private currentBackendModel: string | null = null + private defaultBackendModel: string | null = null + private currentBackendEffort: string | null = null + private defaultBackendEffort: string | null = null + private currentBackendPermissionMode: 'default' | 'auto' | null = null + private instructionsSent = false + + constructor( + private readonly session: GrokSession, + private readonly opts: GrokRemoteLauncherOptions + ) { + super(process.env.DEBUG ? session.logPath : undefined) + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }) + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(GrokDisplay, context) + } + + protected async runMainLoop(): Promise { + const session = this.session + const { server, mcpServers } = await buildHapiMcpBridge(session.client) + this.happyServer = server + + const backend = createGrokBackend({ + cwd: session.path, + ...this.opts + }) + this.backend = backend + backend.setSessionInfoUpdateListener(({ title }) => { + if (typeof title !== 'string') return + session.client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }) + }) + backend.onStderrError((error) => { + const activeModel = this.currentBackendModel ?? this.opts.model ?? null + if (isGrokBuildAuxiliaryQuotaError(`${error.message}\n${error.raw}`, activeModel)) { + logger.debug('[grok-remote] Suppressed non-fatal grok-build session-title quota error', { + activeModel + }) + return + } + logger.debug('[grok-remote] stderr error', error) + const message = formatGrokError(error.message) + session.sendSessionEvent({ type: 'message', message }) + this.messageBuffer.addMessage(message, 'status') + }) + + await backend.initialize() + + const acpMcpServers = toAcpMcpServers(mcpServers) + let acpSessionId: string + try { + if (session.sessionId) { + try { + acpSessionId = await backend.loadSession({ + sessionId: session.sessionId, + cwd: session.path, + mcpServers: acpMcpServers + }) + } catch (error) { + logger.warn('[grok-remote] resume failed, starting new session', error) + session.sendSessionEvent({ + type: 'message', + message: 'Grok resume failed; starting a new session.' + }) + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }) + } + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }) + } + } catch (error) { + const message = formatGrokError(error) + session.sendSessionEvent({ type: 'message', message }) + throw new Error(message, { cause: error }) + } + + session.registerExistingNativeSession(acpSessionId) + const modelMetadata = backend.getSessionModelsMetadata(acpSessionId) + const effortMetadata = backend.getThoughtLevelConfigOption(acpSessionId) + this.currentBackendModel = modelMetadata?.currentModelId ?? this.opts.model ?? null + this.defaultBackendModel = this.currentBackendModel + this.currentBackendEffort = effortMetadata?.currentValue ?? this.opts.effort ?? null + this.defaultBackendEffort = this.currentBackendEffort + this.opts.onConfigDiscovered?.({ + model: this.currentBackendModel, + effort: this.currentBackendEffort + }) + + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokModels, async () => { + const metadata = backend.getSessionModelsMetadata(acpSessionId) + if (!metadata) return { success: false, error: 'Grok model metadata is not available' } + return { + success: true, + availableModels: metadata.availableModels, + currentModelId: metadata.currentModelId, + autoPermissionModeSupported: backend.hasAvailableCommand(acpSessionId, 'auto') + } + }) + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokReasoningEffortOptions, async () => { + const metadata = backend.getThoughtLevelConfigOption(acpSessionId) + if (!metadata) return { success: false, error: 'Grok effort metadata is not available' } + return { + success: true, + options: metadata.options, + currentValue: metadata.currentValue ?? null + } + }) + this.permissionHandler = new GrokPermissionHandler( + session.client, + backend, + () => session.getPermissionMode() as PermissionMode | undefined + ) + this.applyDisplayMode(session.getPermissionMode() as PermissionMode | undefined) + this.messageBuffer.addMessage(`[MODEL:${this.currentBackendModel ?? 'default'}]`, 'system') + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }) + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal) + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) continue + break + } + + const requestedModel = batch.mode.model === null + ? this.defaultBackendModel + : batch.mode.model + if (requestedModel && requestedModel !== this.currentBackendModel) { + try { + await backend.setModel(acpSessionId, requestedModel, { flavor: 'grok' }) + this.currentBackendModel = requestedModel + batch.mode.model = requestedModel + } catch (error) { + logger.warn('[grok-remote] Inline model switch failed', error) + this.rollbackModel(batch, this.currentBackendModel) + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch Grok model to ${requestedModel}.` + }) + } + } + + const requestedEffort = batch.mode.effort === null + ? this.defaultBackendEffort + : batch.mode.effort + if (requestedEffort && requestedEffort !== this.currentBackendEffort) { + try { + await backend.setMode(acpSessionId, requestedEffort) + this.currentBackendEffort = requestedEffort + batch.mode.effort = requestedEffort + } catch (error) { + logger.warn('[grok-remote] Inline effort switch failed', error) + this.rollbackEffort(batch, this.currentBackendEffort) + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch Grok effort to ${requestedEffort}.` + }) + } + } + + const requestedPermissionMode = batch.mode.permissionMode + try { + await this.syncBackendPermissionMode(acpSessionId, requestedPermissionMode) + } catch (error) { + logger.warn('[grok-remote] Inline permission mode switch failed', error) + const fallbackMode: PermissionMode = this.currentBackendPermissionMode ?? 'default' + this.rollbackPermissionMode(batch, fallbackMode) + session.sendSessionEvent({ + type: 'message', + message: requestedPermissionMode === 'auto' + ? 'Grok Auto permission mode is not enabled for this account or CLI build; using Default.' + : `Failed to switch Grok permission mode to ${requestedPermissionMode}.` + }) + } + + this.applyDisplayMode(batch.mode.permissionMode) + this.messageBuffer.addMessage(batch.message, 'user') + const isSlashCommand = batch.message.trimStart().startsWith('/') + let text = batch.mode.permissionMode === 'plan' && !isSlashCommand + ? `${PLAN_MODE_INSTRUCTION}\n\n${batch.message}` + : batch.message + if (!this.instructionsSent && !isSlashCommand) { + text = `${GROK_TITLE_INSTRUCTION}\n\n${text}` + this.instructionsSent = true + } + const promptContent: PromptContent[] = [{ type: 'text', text }] + + session.onThinkingChange(true) + try { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message) + }) + } catch (error) { + const message = formatGrokError(error) + logger.warn('[grok-remote] prompt failed', error) + session.sendSessionEvent({ type: 'message', message: `Grok prompt failed: ${message}` }) + this.messageBuffer.addMessage(`Grok prompt failed: ${message}`, 'status') + } finally { + session.onThinkingChange(false) + await this.permissionHandler?.cancelAll('Prompt finished') + if (session.queue.size() === 0 && !this.shouldExit) { + session.sendSessionEvent({ type: 'ready' }) + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager) + if (this.permissionHandler) { + await this.permissionHandler.cancelAll('Session ended') + this.permissionHandler = null + } + if (this.backend) { + await this.backend.disconnect() + this.backend = null + } + if (this.happyServer) { + this.happyServer.stop() + this.happyServer = null + } + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message) + if (converted) this.session.sendAgentMessage(converted) + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant') + break + case 'reasoning': + if (!message.live) { + this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system') + } + break + case 'tool_call': { + const previous = this.lastDisplayedToolCall.get(message.id) + if (previous !== message.name) { + this.lastDisplayedToolCall.set(message.id, message.name) + this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool') + } + break + } + case 'tool_result': + this.messageBuffer.addMessage('Tool result received', 'result') + break + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status') + break + case 'error': + this.messageBuffer.addMessage(message.message, 'status') + break + case 'turn_complete': + this.messageBuffer.addMessage('Turn complete', 'status') + break + case 'usage': + break + default: { + const exhaustive: never = message + return exhaustive + } + } + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system') + } + } + + private rollbackModel(batch: { mode: { model?: string | null } }, model: string | null): void { + batch.mode.model = model + this.session.setModel(model) + this.session.pushKeepAlive() + this.opts.onModelRollback?.(model) + } + + private rollbackEffort(batch: { mode: { effort?: string | null } }, effort: string | null): void { + batch.mode.effort = effort + this.session.setEffort(effort) + this.session.pushKeepAlive() + this.opts.onEffortRollback?.(effort) + } + + private rollbackPermissionMode( + batch: { mode: { permissionMode: PermissionMode } }, + permissionMode: PermissionMode + ): void { + batch.mode.permissionMode = permissionMode + this.session.setPermissionMode(permissionMode) + this.session.pushKeepAlive() + this.opts.onPermissionModeRollback?.(permissionMode) + } + + private async syncBackendPermissionMode( + sessionId: string, + permissionMode: PermissionMode + ): Promise { + if (!this.backend) return + + if (permissionMode === 'auto') { + if (this.currentBackendPermissionMode === 'auto') return + if (!this.backend.hasAvailableCommand(sessionId, 'auto')) { + await this.setBackendDefaultPermissionMode(sessionId) + throw new Error('Grok did not advertise the /auto command') + } + await this.backend.prompt( + sessionId, + [{ type: 'text', text: '/auto' }], + () => undefined + ) + this.currentBackendPermissionMode = 'auto' + return + } + + await this.setBackendDefaultPermissionMode(sessionId) + } + + private async setBackendDefaultPermissionMode(sessionId: string): Promise { + if (!this.backend || this.currentBackendPermissionMode === 'default') return + await this.backend.prompt( + sessionId, + [{ type: 'text', text: '/always-approve off' }], + () => undefined + ) + this.currentBackendPermissionMode = 'default' + } + + private async handleAbort(): Promise { + if (this.backend && this.session.sessionId) { + await this.backend.cancelPrompt(this.session.sessionId) + } + await this.permissionHandler?.cancelAll('User aborted') + this.session.queue.reset() + this.session.onThinkingChange(false) + this.abortController.abort() + this.abortController = new AbortController() + this.messageBuffer.addMessage('Turn aborted', 'status') + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()) + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()) + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()) + } +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })) +} + +export async function grokRemoteLauncher( + session: GrokSession, + opts: GrokRemoteLauncherOptions +): Promise<'switch' | 'exit'> { + return new GrokRemoteLauncher(session, opts).launch() +} diff --git a/cli/src/grok/loop.ts b/cli/src/grok/loop.ts new file mode 100644 index 0000000000..f528b6d983 --- /dev/null +++ b/cli/src/grok/loop.ts @@ -0,0 +1,74 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { logger } from '@/ui/logger' +import { runLocalRemoteSession } from '@/agent/loopBase' +import { GrokSession } from './session' +import { grokLocalLauncher } from './grokLocalLauncher' +import { grokRemoteLauncher } from './grokRemoteLauncher' +import { ApiClient, ApiSessionClient } from '@/lib' +import type { GrokMode, PermissionMode } from './types' + +interface GrokLoopOptions { + path: string + hapiSessionId: string + startingMode: 'local' | 'remote' + startedBy: 'runner' | 'terminal' + onModeChange: (mode: 'local' | 'remote') => void + messageQueue: MessageQueue2 + session: ApiSessionClient + api: ApiClient + permissionMode: PermissionMode + model?: string + effort?: string + resumeSessionId?: string + onSessionReady?: (session: GrokSession) => void + onModelRollback?: (model: string | null) => void + onEffortRollback?: (effort: string | null) => void + onPermissionModeRollback?: (mode: PermissionMode) => void + onConfigDiscovered?: (config: { model: string | null; effort: string | null }) => void +} + +export async function grokLoop(opts: GrokLoopOptions): Promise { + const session = new GrokSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath: logger.getLogPath(), + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.startingMode, + startedBy: opts.startedBy, + startingMode: opts.startingMode, + permissionMode: opts.permissionMode, + model: opts.model ?? null, + effort: opts.effort ?? null + }) + + if (opts.resumeSessionId) { + session.registerExistingNativeSession(opts.resumeSessionId) + } else if (opts.startingMode === 'local') { + // Grok accepts a caller-supplied UUID for a new native session. Reusing + // the HAPI session UUID gives local mode a deterministic resume token + // without scraping the fullscreen TUI. + session.registerPendingNativeSession(opts.hapiSessionId) + } + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'grok-loop', + runLocal: (instance) => grokLocalLauncher(instance, { + model: instance.getModel() ?? undefined, + effort: instance.getEffort() ?? undefined + }), + runRemote: (instance) => grokRemoteLauncher(instance, { + model: instance.getModel() ?? undefined, + effort: instance.getEffort() ?? undefined, + onModelRollback: opts.onModelRollback, + onEffortRollback: opts.onEffortRollback, + onPermissionModeRollback: opts.onPermissionModeRollback, + onConfigDiscovered: opts.onConfigDiscovered + }), + onSessionReady: opts.onSessionReady + }) +} diff --git a/cli/src/grok/runGrok.ts b/cli/src/grok/runGrok.ts new file mode 100644 index 0000000000..881002d778 --- /dev/null +++ b/cli/src/grok/runGrok.ts @@ -0,0 +1,159 @@ +import { logger } from '@/ui/logger' +import { grokLoop } from './loop' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { hashObject } from '@/utils/deterministicJson' +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' +import type { AgentState } from '@/api/types' +import type { GrokSession } from './session' +import type { GrokMode, PermissionMode } from './types' +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory' +import { registerLocalHandoffHandler } from '@/agent/localHandoff' +import { + createModeChangeHandler, + createRunnerLifecycle, + setControlledByUser +} from '@/agent/runnerLifecycle' +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc' +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter' +import { getInvokedCwd } from '@/utils/invokedCwd' + +export async function runGrok(opts: { + startedBy?: 'runner' | 'terminal' + startingMode?: 'local' | 'remote' + permissionMode?: PermissionMode + model?: string + effort?: string + resumeSessionId?: string + existingSessionId?: string + workingDirectory?: string +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd() + const startedBy = opts.startedBy ?? 'terminal' + const startingMode: 'local' | 'remote' = opts.startingMode + ?? (startedBy === 'runner' ? 'remote' : 'local') + + logger.debug(`[grok] Starting with options: startedBy=${startedBy}, startingMode=${startingMode}`) + + const initialState: AgentState = { controlledByUser: false } + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'grok', + startedBy, + workingDirectory + }) + : await bootstrapSession({ + flavor: 'grok', + startedBy, + workingDirectory, + agentState: initialState, + model: opts.model, + effort: opts.effort + }) + const { api, session, sessionInfo } = bootstrap + setControlledByUser(session, startingMode) + + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default' + let currentModel = opts.model ?? null + let currentEffort = opts.effort ?? null + const queue = new MessageQueue2((mode) => hashObject(mode)) + const sessionRef: { current: GrokSession | null } = { current: null } + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'grok', + stopKeepAlive: () => sessionRef.current?.stopKeepAlive() + }) + lifecycle.registerProcessHandlers() + registerKillSessionHandler(session.rpcHandlerManager, lifecycle) + registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle) + + const syncSessionMode = () => { + const active = sessionRef.current + if (!active) return + active.setPermissionMode(currentPermissionMode) + active.setModel(currentModel) + active.setEffort(currentEffort) + active.pushKeepAlive() + } + + session.onUserMessage((message, localId) => { + queue.push( + formatMessageWithAttachments(message.content.text, message.content.attachments), + { + permissionMode: currentPermissionMode, + model: currentModel ?? undefined, + effort: currentEffort ?? undefined + }, + localId + ) + }) + session.onCancelQueuedMessage((localId) => queue.cancelByLocalId(localId)) + + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'grok', + modelMode: 'nullable', + modelReasoningEffortMode: 'ignore', + effortMode: 'nullable', + onApply: (config) => { + if (config.permissionMode !== undefined) { + currentPermissionMode = config.permissionMode + } + if (config.model !== undefined) currentModel = config.model + if (config.effort !== undefined) currentEffort = config.effort + }, + onAfterApply: syncSessionMode, + appliedFallback: () => ({ permissionMode: currentPermissionMode }) + }) + + let crashed = false + try { + await grokLoop({ + path: workingDirectory, + hapiSessionId: sessionInfo.id, + startingMode, + startedBy, + messageQueue: queue, + session, + api, + permissionMode: currentPermissionMode, + model: opts.model, + effort: opts.effort, + resumeSessionId: opts.resumeSessionId, + onModelRollback: (model) => { + currentModel = model + }, + onEffortRollback: (effort) => { + currentEffort = effort + }, + onPermissionModeRollback: (permissionMode) => { + currentPermissionMode = permissionMode + }, + onConfigDiscovered: (config) => { + currentModel = config.model + currentEffort = config.effort + syncSessionMode() + }, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionRef.current = instance + syncSessionMode() + } + }) + } catch (error) { + crashed = true + lifecycle.markCrash(error) + logger.debug('[grok] Loop error:', error) + } finally { + const localFailure = sessionRef.current?.localLaunchFailure + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1) + lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`) + lifecycle.setSessionEndReason('error') + } else if (!crashed) { + lifecycle.setSessionEndReason('completed') + } + await lifecycle.cleanupAndExit() + } +} diff --git a/cli/src/grok/session.ts b/cli/src/grok/session.ts new file mode 100644 index 0000000000..c8168c6ae6 --- /dev/null +++ b/cli/src/grok/session.ts @@ -0,0 +1,97 @@ +import { ApiClient, ApiSessionClient } from '@/lib' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { AgentSessionBase } from '@/agent/sessionBase' +import type { GrokMode, PermissionMode } from './types' +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy' + +type LocalLaunchFailure = { + message: string + exitReason: LocalLaunchExitReason +} + +export class GrokSession extends AgentSessionBase { + readonly startedBy: 'runner' | 'terminal' + readonly startingMode: 'local' | 'remote' + localLaunchFailure: LocalLaunchFailure | null = null + private nativeSessionExists: boolean + + constructor(opts: { + api: ApiClient + client: ApiSessionClient + path: string + logPath: string + sessionId: string | null + messageQueue: MessageQueue2 + onModeChange: (mode: 'local' | 'remote') => void + mode?: 'local' | 'remote' + startedBy: 'runner' | 'terminal' + startingMode: 'local' | 'remote' + permissionMode?: PermissionMode + model?: string | null + effort?: string | null + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'GrokSession', + sessionIdLabel: 'Grok', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + grokSessionId: sessionId + }), + permissionMode: opts.permissionMode, + model: opts.model, + effort: opts.effort + }) + + this.startedBy = opts.startedBy + this.startingMode = opts.startingMode + this.nativeSessionExists = opts.sessionId !== null + } + + registerPendingNativeSession = (sessionId: string): void => { + this.onSessionFound(sessionId) + this.nativeSessionExists = false + } + + registerExistingNativeSession = (sessionId: string): void => { + this.onSessionFound(sessionId) + this.nativeSessionExists = true + } + + shouldResumeNativeSession = (): boolean => this.nativeSessionExists + + markNativeSessionStarted = (): void => { + this.nativeSessionExists = true + } + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode + } + + setModel = (model: string | null): void => { + this.model = model + } + + setEffort = (effort: string | null): void => { + this.effort = effort + } + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason } + } + + sendAgentMessage = (message: unknown): void => { + this.client.sendAgentMessage(message) + } + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event) + } +} diff --git a/cli/src/grok/types.ts b/cli/src/grok/types.ts new file mode 100644 index 0000000000..6f9122efb5 --- /dev/null +++ b/cli/src/grok/types.ts @@ -0,0 +1,9 @@ +import type { GrokPermissionMode } from '@hapi/protocol/types' + +export type PermissionMode = GrokPermissionMode + +export interface GrokMode { + permissionMode: PermissionMode + model?: string | null + effort?: string | null +} diff --git a/cli/src/grok/utils/grokBackend.test.ts b/cli/src/grok/utils/grokBackend.test.ts new file mode 100644 index 0000000000..68a911b73f --- /dev/null +++ b/cli/src/grok/utils/grokBackend.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + buildGrokAgentArgs, + formatGrokError, + isGrokBuildAuxiliaryQuotaError +} from './grokBackend' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function setWindowsPlatform(): void { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) +} + +describe('buildGrokAgentArgs', () => { + it('starts the official Grok ACP stdio agent', () => { + expect(buildGrokAgentArgs({ cwd: '/tmp/project' })).toEqual([ + '--cwd', '/tmp/project', 'agent', 'stdio' + ]) + }) + + it('places agent options before the stdio subcommand', () => { + expect(buildGrokAgentArgs({ + cwd: '/tmp/project', + model: 'grok-4.5', + effort: 'low' + })).toEqual([ + '--cwd', '/tmp/project', + 'agent', + '--model', 'grok-4.5', + '--reasoning-effort', 'low', + 'stdio' + ]) + }) + + it('rejects dynamic shell metacharacters before a Windows ACP spawn', () => { + setWindowsPlatform() + + expect(() => buildGrokAgentArgs({ cwd: 'C:\\repo&whoami' })).toThrow('Invalid cwd') + expect(() => buildGrokAgentArgs({ + cwd: 'C:\\repo', + model: 'grok|whoami' + })).toThrow('Invalid model') + expect(() => buildGrokAgentArgs({ + cwd: 'C:\\repo', + effort: 'low%PATH%' + })).toThrow('Invalid effort') + }) +}) + +describe('formatGrokError', () => { + it('turns ACP auth failures into an actionable login hint', () => { + expect(formatGrokError(new Error('Authentication required: no auth method id provided'))) + .toContain('grok login --device-auth') + }) + + it('preserves unrelated Grok errors', () => { + expect(formatGrokError(new Error('Payment Required'))).toBe('Payment Required') + }) + + it('strips terminal color codes from surfaced errors', () => { + expect(formatGrokError('\u001b[31mERROR\u001b[0m Payment Required')) + .toBe('ERROR Payment Required') + }) +}) + +describe('isGrokBuildAuxiliaryQuotaError', () => { + const auxiliary402 = [ + '\u001b[31mERROR\u001b[0m responses API error status=402 Payment Required', + 'personal-team-blocked:spending-limit: You have run out of credits or need a Grok subscription.', + 'model_id=grok-build' + ].join(' ') + + it('recognizes the non-fatal grok-build side request when another model is active', () => { + expect(isGrokBuildAuxiliaryQuotaError(auxiliary402, 'grok-4.5')).toBe(true) + }) + + it('does not hide a quota error when grok-build is the active model', () => { + expect(isGrokBuildAuxiliaryQuotaError(auxiliary402, 'grok-build')).toBe(false) + }) + + it('does not hide unrelated 402 errors', () => { + expect(isGrokBuildAuxiliaryQuotaError( + 'status=402 Payment Required model_id=grok-4.5 spending-limit', + 'grok-4.5' + )).toBe(false) + }) +}) diff --git a/cli/src/grok/utils/grokBackend.ts b/cli/src/grok/utils/grokBackend.ts new file mode 100644 index 0000000000..66d8478365 --- /dev/null +++ b/cli/src/grok/utils/grokBackend.ts @@ -0,0 +1,76 @@ +import { AcpSdkBackend } from '@/agent/backends/acp' +import { assertSafeWindowsShellArg } from './windowsShellArgs' + +const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/g + +function stripAnsi(value: string): string { + return value.replace(ANSI_SGR_PATTERN, '') +} + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value + } + } + return result +} + +export function buildGrokAgentArgs(opts: { cwd: string; model?: string; effort?: string }): string[] { + assertSafeWindowsShellArg(opts.cwd, 'cwd') + if (opts.model) assertSafeWindowsShellArg(opts.model, 'model') + if (opts.effort) assertSafeWindowsShellArg(opts.effort, 'effort') + + // --cwd is a top-level Grok flag and must precede the `agent` subcommand. + // session/new also carries cwd, but setting it at process start ensures + // Grok discovers the correct project rules/plugins before initialization. + const args = ['--cwd', opts.cwd, 'agent'] + if (opts.model) { + args.push('--model', opts.model) + } + if (opts.effort) { + args.push('--reasoning-effort', opts.effort) + } + args.push('stdio') + return args +} + +export function createGrokBackend(opts: { + cwd: string + model?: string + effort?: string +}): AcpSdkBackend { + return new AcpSdkBackend({ + command: 'grok', + args: buildGrokAgentArgs(opts), + env: filterEnv(process.env) + }) +} + +export function formatGrokError(error: unknown): string { + const message = stripAnsi(error instanceof Error ? error.message : String(error)) + if (/authentication required|no auth method id provided/i.test(message)) { + return 'Grok authentication required. Run `grok login --device-auth` on this machine, or configure XAI_API_KEY.' + } + return message +} + +/** + * Grok 0.2.93 generates a session title through its separate + * GROK_SESSION_SUMMARY_MODEL route. Accounts whose catalog only exposes + * grok-4.5 can receive a non-fatal 402 for that default grok-build request + * while the selected grok-4.5 turn continues normally. The prompt rejection, + * if any, is reported separately by backend.prompt(), so suppressing this + * auxiliary stderr line cannot hide a failed main turn. + */ +export function isGrokBuildAuxiliaryQuotaError( + value: string, + activeModel: string | null | undefined +): boolean { + if (!activeModel || activeModel === 'grok-build') return false + const message = stripAnsi(value) + return /(?:status\s*=\s*)?402\s+Payment Required/i.test(message) + && /model_id\s*=\s*grok-build\b/i.test(message) + && /(?:spending-limit|run out of credits|need a Grok subscription)/i.test(message) +} diff --git a/cli/src/grok/utils/permissionHandler.test.ts b/cli/src/grok/utils/permissionHandler.test.ts new file mode 100644 index 0000000000..4e8ad36e14 --- /dev/null +++ b/cli/src/grok/utils/permissionHandler.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ApiSessionClient } from '@/api/apiSession' +import type { AgentState } from '@/api/types' +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types' +import { GrokPermissionHandler } from './permissionHandler' + +vi.mock('@/ui/logger', () => ({ logger: { debug: vi.fn() } })) + +function createHarness(mode: 'default' | 'auto' | 'plan' | 'bypassPermissions') { + let state: AgentState = { requests: {}, completedRequests: {} } + let backendHandler: ((request: PermissionRequest) => void) | null = null + const responses: PermissionResponse[] = [] + const rpcHandlers = new Map Promise | unknown>() + const session = { + rpcHandlerManager: { + registerHandler(method: string, handler: (payload: unknown) => Promise | unknown) { + rpcHandlers.set(method, handler) + } + }, + updateAgentState(handler: (current: AgentState) => AgentState) { + state = handler(state) + } + } as unknown as ApiSessionClient + const backend: AgentBackend = { + async initialize() {}, + async newSession() { return 'session-1' }, + async prompt() {}, + async cancelPrompt() {}, + async respondToPermission(_sessionId, _request, response) { responses.push(response) }, + onPermissionRequest(handler) { backendHandler = handler }, + async disconnect() {} + } + new GrokPermissionHandler(session, backend, () => mode) + return { + state: () => state, + responses, + rpcHandlers, + emit(request: PermissionRequest) { + if (!backendHandler) throw new Error('handler missing') + backendHandler(request) + } + } +} + +function request(): PermissionRequest { + return { + id: 'perm-1', + sessionId: 'session-1', + toolCallId: 'tool-1', + title: 'Shell', + rawInput: { command: 'pwd' }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' }, + { optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' } + ] + } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('GrokPermissionHandler', () => { + it('queues default-mode requests for HAPI approval', () => { + const harness = createHarness('default') + harness.emit(request()) + expect(harness.responses).toEqual([]) + expect(harness.state().requests?.['perm-1']).toMatchObject({ tool: 'Shell' }) + }) + + it('queues dangerous requests that Grok Auto leaves for user approval', () => { + const harness = createHarness('auto') + harness.emit(request()) + expect(harness.responses).toEqual([]) + expect(harness.state().requests?.['perm-1']).toMatchObject({ tool: 'Shell' }) + }) + + it('auto-approves bypassPermissions with the advertised allow-always option', async () => { + const harness = createHarness('bypassPermissions') + harness.emit(request()) + await flush() + expect(harness.responses).toEqual([{ outcome: 'selected', optionId: 'allow-always' }]) + expect(harness.state().completedRequests?.['perm-1']).toMatchObject({ + status: 'approved', + decision: 'approved_for_session' + }) + }) + + it('denies tool execution in plan mode', async () => { + const harness = createHarness('plan') + harness.emit(request()) + await flush() + expect(harness.responses).toEqual([{ outcome: 'selected', optionId: 'reject-once' }]) + expect(harness.state().completedRequests?.['perm-1']).toMatchObject({ + status: 'denied', + decision: 'denied' + }) + }) +}) diff --git a/cli/src/grok/utils/permissionHandler.ts b/cli/src/grok/utils/permissionHandler.ts new file mode 100644 index 0000000000..e1b31bca3b --- /dev/null +++ b/cli/src/grok/utils/permissionHandler.ts @@ -0,0 +1,188 @@ +import type { ApiSessionClient } from '@/api/apiSession' +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types' +import type { GrokPermissionMode } from '@hapi/protocol/types' +import { deriveToolName } from '@/agent/utils' +import { logger } from '@/ui/logger' +import { + BasePermissionHandler, + type AutoApprovalDecision, + type PendingPermissionRequest, + type PermissionCompletion +} from '@/modules/common/permission/BasePermissionHandler' + +interface PermissionResponseMessage { + id: string + approved: boolean + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + reason?: string +} + +function deriveToolInput(request: PermissionRequest): unknown { + return request.rawInput !== undefined ? request.rawInput : request.rawOutput +} + +function pickOptionId( + request: PermissionRequest, + preferredKinds: string[], + options: { fallbackToFirst?: boolean } = {} +): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind) + if (match) return match.optionId + } + if (options.fallbackToFirst === false) return null + return request.options[0]?.optionId ?? null +} + +function mapDecisionToOutcome( + request: PermissionRequest, + decision: PermissionResponseMessage['decision'] +): PermissionResponse { + if (decision === 'abort') return { outcome: 'cancelled' } + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } + } + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } + } + const optionId = pickOptionId(request, ['reject_once', 'reject_always']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } +} + +function planDenial(request: PermissionRequest): PermissionResponse { + const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false }) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } +} + +export class GrokPermissionHandler extends BasePermissionHandler { + private readonly pendingBackendRequests = new Map() + + constructor( + session: ApiSessionClient, + private readonly backend: AgentBackend, + private readonly getPermissionMode: () => GrokPermissionMode | undefined + ) { + super(session) + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)) + } + + private handlePermissionRequest(request: PermissionRequest): void { + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }) + const toolInput = deriveToolInput(request) + const mode = this.getPermissionMode() ?? 'default' + + const autoDecision: AutoApprovalDecision | null = mode === 'bypassPermissions' + ? 'approved_for_session' + : this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId) + if (autoDecision) { + void this.autoApprove(request, toolName, toolInput, autoDecision) + return + } + if (mode === 'plan') { + void this.denyForPlanMode(request, toolName, toolInput) + return + } + + this.pendingBackendRequests.set(request.id, request) + this.addPendingRequest(request.id, toolName, toolInput, { + resolve: () => {}, + reject: () => {} + }) + logger.debug(`[Grok] Permission request queued for ${toolName} (${request.id})`) + } + + private async autoApprove( + request: PermissionRequest, + toolName: string, + toolInput: unknown, + decision: AutoApprovalDecision + ): Promise { + await this.backend.respondToPermission(request.sessionId, request, mapDecisionToOutcome(request, decision)) + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now(), + completedAt: Date.now(), + status: 'approved', + decision + } + } + })) + } + + private async denyForPlanMode( + request: PermissionRequest, + toolName: string, + toolInput: unknown + ): Promise { + const outcome = planDenial(request) + await this.backend.respondToPermission(request.sessionId, request, outcome) + const status = outcome.outcome === 'selected' ? 'denied' : 'canceled' + const timestamp = Date.now() + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: timestamp, + completedAt: timestamp, + status, + reason: 'Plan mode blocks tool execution', + decision: status === 'denied' ? 'denied' : 'abort' + } + } + })) + } + + protected async handlePermissionResponse( + response: PermissionResponseMessage, + pending: PendingPermissionRequest + ): Promise { + const request = this.pendingBackendRequests.get(response.id) + this.pendingBackendRequests.delete(response.id) + const decision = response.decision ?? (response.approved ? 'approved' : 'denied') + + if (decision === 'abort' && request) { + await this.backend.cancelPrompt(request.sessionId) + } + if (request) { + await this.backend.respondToPermission(request.sessionId, request, mapDecisionToOutcome(request, decision)) + } + pending.resolve() + + return { + status: response.approved ? 'approved' : 'denied', + decision, + reason: response.reason + } + } + + protected handleMissingPendingResponse(response: PermissionResponseMessage): void { + logger.debug('[Grok] Permission response received for unknown request', response.id) + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingBackendRequests.values()) + this.pendingBackendRequests.clear() + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }) + } + this.cancelPendingRequests({ + completedReason: reason, + rejectMessage: reason, + decision: 'abort' + }) + } +} diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts new file mode 100644 index 0000000000..c439414160 --- /dev/null +++ b/cli/src/grok/utils/systemPrompt.ts @@ -0,0 +1,2 @@ +export const GROK_TITLE_INSTRUCTION = + 'Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.' diff --git a/cli/src/grok/utils/windowsShellArgs.ts b/cli/src/grok/utils/windowsShellArgs.ts new file mode 100644 index 0000000000..b999870d68 --- /dev/null +++ b/cli/src/grok/utils/windowsShellArgs.ts @@ -0,0 +1,8 @@ +const WINDOWS_SHELL_ARG_PATTERN = /[&|<>^()%!"\r\n]/u + +/** Reject dynamic values before passing them to a Windows `shell: true` spawn. */ +export function assertSafeWindowsShellArg(value: string, label: string): void { + if (process.platform === 'win32' && WINDOWS_SHELL_ARG_PATTERN.test(value)) { + throw new Error(`Invalid ${label}: contains Windows shell metacharacters`) + } +} diff --git a/cli/src/modules/common/grokModels.test.ts b/cli/src/modules/common/grokModels.test.ts new file mode 100644 index 0000000000..9df7b98092 --- /dev/null +++ b/cli/src/modules/common/grokModels.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { buildGrokModelsArgs, parseGrokInitializeModels, parseGrokModelsOutput } from './grokModels' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +describe('Grok model discovery', () => { + test('runs the official model listing command in the selected cwd', () => { + expect(buildGrokModelsArgs('/home/user/project')).toEqual([ + '--cwd', '/home/user/project', 'models' + ]) + }) + + test('rejects shell metacharacters in a Windows discovery cwd', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + + expect(() => buildGrokModelsArgs('C:\\repo&whoami')).toThrow('Invalid cwd') + }) + + test('parses available and default models from Grok Build output', () => { + expect(parseGrokModelsOutput(` +You are logged in with grok.com. + +Default model: grok-4.5 + +Available models: + * grok-4.5 (default) + * custom-fast +`)).toEqual({ + availableModels: [ + { modelId: 'grok-4.5' }, + { modelId: 'custom-fast' } + ], + currentModelId: 'grok-4.5' + }) + }) + + test('parses model names and per-model efforts from ACP initialize metadata', () => { + expect(parseGrokInitializeModels({ + _meta: { + availableCommands: [{ name: 'auto' }], + modelState: { + currentModelId: 'grok-4.5', + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + _meta: { + reasoningEfforts: [ + { value: 'high', label: 'High Effort', default: true }, + { value: 'low', label: 'Low Effort', default: false } + ] + } + }] + } + } + })).toEqual({ + currentModelId: 'grok-4.5', + autoPermissionModeSupported: true, + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [ + { value: 'high', name: 'High Effort', isDefault: true }, + { value: 'low', name: 'Low Effort', isDefault: false } + ] + }] + }) + }) +}) diff --git a/cli/src/modules/common/grokModels.ts b/cli/src/modules/common/grokModels.ts new file mode 100644 index 0000000000..2a4d5747ff --- /dev/null +++ b/cli/src/modules/common/grokModels.ts @@ -0,0 +1,241 @@ +import { spawn } from 'node:child_process' +import { asString, isObject } from '@hapi/protocol' +import type { GrokModelSummary, GrokModelsResponse, GrokReasoningEffortOption } from '@hapi/protocol/apiTypes' +import { AcpStdioTransport } from '@/agent/backends/acp/AcpStdioTransport' +import { assertSafeWindowsShellArg } from '@/grok/utils/windowsShellArgs' +import { getErrorMessage } from './rpcResponses' +import packageJson from '../../../package.json' + +export interface ListGrokModelsForCwdRequest { + cwd?: string +} + +export type ListGrokModelsForCwdResponse = GrokModelsResponse + +interface CacheEntry { + expiresAt: number + response: ListGrokModelsForCwdResponse +} + +const CACHE_TTL_MS = 60_000 +const PROBE_TIMEOUT_MS = 15_000 +const SETTINGS_PROBE_GRACE_MS = 300 +const cache = new Map() +const inflight = new Map>() + +export function buildGrokModelsArgs(cwd: string): string[] { + assertSafeWindowsShellArg(cwd, 'cwd') + return ['--cwd', cwd, 'models'] +} + +export function parseGrokModelsOutput(output: string): { + availableModels: GrokModelSummary[] + currentModelId: string | null +} { + const availableModels: GrokModelSummary[] = [] + const seen = new Set() + let currentModelId: string | null = null + let inAvailableModels = false + + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim() + if (line.startsWith('Default model:')) { + currentModelId = line.slice('Default model:'.length).trim() || null + continue + } + if (line === 'Available models:') { + inAvailableModels = true + continue + } + if (!inAvailableModels || !line.startsWith('*')) continue + + const modelId = line.slice(1).replace(/\s+\(default\)\s*$/, '').trim() + if (!modelId || seen.has(modelId)) continue + seen.add(modelId) + availableModels.push({ modelId }) + } + + if (currentModelId && !seen.has(currentModelId)) { + availableModels.unshift({ modelId: currentModelId }) + } + + return { availableModels, currentModelId } +} + +export function parseGrokInitializeModels(response: unknown): { + availableModels: GrokModelSummary[] + currentModelId: string | null + autoPermissionModeSupported: boolean +} { + if (!isObject(response) || !isObject(response._meta) || !isObject(response._meta.modelState)) { + return { availableModels: [], currentModelId: null, autoPermissionModeSupported: false } + } + const state = response._meta.modelState + const rawCommands = Array.isArray(response._meta.availableCommands) + ? response._meta.availableCommands + : [] + const autoPermissionModeSupported = rawCommands.some( + (entry) => isObject(entry) && asString(entry.name) === 'auto' + ) + const currentModelId = asString(state.currentModelId) + const rawModels = Array.isArray(state.availableModels) ? state.availableModels : [] + const availableModels = rawModels + .filter((entry): entry is Record => isObject(entry)) + .map((entry): GrokModelSummary | null => { + const modelId = asString(entry.modelId) + if (!modelId) return null + const meta = isObject(entry._meta) ? entry._meta : null + const rawEfforts = meta && Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [] + const reasoningEfforts = rawEfforts + .filter((effort): effort is Record => isObject(effort)) + .map((effort): GrokReasoningEffortOption => ({ + value: asString(effort.value) ?? asString(effort.id) ?? '', + name: asString(effort.label) ?? undefined, + isDefault: effort.default === true + })) + .filter((effort) => effort.value.length > 0) + return { + modelId, + name: asString(entry.name) ?? undefined, + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}) + } + }) + .filter((entry): entry is GrokModelSummary => entry !== null) + + return { availableModels, currentModelId, autoPermissionModeSupported } +} + +async function runGrokModelsCliProbe(cwd: string): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('grok', buildGrokModelsArgs(cwd), { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: process.platform === 'win32' + }) + let stdout = '' + let stderr = '' + let settled = false + + const timeout = setTimeout(() => { + if (settled) return + settled = true + child.kill('SIGTERM') + reject(new Error('Grok model discovery timed out')) + }, PROBE_TIMEOUT_MS) + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + if (settled) return + settled = true + clearTimeout(timeout) + reject(error) + }) + child.on('exit', (code) => { + if (settled) return + settled = true + clearTimeout(timeout) + if (code !== 0) { + reject(new Error(stderr.trim() || `grok models exited with code ${code}`)) + return + } + resolve({ success: true, ...parseGrokModelsOutput(stdout) }) + }) + }) +} + +async function runGrokModelsProbe(cwd: string): Promise { + // The primary ACP probe also uses shell mode on Windows through AcpStdioTransport. + assertSafeWindowsShellArg(cwd, 'cwd') + const transport = new AcpStdioTransport({ + command: 'grok', + args: ['--cwd', cwd, 'agent', '--reasoning-effort', 'low', 'stdio'], + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + }) + try { + let resolveAutoPermissionMode: ((supported: boolean) => void) | null = null + const autoPermissionMode = new Promise((resolve) => { + resolveAutoPermissionMode = resolve + }) + transport.onNotification((method, params) => { + if ( + method === '_x.ai/settings/update' + && isObject(params) + && 'auto_permission_mode_enabled' in params + ) { + resolveAutoPermissionMode?.(params.auto_permission_mode_enabled === true) + resolveAutoPermissionMode = null + } + }) + const response = await transport.sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { parameterizedModelPicker: true } + }, + clientInfo: { name: 'hapi-grok-models', version: packageJson.version } + }, { timeoutMs: PROBE_TIMEOUT_MS }) + const parsed = parseGrokInitializeModels(response) + if (parsed.availableModels.length > 0) { + const remoteSupport = await Promise.race([ + autoPermissionMode, + new Promise((resolve) => setTimeout(resolve, SETTINGS_PROBE_GRACE_MS, null)) + ]) + return { + success: true, + ...parsed, + autoPermissionModeSupported: parsed.autoPermissionModeSupported || remoteSupport === true + } + } + } catch { + // Older Grok builds may not expose modelState during initialize. + // Fall back to the stable `grok models` command below. + } finally { + await transport.close().catch(() => undefined) + } + return await runGrokModelsCliProbe(cwd) +} + +export async function listGrokModelsForCwd(cwd: string): Promise { + const trimmed = cwd?.trim() + if (!trimmed) return { success: false, error: 'cwd is required' } + + const cached = cache.get(trimmed) + if (cached && cached.expiresAt > Date.now()) return cached.response + + const running = inflight.get(trimmed) + if (running) return running + + const promise = (async () => { + try { + const response = await runGrokModelsProbe(trimmed) + if (response.success) { + cache.set(trimmed, { expiresAt: Date.now() + CACHE_TTL_MS, response }) + } + return response + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to discover Grok models') + } + } finally { + inflight.delete(trimmed) + } + })() + + inflight.set(trimmed, promise) + return promise +} + +export function _resetGrokModelsCacheForTests(): void { + cache.clear() + inflight.clear() +} diff --git a/cli/src/modules/common/handlers/grokModels.ts b/cli/src/modules/common/handlers/grokModels.ts new file mode 100644 index 0000000000..191329a91b --- /dev/null +++ b/cli/src/modules/common/handlers/grokModels.ts @@ -0,0 +1,23 @@ +import { logger } from '@/ui/logger' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { + listGrokModelsForCwd, + type ListGrokModelsForCwdRequest, + type ListGrokModelsForCwdResponse +} from '../grokModels' +import { getErrorMessage, rpcError } from '../rpcResponses' + +export function registerGrokModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.ListGrokModelsForCwd, + async (data) => { + try { + return await listGrokModelsForCwd(typeof data?.cwd === 'string' ? data.cwd : '') + } catch (error) { + logger.debug('Failed to list Grok models:', error) + return rpcError(getErrorMessage(error, 'Failed to list Grok models')) + } + } + ) +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index b555593af6..4b484349df 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -3,6 +3,7 @@ import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' +import { registerGrokModelHandlers } from './handlers/grokModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -17,6 +18,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerCodexModelHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) + registerGrokModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts index e646df6be2..08813e2e0c 100644 --- a/cli/src/modules/common/skills.test.ts +++ b/cli/src/modules/common/skills.test.ts @@ -95,6 +95,18 @@ describe('listSkills', () => { expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator']) }) + it('lists Grok user and project skills alongside shared .agents skills', async () => { + const repoRoot = join(sandboxDir, 'grok-repo') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(homeDir, '.grok', 'skills', 'grok-user'), 'grok-user', 'Grok user skill') + await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') + await writeSkill(join(repoRoot, '.grok', 'skills', 'grok-project'), 'grok-project', 'Grok project skill') + + const skills = await listSkills(repoRoot, { flavor: 'grok' }) + + expect(skills.map((skill) => skill.name)).toEqual(['grok-project', 'grok-user', 'shared']) + }) + it('scopes user skills to the requested flavor', async () => { await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') await writeSkill(join(homeDir, '.claude', 'skills', 'claude-only'), 'claude-only', 'Claude skill') diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts index 47b169da6f..a9db9e444f 100644 --- a/cli/src/modules/common/skills.ts +++ b/cli/src/modules/common/skills.ts @@ -43,6 +43,8 @@ function getAgentConfigDir(flavor?: string): string { return process.env.CLAUDE_CONFIG_DIR || join(getHomeDirectory(), '.claude'); case 'codex': return process.env.CODEX_HOME || join(getHomeDirectory(), '.codex'); + case 'grok': + return process.env.GROK_HOME || join(getHomeDirectory(), '.grok'); default: return join(getHomeDirectory(), `.${normalizedFlavor}`); } @@ -58,6 +60,9 @@ function getUserSkillsRoots(flavor?: string): string[] { case 'codex': roots.push(join(getAgentConfigDir(flavor), 'skills')); break; + case 'grok': + roots.push(join(getAgentConfigDir(flavor), 'skills')); + break; } return roots; } @@ -75,6 +80,9 @@ function getProjectSkillsRoots(directory: string, flavor?: string): string[] { case 'codex': roots.push(join(directory, '.codex', 'skills')); break; + case 'grok': + roots.push(join(directory, '.grok', 'skills')); + break; } return roots; } diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 5a11acefe5..2b4171c5ef 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -220,6 +220,25 @@ describe('listSlashCommands', () => { }) }) + it('exposes only Grok ACP-native built-ins', async () => { + const commands = await listSlashCommands('grok', projectDir) + const builtinNames = commands + .filter((command) => command.source === 'builtin') + .map((command) => command.name) + + expect(builtinNames).toEqual([ + 'compact', + 'context', + 'session-info', + 'goal', + 'always-approve', + 'auto', + ]) + for (const composerControl of ['model', 'effort', 'plan', 'view-plan']) { + expect(builtinNames).not.toContain(composerControl) + } + }) + it('lets project opencode prompts override same-name built-ins', async () => { await writeFile( join(projectDir, '.opencode', 'command', 'status.md'), diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 32217cb7db..1d264bbbec 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -69,6 +69,8 @@ function getUserCommandsDir(agent: string): string | null { const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); return join(xdgConfigHome, 'opencode', 'command'); } + case 'grok': + return join(homedir(), '.agents', 'commands'); default: // Gemini and other agents don't have user commands return null; @@ -87,6 +89,8 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null return join(projectDir, '.codex', 'prompts'); case 'opencode': return join(projectDir, '.opencode', 'command'); + case 'grok': + return join(projectDir, '.grok', 'commands'); default: // Gemini and other agents don't have project commands return null; diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index b49a345596..7776cd6c2b 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -21,6 +21,7 @@ export function createOpencodeBackend(opts: { return new AcpSdkBackend({ command: 'opencode', args, - env: filterEnv(env) + env: filterEnv(env), + textChunkMode: 'delta' }); } diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts index 3532265af2..358b441313 100644 --- a/cli/src/pi/schemas.ts +++ b/cli/src/pi/schemas.ts @@ -17,20 +17,20 @@ import type { PiModelSummary } from '@hapi/protocol/apiTypes'; // 字段级容错 schema // ============================================================================ -/** 提取 string 值,非 string 返回 undefined */ -const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); +/** 提取 string 值,非 string 或缺失返回 undefined */ +const asOptStr = z.unknown().optional().transform(v => typeof v === 'string' ? v : undefined); -/** 提取 number 值,非 number 返回 undefined */ -const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); +/** 提取 number 值,非 number 或缺失返回 undefined */ +const asOptNum = z.unknown().optional().transform(v => typeof v === 'number' ? v : undefined); -/** 提取 boolean 值,非 boolean 返回 undefined */ -const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); +/** 提取 boolean 值,非 boolean 或缺失返回 undefined */ +const asOptBool = z.unknown().optional().transform(v => typeof v === 'boolean' ? v : undefined); -/** 提取 string 值,非 string 返回指定默认值 */ -const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); +/** 提取 string 值,非 string 或缺失返回指定默认值 */ +const asStrOrDef = (def: string) => z.unknown().optional().transform(v => typeof v === 'string' ? v : def); -/** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ -const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { +/** 提取合法的 thinkingLevelMap,非法结构或缺失返回 undefined */ +const asOptThinkingLevelMap = z.unknown().optional().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)) { @@ -80,7 +80,7 @@ const PiCommandSummarySchema = z.object({ const PiCommandEntrySchema = z.object({ name: asStrOrDef(''), description: asOptStr, - source: z.unknown().transform(v => + source: z.unknown().optional().transform(v => VALID_COMMAND_SOURCES.includes(v as PiCommandSource) ? (v as PiCommandSource) : ('skill' as const), @@ -195,9 +195,11 @@ export const PiAssistantMessageEventSchema = z.object({ // ============================================================================ export function parsePiCommands(data: unknown) { - return PiCommandsResponseDataSchema.safeParse(data).data ?? []; + const result = PiCommandsResponseDataSchema.safeParse(data) + return result.success ? result.data : [] } export function parsePiModels(data: unknown) { - return PiModelsResponseDataSchema.safeParse(data).data ?? []; + const result = PiModelsResponseDataSchema.safeParse(data) + return result.success ? result.data : [] } diff --git a/cli/src/runner/README.md b/cli/src/runner/README.md index ba782dab7e..e5ddfd3390 100644 --- a/cli/src/runner/README.md +++ b/cli/src/runner/README.md @@ -85,6 +85,7 @@ The runner supports spawning sessions with different AI agents: | `claude` (default) | `hapi claude` | `CLAUDE_CODE_OAUTH_TOKEN` | | `codex` | `hapi codex` | `CODEX_HOME` (temp directory with `auth.json`) | | `gemini` | `hapi gemini` | - | +| `grok` | `hapi grok` | Grok CLI login or `XAI_API_KEY` | | `opencode` | `hapi opencode` | OpenCode config (no token injection) | ### Token Authentication @@ -92,6 +93,7 @@ The runner supports spawning sessions with different AI agents: When spawning a session with a token: - **Claude**: Sets `CLAUDE_CODE_OAUTH_TOKEN` environment variable - **Codex**: Creates temp directory at `os.tmpdir()/hapi-codex-*`, writes token to `auth.json`, sets `CODEX_HOME` +- **Grok Build**: No token injection; relies on Grok CLI login or `XAI_API_KEY` in the runner environment - **OpenCode**: No token injection; relies on OpenCode's own configuration ## 3. Session Management diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index fb6d764895..afc208ccda 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -31,7 +31,7 @@ describe('buildCliArgs', () => { }) it('prefers --permission-mode over --yolo when both present', () => { - const args = buildCliArgs('gemini', { + const args = buildCliArgs('cursor', { directory: '/tmp', permissionMode: 'yolo', }, true) @@ -43,6 +43,10 @@ describe('buildCliArgs', () => { expect(yoloIdx).toBe(-1) }) + it('throws for the removed gemini agent (no longer launchable)', () => { + expect(() => buildCliArgs('gemini', { directory: '/tmp' })).toThrow(/no longer supported/) + }) + it('adds --yolo when no permissionMode and yolo is true', () => { const args = buildCliArgs('claude', { directory: '/tmp', @@ -89,7 +93,7 @@ describe('buildCliArgs', () => { }) it('validates all known permission modes', () => { - for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) { + for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { directory: '/tmp', permissionMode: mode, @@ -99,6 +103,34 @@ describe('buildCliArgs', () => { } }) + it('passes --cursor-worktree for cursor worktree sessions', () => { + const args = buildCliArgs('cursor', { + directory: '/tmp/repo', + sessionType: 'worktree', + worktreeName: 'feature-x', + }) + expect(args).toContain('--cursor-worktree') + expect(args).toContain('feature-x') + }) + + it('passes bare --cursor-worktree when name is omitted', () => { + const args = buildCliArgs('cursor', { + directory: '/tmp/repo', + sessionType: 'worktree', + }) + expect(args).toContain('--cursor-worktree') + expect(args[args.length - 1]).toBe('--cursor-worktree') + }) + + it('does not pass --cursor-worktree for non-cursor worktree sessions', () => { + const args = buildCliArgs('claude', { + directory: '/tmp/repo', + sessionType: 'worktree', + worktreeName: 'feature-x', + }) + expect(args).not.toContain('--cursor-worktree') + }) + it('uses --session-id for pi resume (not --resume)', () => { const args = buildCliArgs('pi', { directory: '/tmp', @@ -138,4 +170,24 @@ describe('buildCliArgs', () => { expect(args).toContain('--effort') expect(args).toContain('high') }) + + it('builds Grok runner resume, model, effort, and permission arguments', () => { + const args = buildCliArgs('grok', { + directory: '/tmp', + resumeSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + }) + + expect(args).toEqual([ + 'grok', + '--resume', 'grok-session-1', + '--hapi-starting-mode', 'remote', + '--started-by', 'runner', + '--model', 'grok-4.5', + '--effort', 'low', + '--permission-mode', 'plan' + ]) + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 694ad7197b..15cea991f1 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -287,6 +287,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'); + } const yolo = options.yolo === true; const sessionType = options.sessionType ?? 'simple'; const worktreeName = options.worktreeName; @@ -333,20 +336,27 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } if (sessionType === 'worktree') { - const worktreeResult = await createWorktree({ - basePath: directory, - nameHint: worktreeName - }); - if (!worktreeResult.ok) { - logger.debug(`[RUNNER RUN] Worktree creation failed: ${worktreeResult.error}`); - return { - type: 'error', - errorMessage: worktreeResult.error - }; + // Cursor Agent has native `--worktree` under ~/.cursor/worktrees/. Prefer that + // over HAPI's sibling-directory worktree so Cursor sandbox/skills see the same layout. + if (agent === 'cursor') { + spawnDirectory = directory; + logger.debug(`[RUNNER RUN] Cursor-native worktree requested (nameHint=${worktreeName ?? '(auto)'})`); + } else { + const worktreeResult = await createWorktree({ + basePath: directory, + nameHint: worktreeName + }); + if (!worktreeResult.ok) { + logger.debug(`[RUNNER RUN] Worktree creation failed: ${worktreeResult.error}`); + return { + type: 'error', + errorMessage: worktreeResult.error + }; + } + worktreeInfo = worktreeResult.info; + spawnDirectory = worktreeInfo.worktreePath; + logger.debug(`[RUNNER RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); } - worktreeInfo = worktreeResult.info; - spawnDirectory = worktreeInfo.worktreePath; - logger.debug(`[RUNNER RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); } const cleanupWorktree = async () => { @@ -1073,12 +1083,15 @@ export function buildCliArgs( options: SpawnSessionOptions, yolo?: boolean ): string[] { + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18).'); + } const agentCommand = agent === 'codex' ? 'codex' : agent === 'cursor' ? 'cursor' - : agent === 'gemini' - ? 'gemini' + : agent === 'grok' + ? 'grok' : agent === 'kimi' ? 'kimi' : agent === 'opencode' @@ -1103,7 +1116,7 @@ export function buildCliArgs( if (options.model) { args.push('--model', options.model); } - if (options.effort && (agent === 'claude' || agent === 'pi')) { + if (options.effort && (agent === 'claude' || agent === 'grok' || agent === 'pi')) { args.push('--effort', options.effort); } if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { @@ -1121,5 +1134,12 @@ export function buildCliArgs( args.push('--yolo'); } } + if (agent === 'cursor' && options.sessionType === 'worktree') { + args.push('--cursor-worktree'); + const name = options.worktreeName?.trim(); + if (name) { + args.push(name); + } + } return args; } diff --git a/cli/src/test/setup.ts b/cli/src/test/setup.ts index 2f55a21f47..2417037d9f 100644 --- a/cli/src/test/setup.ts +++ b/cli/src/test/setup.ts @@ -25,5 +25,8 @@ process.env.HAPI_API_URL = `http://127.0.0.1:${config.port}` process.env.CLI_API_TOKEN = config.token process.env.HAPI_HOME = config.tmpHome process.env.HAPI_BUN_EXEC = config.bunExec +// The stress test starts 20 real CLI children. On slower/loaded machines their +// session webhooks can take longer than the production control-client default. +process.env.HAPI_RUNNER_HTTP_TIMEOUT ??= '60000' // Keep heartbeat short so the version-mismatch test doesn't need to wait 60s process.env.HAPI_RUNNER_HEARTBEAT_INTERVAL ??= '30000' diff --git a/cli/src/ui/ink/GeminiDisplay.tsx b/cli/src/ui/ink/GrokDisplay.tsx similarity index 95% rename from cli/src/ui/ink/GeminiDisplay.tsx rename to cli/src/ui/ink/GrokDisplay.tsx index 34e04e1d7c..eba83455a2 100644 --- a/cli/src/ui/ink/GeminiDisplay.tsx +++ b/cli/src/ui/ink/GrokDisplay.tsx @@ -3,7 +3,7 @@ import { Box, Text, useStdout } from 'ink'; import { MessageBuffer, type BufferedMessage } from './messageBuffer'; import { useSwitchControls } from './useSwitchControls'; -interface GeminiDisplayProps { +interface GrokDisplayProps { messageBuffer: MessageBuffer; logPath?: string; onExit?: () => void; @@ -28,7 +28,7 @@ function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string return null; } -export const GeminiDisplay: React.FC = ({ +export const GrokDisplay: React.FC = ({ messageBuffer, logPath, onExit, @@ -112,7 +112,7 @@ export const GeminiDisplay: React.FC = ({ overflow="hidden" > - Gemini Agent Messages + Grok Build Agent Messages {'-'.repeat(Math.min(terminalWidth - 4, 60))} @@ -166,7 +166,7 @@ export const GeminiDisplay: React.FC = ({ ) : ( - Gemini running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} + Grok Build running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} )} {(model || permissionMode) && ( diff --git a/cli/src/utils/autoStartServer.ts b/cli/src/utils/autoStartServer.ts index c565d9621f..3cf20814ab 100644 --- a/cli/src/utils/autoStartServer.ts +++ b/cli/src/utils/autoStartServer.ts @@ -142,7 +142,10 @@ function startServerAsChild(): void { /** * Main entry point: auto-start hub if conditions are met */ -export async function maybeAutoStartServer(): Promise { +export async function maybeAutoStartServer(options?: { + waitForReady?: boolean + quiet?: boolean +}): Promise { try { const shouldStart = await shouldAutoStartServer() if (!shouldStart) { @@ -150,24 +153,36 @@ export async function maybeAutoStartServer(): Promise { } logger.debug('[AUTO-START] Starting hub automatically...') - console.log(chalk.gray('Starting HAPI hub in background...')) + if (!options?.quiet) { + console.log(chalk.gray('Starting HAPI hub in background...')) + } startServerAsChild() + if (options?.waitForReady === false) { + return + } + const isReady = await waitForServerReady(configuration.apiUrl) if (!isReady) { - console.log(chalk.yellow('Warning: Hub did not start within expected time')) - console.log(chalk.gray(' Try running `hapi hub` manually to see errors')) + if (!options?.quiet) { + console.log(chalk.yellow('Warning: Hub did not start within expected time')) + console.log(chalk.gray(' Try running `hapi hub` manually to see errors')) + } return } - console.log(chalk.green('HAPI hub started')) + if (!options?.quiet) { + console.log(chalk.green('HAPI hub started')) + } } catch (error) { logger.debug('[AUTO-START] Error during hub auto-start', error) - console.log(chalk.yellow('Warning: Failed to auto-start hub')) - if (error instanceof Error) { - console.log(chalk.gray(` Error: ${error.message}`)) + if (!options?.quiet) { + console.log(chalk.yellow('Warning: Failed to auto-start hub')) + if (error instanceof Error) { + console.log(chalk.gray(` Error: ${error.message}`)) + } } } } diff --git a/cli/src/utils/machineHealth.test.ts b/cli/src/utils/machineHealth.test.ts new file mode 100644 index 0000000000..b3fab5bcec --- /dev/null +++ b/cli/src/utils/machineHealth.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { + collectMachineHealth, + readDarwinMemoryUsedPercent, + readLinuxMemoryUsedPercent, + resetMachineHealthSamplerForTests +} from './machineHealth' + +describe('readLinuxMemoryUsedPercent', () => { + it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => { + const meminfo = ` +MemTotal: 32793696 kB +MemFree: 578248 kB +MemAvailable: 18312444 kB +Buffers: 1196580 kB +Cached: 9758076 kB +`.trim() + + expect(readLinuxMemoryUsedPercent(meminfo)).toBe(44) + }) +}) + +describe('readDarwinMemoryUsedPercent', () => { + it('reports App+Wired+Compressed used memory, not total-minus-free (real Apple Silicon capture)', () => { + // Verbatim `vm_stat` capture from a 16GB Mac mini (M-series, macOS 26.4, hw.memsize + // 17179869184). `top` reported the same moment as "15G used (1952M wired, 5204M + // compressor)"; anonymous 370327 pages ≈ 5.65GB App Memory. Activity Monitor's + // "Memory Used" = App + Wired + Compressed ≈ 12.6GB → 79%. + // The pre-fix path (`total - os.freemem()`) reported 99% here and drove the tooltip to + // "High pressure — avoid spawning more here", because os.freemem() counts reclaimable + // file cache as used. Summing only anonymous + wired + compressor yields 79%, matching + // the number a user sees in Activity Monitor. + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 15078. +Pages active: 269344. +Pages inactive: 269559. +Pages speculative: 1623. +Pages throttled: 0. +Pages wired down: 124928. +Pages purgeable: 6245. +"Translation faults": 6794924000. +Pages copy-on-write: 151784964. +Pages zero filled: 6137372590. +Pages reactivated: 57500663. +Pages purged: 11350044. +File-backed pages: 170199. +Anonymous pages: 370327. +Pages stored in compressor: 785407. +Pages occupied by compressor: 333030. +Decompressions: 49937944. +Compressions: 60303007. +Pageins: 30419969. +Pageouts: 124211. +Swapins: 288. +Swapouts: 12228. +`.trim() + + const totalBytes = 17179869184 + expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(79) + }) + + it('parses page size from the header instead of hardcoding it (Intel, 4KB pages)', () => { + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 4096 bytes) +Pages free: 48576. +Pages active: 900000. +Pages inactive: 900000. +Pages speculative: 100000. +Pages throttled: 0. +Pages wired down: 200000. +Pages purgeable: 1000. +Anonymous pages: 800000. +Pages occupied by compressor: 48576. +`.trim() + + // 8GB total (2097152 pages of 4096B), chosen so anonymous+wired+compressor pages == + // exactly half of total pages — verifies the header page size is used, not hardcoded. + const totalBytes = 8589934592 + expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(50) + }) + + it('returns undefined when a required page count is missing, so callers fall back', () => { + // Valid header and two of the three required counts present, but "Anonymous pages" is + // absent — must fail safe to the caller's os.freemem() path, not compute from partial data. + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages wired down: 124928. +Pages occupied by compressor: 333030. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined() + }) + + it('returns undefined for entirely unparseable input', () => { + expect(readDarwinMemoryUsedPercent('not a vm_stat output at all', 17179869184)).toBeUndefined() + }) + + it('returns undefined when totalBytes is not positive', () => { + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 10000. +Pages inactive: 150000. +Pages speculative: 9125. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 0)).toBeUndefined() + }) + + it('falls back (undefined) when the page-size header is missing instead of guessing 4096', () => { + // Required page counts are all present, but no "page size of N bytes" header. Guessing + // 4096 would 4x-underestimate used bytes on Apple Silicon and re-introduce the + // false-high-pressure bug, so this must fail safe to the caller's os.freemem() path. + const vmStat = ` +Pages wired down: 124928. +Anonymous pages: 370327. +Pages occupied by compressor: 333030. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined() + }) +}) + +describe('collectMachineHealth', () => { + it('returns schema-valid health with memory, uptime, and cpu count', () => { + resetMachineHealthSamplerForTests() + const health = collectMachineHealth(1_700_000_000_000) + expect(health.collectedAt).toBe(1_700_000_000_000) + expect(health.cpuCount).toBeGreaterThan(0) + expect(health.memoryPercent).toBeGreaterThanOrEqual(0) + expect(health.memoryPercent).toBeLessThanOrEqual(100) + expect(health.uptimeSeconds).toBeGreaterThan(0) + }) + + it('computes cpu percent after a second sample', async () => { + resetMachineHealthSamplerForTests() + collectMachineHealth() + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = collectMachineHealth() + if (second.cpuPercent !== undefined) { + expect(second.cpuPercent).toBeGreaterThanOrEqual(0) + expect(second.cpuPercent).toBeLessThanOrEqual(100) + } + }) +}) diff --git a/cli/src/utils/machineHealth.ts b/cli/src/utils/machineHealth.ts new file mode 100644 index 0000000000..94c98051ec --- /dev/null +++ b/cli/src/utils/machineHealth.ts @@ -0,0 +1,213 @@ +import { execSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os' +import type { MachineHealth } from '@hapi/protocol/types' +import { MachineHealthSchema } from '@hapi/protocol/schemas' + +type CpuTimesSnapshot = { + idle: number + total: number +} + +let previousCpuSnapshot: CpuTimesSnapshot | null = null + +function sumCpuTimes(): CpuTimesSnapshot | null { + const cores = cpus() + if (cores.length === 0) { + return null + } + + let idle = 0 + let total = 0 + for (const core of cores) { + const times = core.times + idle += times.idle + total += times.user + times.nice + times.sys + times.idle + times.irq + } + + return { idle, total } +} + +function computeCpuPercent(current: CpuTimesSnapshot, previous: CpuTimesSnapshot): number | undefined { + const idleDelta = current.idle - previous.idle + const totalDelta = current.total - previous.total + if (totalDelta <= 0) { + return undefined + } + + const usage = 1 - idleDelta / totalDelta + return Math.max(0, Math.min(100, Math.round(usage * 100))) +} + +function parseMeminfoKbValue(meminfo: string, key: string): number | undefined { + for (const line of meminfo.split('\n')) { + if (!line.startsWith(`${key}:`)) { + continue + } + const kb = Number(line.split(/\s+/)[1]) + return Number.isFinite(kb) ? kb * 1024 : undefined + } + return undefined +} + +/** Linux pressure percent: (MemTotal - MemAvailable) / MemTotal. Testable without /proc. */ +export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined { + const total = parseMeminfoKbValue(meminfo, 'MemTotal') + if (!total || total <= 0) { + return undefined + } + + const available = parseMeminfoKbValue(meminfo, 'MemAvailable') + if (available !== undefined) { + return Math.max(0, Math.min(100, Math.round(((total - available) / total) * 100))) + } + + // Pre-3.14 kernels: approximate available as free + reclaimable cache. + const free = parseMeminfoKbValue(meminfo, 'MemFree') + if (free === undefined) { + return undefined + } + const buffers = parseMeminfoKbValue(meminfo, 'Buffers') ?? 0 + const cached = parseMeminfoKbValue(meminfo, 'Cached') ?? 0 + const approxAvailable = free + buffers + cached + return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100))) +} + +function parseVmStatPagesValue(vmStat: string, label: string): number | undefined { + for (const line of vmStat.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith(`${label}:`)) { + continue + } + const match = trimmed.match(/(\d+)\.?\s*$/) + if (!match) { + return undefined + } + const pages = Number(match[1]) + return Number.isFinite(pages) ? pages : undefined + } + return undefined +} + +function parseVmStatPageSize(vmStat: string): number | undefined { + const match = vmStat.match(/page size of (\d+) bytes/) + if (!match) { + return undefined + } + const size = Number(match[1]) + return Number.isFinite(size) && size > 0 ? size : undefined +} + +/** + * Darwin used-memory percent, matching Activity Monitor's "Memory Used" figure: + * App Memory + Wired + Compressed. It sums the anonymous (non-file-backed) resident + * pages, the wired-down pages, and the pages occupied by the compressor — the + * genuinely-occupied memory. Free and reclaimable file cache (inactive/speculative + * file-backed pages) are simply not part of that sum. + * + * os.freemem() (total - free) instead counts reclaimable file cache as used, which is why + * the pre-fix path reported a stuck ~99% "High pressure" on macOS. Summing occupied pages + * keeps parity with the Linux branch, whose total - MemAvailable likewise treats reclaimable + * cache as available and anonymous/wired memory as used. The three fields were validated on a + * real Mac mini against `top`'s wired/compressor byte figures. + * + * Any unparseable required field — including the page-size header — returns undefined so the + * caller falls back to os.freemem() rather than reporting a wrong-but-plausible percent. + */ +export function readDarwinMemoryUsedPercent(vmStat: string, totalBytes: number): number | undefined { + if (!totalBytes || totalBytes <= 0) { + return undefined + } + + const pageSize = parseVmStatPageSize(vmStat) + const anonymous = parseVmStatPagesValue(vmStat, 'Anonymous pages') + const wired = parseVmStatPagesValue(vmStat, 'Pages wired down') + const compressor = parseVmStatPagesValue(vmStat, 'Pages occupied by compressor') + if (pageSize === undefined || anonymous === undefined || wired === undefined || compressor === undefined) { + return undefined + } + + const used = (anonymous + wired + compressor) * pageSize + return Math.max(0, Math.min(100, Math.round((used / totalBytes) * 100))) +} + +function computeMemoryPercent(): number | undefined { + if (platform() === 'linux') { + try { + const fromProc = readLinuxMemoryUsedPercent(readFileSync('/proc/meminfo', 'utf8')) + if (fromProc !== undefined) { + return fromProc + } + } catch { + // fall through to os.freemem() + } + } + + if (platform() === 'darwin') { + try { + const vmStat = execSync('vm_stat', { encoding: 'utf8', timeout: 1000 }) + const fromVmStat = readDarwinMemoryUsedPercent(vmStat, totalmem()) + if (fromVmStat !== undefined) { + return fromVmStat + } + } catch { + // fall through to os.freemem() + } + } + + const total = totalmem() + if (total <= 0) { + return undefined + } + + const used = total - freemem() + return Math.max(0, Math.min(100, Math.round((used / total) * 100))) +} + +function isUnixLikeLoadPlatform(): boolean { + return platform() !== 'win32' +} + +function computeUptimeSeconds(): number | undefined { + const seconds = uptime() + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined + } + return Math.floor(seconds) +} + +export function collectMachineHealth(now: number = Date.now()): MachineHealth { + const cpuCount = availableParallelism() + const memoryPercent = computeMemoryPercent() + const uptimeSeconds = computeUptimeSeconds() + const load1m = isUnixLikeLoadPlatform() ? loadavg()[0] : undefined + + const cpuSnapshot = sumCpuTimes() + let cpuPercent: number | undefined + if (cpuSnapshot && previousCpuSnapshot) { + cpuPercent = computeCpuPercent(cpuSnapshot, previousCpuSnapshot) + } + if (cpuSnapshot) { + previousCpuSnapshot = cpuSnapshot + } + + const health = { + collectedAt: now, + cpuCount, + ...(load1m !== undefined ? { load1m } : {}), + ...(cpuPercent !== undefined ? { cpuPercent } : {}), + ...(memoryPercent !== undefined ? { memoryPercent } : {}), + ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) + } + + const parsed = MachineHealthSchema.safeParse(health) + if (!parsed.success) { + return { collectedAt: now } + } + return parsed.data +} + +/** Test helper */ +export function resetMachineHealthSamplerForTests(): void { + previousCpuSnapshot = null +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 655a6b70df..676e04af85 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -23,6 +23,7 @@ export default defineConfig({ { text: 'PWA', link: '/guide/pwa' }, { text: 'How it Works', link: '/guide/how-it-works' }, { text: 'Cursor Agent', link: '/guide/cursor' }, + { text: 'Grok Build', link: '/guide/grok' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'FAQ', link: '/guide/faq' } diff --git a/docs/guide/cursor.md b/docs/guide/cursor.md index c7ab123830..86d1897c2d 100644 --- a/docs/guide/cursor.md +++ b/docs/guide/cursor.md @@ -23,8 +23,11 @@ hapi cursor resume # Resume a specific chat hapi cursor --continue # Resume the most recent chat hapi cursor --mode plan # Start in Plan mode hapi cursor --mode ask # Start in Ask mode +hapi cursor --auto-review # Start with Auto-review (Smart Auto) hapi cursor --yolo # Bypass approval prompts (--force) hapi cursor --model # Specify model +hapi cursor --cursor-worktree feature-x # Cursor-native worktree +hapi cursor --cursor-add-dir ../shared # Extra workspace root (repeatable) ``` ## Permission Modes @@ -34,9 +37,25 @@ hapi cursor --model # Specify model | `default` | Standard agent behavior | | `plan` | Plan mode - design approach before coding | | `ask` | Ask mode - explore code without edits | +| `debug` | Debug mode - hypotheses + instrumentation | +| `autoReview` | Auto-review (Smart Auto) - allowlist/sandbox/classifier instead of full YOLO | | `yolo` | Bypass approval prompts | -Set mode via `--mode` flag or change from the web UI during a session. +Set mode via `--mode` / `--permission-mode` / `--auto-review`, or change from the web UI during a session. + +## Cursor-native worktree & multi-root + +- New Session **Worktree** for Cursor uses Cursor's `--worktree` (`~/.cursor/worktrees//`), not HAPI's sibling-directory worktree. +- Mid-session: send `/worktree`, `/apply-worktree`, `/delete-worktree`, or `/add-dir ` (isolated pass-through). +- CLI: `hapi cursor --cursor-worktree feature-x --cursor-add-dir ../shared` + +## Slash pass-through (remote) + +These commands are isolated in the queue and forwarded to the agent (ACP prompt or legacy `-p`): + +`/compress` `/summarize` `/compact` `/model` `/multitask` `/best-of-n` `/worktree` `/apply-worktree` `/delete-worktree` `/add-dir` `/context` `/fork` `/auto-review` + +Interactive TUI-only commands (`/config`, `/mcp`, `/sandbox`, `/btw`, `/rewind`, …) are not supported remotely. ## Modes @@ -45,6 +64,7 @@ Set mode via `--mode` flag or change from the web UI during a session. ## Limitations +- **Multitask UI** - `/multitask` is slash-driven; HAPI does not yet provide an Agents Window-style fleet pane. Subagent `cursor/task` notifications show as CursorTask cards when the agent emits them. - **Legacy sessions** - Cursor sessions created before the ACP migration can still resume temporarily via stream-json. Start a new Cursor session to get ACP permissions, plans, todos, and question support. - **Session resume** - ACP sessions resume through `session/load`. Old stream-json `session_id` values are not loadable via ACP; those sessions keep using the legacy path until you start fresh. diff --git a/docs/guide/grok.md b/docs/guide/grok.md new file mode 100644 index 0000000000..f56f631720 --- /dev/null +++ b/docs/guide/grok.md @@ -0,0 +1,90 @@ +# Grok Build + +HAPI can run the official Grok Build CLI locally and control the same coding session remotely from the Web/PWA. + +## Install + +Install Grok Build using the official installer: + +::: code-group + +```bash [macOS / Linux / WSL] +curl -fsSL https://x.ai/cli/install.sh | bash +``` + +```powershell [Windows PowerShell] +irm https://x.ai/cli/install.ps1 | iex +``` + +::: + +Verify the installation: + +```bash +grok version +``` + +## Authenticate + +HAPI reuses the Grok CLI's local authentication. On a headless runner machine, authenticate once with device-code login: + +```bash +grok login --device-auth +``` + +Alternatively, configure an xAI API key in the runner environment: + +```bash +export XAI_API_KEY="xai-..." +``` + +Do not place API keys in HAPI configuration files, logs, or a repository. + +## Start a session + +Start the native Grok Build TUI: + +```bash +hapi grok +``` + +Start with explicit launch settings: + +```bash +hapi grok --model grok-4.5 --effort low --permission-mode default +``` + +Sessions created from a HAPI runner start in remote mode automatically. Terminal-created sessions start in the native Grok TUI and can switch to remote control without parsing terminal output. + +## Permission modes + +HAPI exposes a conservative subset for the first integration: + +- `default` — tool requests are shown in HAPI for approval or denial. +- `plan` — HAPI asks Grok to plan only and rejects tool execution requests. +- `bypassPermissions` — tool requests are automatically approved for the session. + +Use `bypassPermissions` only in a trusted workspace. + +## Resume and handoff + +Remote mode uses Grok's ACP stdio agent (`grok agent stdio`). HAPI stores the native Grok session ID and uses it for: + +- ACP `session/load` after a restart. +- `grok --resume ` when switching back to the native TUI. +- `hapi resume ` from a terminal. + +For a new local session, HAPI supplies a UUID with `grok --session-id`, so the session can be resumed without scraping the fullscreen TUI. + +## Model and effort controls + +The Create page discovers Grok's ACP model catalog and the reasoning-effort choices advertised for each model. Remote sessions can switch both model and effort between turns; HAPI applies them through ACP `session/set_model` and `session/set_mode`. + +HAPI also exposes Grok's common slash commands, discovers skills from `.grok/skills`, `~/.grok/skills`, and shared `.agents/skills`, and asks Grok to set a concise HAPI session title after the first normal prompt. + +## Current limitations + +- OAuth/device-code login must be completed outside the HAPI Web UI. +- Grok subscription, credit, and model availability are controlled by xAI. + +If a remote session reports authentication failure, run `grok login --device-auth` on the runner machine and retry. diff --git a/docs/tooling/mermaid-lightbox-dogfood.md b/docs/tooling/mermaid-lightbox-dogfood.md new file mode 100644 index 0000000000..081f07e4f3 --- /dev/null +++ b/docs/tooling/mermaid-lightbox-dogfood.md @@ -0,0 +1,56 @@ +# Mermaid lightbox dogfood (Playwright) + +Two Playwright targets: + +| Target | What it exercises | Command | +|--------|-------------------|---------| +| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` | +| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` | + +## Live session (production-shaped) + +**Session URL (after seed):** + +`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737` + +Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver). +For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first). + +### 1. Seed fixtures (hub DB) + +On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`): + +```bash +bun run seed:mermaid-lightbox:session +``` + +Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session. + +### 2. Deploy web with your branch + +```bash +hapi-driver-rebuild --build-web +# activate soup when ready (restarts hub) +``` + +Hard-refresh the browser after web changes. + +### 3. Run live Playwright + +```bash +HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live +``` + +Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`). + +**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines. + +If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver. + +## Isolation page (not chat) + +Only for component regression; **not** the same as chat: + +`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build). + +Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts` diff --git a/e2e/file-md-preview.spec.ts b/e2e/file-md-preview.spec.ts new file mode 100644 index 0000000000..7456ade067 --- /dev/null +++ b/e2e/file-md-preview.spec.ts @@ -0,0 +1,33 @@ +/* + * Playwright smoke for issue #954 — markdown Source | Preview toggle in + * the session file pane. Drives the Vite fixture that mounts the same + * MarkdownRenderer + toggle affordance as production `file.tsx`. + */ + +import { test, expect } from '@playwright/test' +import path from 'node:path' + +const SCREENSHOT_PATH = path.resolve( + process.env.HOME ?? '', + 'coding/hapi/localdocs/playwright-runs/954-file-md-preview.png' +) + +test.describe('file markdown preview e2e', () => { + test('preview renders heading and table; source shows raw markdown', async ({ page }) => { + await page.goto('/e2e-fixtures/file-md-preview-fixture.html') + await expect(page.getByTestId('file-md-preview-fixture')).toBeVisible() + + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'general' })).toBeVisible() + await expect(page.locator('.aui-md-codeblock')).toBeVisible() + + await page.getByTestId('markdown-mode-source').click() + await expect(page.getByTestId('markdown-source-view')).toContainText('# Teams and channels') + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toHaveCount(0) + + await page.getByTestId('markdown-mode-preview').click() + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible() + + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }) + }) +}) diff --git a/e2e/settings-responsive.spec.ts b/e2e/settings-responsive.spec.ts new file mode 100644 index 0000000000..43fa359b1d --- /dev/null +++ b/e2e/settings-responsive.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' + +const fixture = '/e2e-fixtures/settings-fixture.html' + +test.describe('settings responsive layout', () => { + test('mobile drills from the category hub into a full-width detail page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(fixture) + + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible() + await expect(page.getByRole('button', { name: /Display/ })).toBeVisible() + await expect(page.locator('aside')).toBeHidden() + + await page.getByRole('button', { name: /Display/ }).click() + + await expect(page.locator('header').getByRole('heading', { name: 'Display' })).toBeVisible() + await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible() + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden() + await expect(page.getByRole('listbox')).toHaveCount(0) + }) + + test('desktop keeps navigation visible and renders Display as the default detail', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(fixture) + + const desktopNav = page.locator('aside nav') + await expect(desktopNav).toBeVisible() + await expect(desktopNav.getByRole('button', { name: 'Display' })).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('heading', { name: 'Display' })).toBeVisible() + await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible() + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden() + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible() + await expect(page.locator('aside')).toBeHidden() + }) +}) diff --git a/hub/src/overseer/oneBossInvariant.test.ts b/hub/src/overseer/oneBossInvariant.test.ts new file mode 100644 index 0000000000..2deebece5c --- /dev/null +++ b/hub/src/overseer/oneBossInvariant.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test' +import { join } from 'node:path' +import { loadAndReplay } from './replayHarness' +import { assertOneBossInvariant, checkOneBossInvariant } from './oneBossInvariant' + +const FIXTURE_DIR = join(import.meta.dir, '..', '..', '..', 'test', 'fixtures', 'overseer-replay') +const fixture = (name: string) => join(FIXTURE_DIR, `${name}.json`) + +describe('One-boss invariant (ADR-001)', () => { + it('passes vacuously when the stream has no dispatched events', () => { + const ctx = loadAndReplay(fixture('routine-progress-flood')) + const result = checkOneBossInvariant(ctx) + expect(result.checked).toBe(0) + expect(result.violations).toHaveLength(0) + expect(() => assertOneBossInvariant(ctx)).not.toThrow() + }) + + it('checks a clean operator-attributed dispatch and finds no violation', () => { + const ctx = loadAndReplay(fixture('one-boss-clean')) + const result = checkOneBossInvariant(ctx) + expect(result.checked).toBe(1) + expect(result.violations).toHaveLength(0) + expect(() => assertOneBossInvariant(ctx)).not.toThrow() + }) + + it('catches a leaking dispatch (attribution boilerplate + overseer metadata)', () => { + const ctx = loadAndReplay(fixture('one-boss-leak')) + const result = checkOneBossInvariant(ctx) + expect(result.checked).toBe(1) + const kinds = new Set(result.violations.map((v) => v.kind)) + expect(kinds.has('attribution-phrase')).toBe(true) + expect(kinds.has('metadata-key')).toBe(true) + expect(() => assertOneBossInvariant(ctx)).toThrow(/one-boss/) + }) +}) diff --git a/hub/src/overseer/oneBossInvariant.ts b/hub/src/overseer/oneBossInvariant.ts new file mode 100644 index 0000000000..eb678de31b --- /dev/null +++ b/hub/src/overseer/oneBossInvariant.ts @@ -0,0 +1,164 @@ +/** + * One-boss invariant (ADR-001 §"Invariant test"). + * + * Workers never know about the Overseer: every dispatched instruction arrives + * operator-attributed. This module is the mechanical check that protects the + * decision from drift. + * + * At Step 2.75 there is no dispatch writer, so a replayed snapshot contains no + * `dispatched` events and the invariant passes VACUOUSLY (checked === 0). The + * assertion shape is wired so that when Step 4 lands the dispatch envelope + + * worker-message writer, replaying a Step-4-era snapshot (which carries + * `dispatched` events plus their envelopes/messages) activates real coverage + * with zero changes here. + * + * The check is intent-based, not lexical: it does NOT ban the word "overseer" + * from worker messages (operators legitimately reference the product by name). + * It bans GENERATED attribution boilerplate and envelope-metadata exposure. + */ +import type { Database } from 'bun:sqlite' +import type { ReplayContext, SnapshotDispatchEnvelope, SnapshotWorkerMessage } from './replayHarness' + +/** Curated forbidden-phrase list (ADR-001). Extend per persona archetypes. */ +export const FORBIDDEN_ATTRIBUTION_PATTERNS: RegExp[] = [ + /the\s+overseer\s+(says|suggests|asks|wants)/i, + /your\s+assistant\s+(says|suggests|asks|wants)/i, + /on\s+behalf\s+of\s+(the\s+)?overseer/i, + /(message|dispatch|request)\s+from\s+(the\s+)?overseer/i, + /(chief\s+of\s+staff|fleet\s+manager|fleet\s+coordinator)\s+(says|suggests|wants)/i +] + +/** Exact metadata keys that would leak Overseer provenance to a worker. */ +export const FORBIDDEN_METADATA_KEYS = [ + 'source', + 'origin', + 'dispatched_by', + 'envelope_id', + 'dispatch_envelope_id', + 'rationale', + 'related_event_ids', + 'confirmation_source', + 'idempotency_key' +] as const + +export type OneBossViolation = { + eventId: number + messageId: string | null + kind: 'role' | 'metadata-key' | 'attribution-phrase' | 'missing-message' + detail: string +} + +export type OneBossResult = { + checked: number + violations: OneBossViolation[] +} + +function metadataKeyViolation( + eventId: number, + messageId: string, + metadata: Record +): OneBossViolation | null { + for (const key of Object.keys(metadata)) { + const lower = key.toLowerCase() + if (lower.startsWith('overseer') || lower.startsWith('overseer_')) { + return { eventId, messageId, kind: 'metadata-key', detail: `overseer-prefixed metadata key '${key}'` } + } + if ((FORBIDDEN_METADATA_KEYS as readonly string[]).includes(lower)) { + return { eventId, messageId, kind: 'metadata-key', detail: `forbidden metadata key '${key}'` } + } + } + return null +} + +function attributionViolation( + eventId: number, + messageId: string, + rendered: string +): OneBossViolation | null { + for (const pattern of FORBIDDEN_ATTRIBUTION_PATTERNS) { + if (pattern.test(rendered)) { + return { + eventId, + messageId, + kind: 'attribution-phrase', + detail: `rendered instruction matches forbidden attribution ${pattern}` + } + } + } + return null +} + +type DispatchedEventRow = { id: number; idempotency_key: string | null } + +/** + * Check the one-boss invariant over a replayed snapshot. + * + * Resolves each `dispatched` event to its dispatch envelope (by + * idempotency_key) and the worker-facing message it produced, then asserts the + * message is operator-attributed with no Overseer leakage. Envelopes/messages + * are read from the snapshot today; Step 4 swaps the resolution to the real + * `dispatch_envelopes` + `messages` tables. + */ +export function checkOneBossInvariant(ctx: ReplayContext): OneBossResult { + const db: Database = ctx.db + const dispatched = db.prepare( + "SELECT id, idempotency_key FROM events WHERE event_type = 'dispatched'" + ).all() as DispatchedEventRow[] + + const envelopeByKey = new Map() + for (const env of ctx.snapshot.dispatchEnvelopes ?? []) { + envelopeByKey.set(env.idempotencyKey, env) + } + const messageById = new Map() + for (const msg of ctx.snapshot.workerMessages ?? []) { + messageById.set(msg.id, msg) + } + + const violations: OneBossViolation[] = [] + + for (const event of dispatched) { + const envelope = event.idempotency_key ? envelopeByKey.get(event.idempotency_key) : undefined + const message = envelope ? messageById.get(envelope.messageId) : undefined + + if (!envelope || !message) { + // A dispatched event with no resolvable worker message is itself a + // contract violation once dispatch exists (Step 4); flag it. + violations.push({ + eventId: event.id, + messageId: envelope?.messageId ?? null, + kind: 'missing-message', + detail: 'dispatched event has no resolvable worker message' + }) + continue + } + + if (message.role !== 'user') { + violations.push({ + eventId: event.id, + messageId: message.id, + kind: 'role', + detail: `worker message role is '${message.role}', expected 'user'` + }) + } + + const metaViolation = metadataKeyViolation(event.id, message.id, message.metadata ?? {}) + if (metaViolation) violations.push(metaViolation) + + const attrViolation = attributionViolation(event.id, message.id, message.renderedInstruction) + if (attrViolation) violations.push(attrViolation) + } + + return { checked: dispatched.length, violations } +} + +/** Throwing wrapper for use directly in assertions / CI gate scripts. */ +export function assertOneBossInvariant(ctx: ReplayContext): OneBossResult { + const result = checkOneBossInvariant(ctx) + if (result.violations.length > 0) { + const summary = result.violations + .map((v) => `event #${v.eventId} [${v.kind}]: ${v.detail}`) + .join('; ') + throw new Error(`[one-boss] invariant violated (${result.violations.length}): ${summary}`) + } + return result +} diff --git a/hub/src/overseer/replayHarness.test.ts b/hub/src/overseer/replayHarness.test.ts new file mode 100644 index 0000000000..3b1e86434c --- /dev/null +++ b/hub/src/overseer/replayHarness.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'bun:test' +import { join } from 'node:path' +import { + computeEffectivePriority, + countStaleItems, + detectAlarmFlood, + detectContradictions, + findRootCauseEventId, + loadAndReplay, + parseSnapshot, + priorityDistribution, + runPromotionPass +} from './replayHarness' + +const FIXTURE_DIR = join(import.meta.dir, '..', '..', '..', 'test', 'fixtures', 'overseer-replay') + +function fixture(name: string): string { + return join(FIXTURE_DIR, `${name}.json`) +} + +/** Fixed reference epoch baked into the time-relative fixtures. */ +const NOW = 1_700_000_000_000 + +describe('Overseer replay harness — golden scenarios (prioritization §6)', () => { + it('loader replays into a sandbox :memory: DB without a production path', () => { + const ctx = loadAndReplay(fixture('routine-progress-flood')) + expect((ctx.store as unknown as { _dbPath: string })._dbPath).toBe(':memory:') + expect(ctx.store.events.count()).toBe(30) + }) + + it('1: 30 routine progress events surface nothing', () => { + const ctx = loadAndReplay(fixture('routine-progress-flood')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(0) + expect(ctx.store.events.count()).toBe(30) + }) + + it('2: alarm flood — 11 candidates in a 10-min window is detected', () => { + const ctx = loadAndReplay(fixture('alarm-flood')) + const flood = detectAlarmFlood(ctx.store.events.list({ limit: 200 })) + expect(flood.flood).toBe(true) + expect(flood.peakCount).toBe(11) + // routine flood has zero candidates -> no flood + const calm = loadAndReplay(fixture('routine-progress-flood')) + expect(detectAlarmFlood(calm.store.events.list({ limit: 200 })).flood).toBe(false) + }) + + it('3: same-session attention events collapse into one item with merged source_event_ids', () => { + const ctx = loadAndReplay(fixture('same-session-collapse')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(1) + const merged = inbox[0]!.sourceEventIds.slice().sort((a, b) => a - b) + expect(merged).toEqual([ctx.eventIdBySid.get(1)!, ctx.eventIdBySid.get(2)!].sort((a, b) => a - b)) + expect(inbox[0]!.category).toBe('REVIEW') + }) + + it('4: idempotent re-emission is stored once and produces one item', () => { + const ctx = loadAndReplay(fixture('idempotent-reemission')) + expect(ctx.store.events.count()).toBe(1) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(1) + expect(inbox[0]!.category).toBe('QUESTION') + }) + + it('5: blocked_by fan-in — root-cause traversal returns the upstream, not the symptoms', () => { + const ctx = loadAndReplay(fixture('blocked-by-fanin')) + const upstream = ctx.eventIdBySid.get(100)! + for (const symptomSid of [1, 2, 3]) { + const symptom = ctx.eventIdBySid.get(symptomSid)! + expect(findRootCauseEventId(ctx.db, symptom)).toBe(upstream) + } + // substrate is present even though v0 promotion still surfaces per-session + const inbox = runPromotionPass(ctx) + expect(inbox.length).toBeGreaterThanOrEqual(3) + }) + + it('6: approval_requested escalates to the highest coarse priority tier', () => { + const ctx = loadAndReplay(fixture('approval-escalation')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(1) + expect(inbox[0]!.category).toBe('APPROVAL') + expect(inbox[0]!.basePriority).toBe(10) + }) + + it('7: stale-item aging — a 24h+ item is detected and out-prioritizes a fresh higher tier', () => { + const ctx = loadAndReplay(fixture('aging-and-stale')) + const items = ctx.store.inbox.list({ activeOnly: true, limit: 200 }) + expect(countStaleItems(items, NOW)).toBe(1) + + const oldCompleted = computeEffectivePriority(50, 1699910000000, NOW) + const freshReview = computeEffectivePriority(40, 1699999700000, NOW) + expect(oldCompleted).toBeLessThan(freshReview) + + const dist = priorityDistribution(items) + expect(dist.low).toBeGreaterThan(dist.medium) + expect(dist.low).toBeGreaterThan(dist.high) + }) + + it('8: completed with operator action + PR artifact surfaces with the PR handle as title', () => { + const ctx = loadAndReplay(fixture('completed-review-pr')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(1) + expect(inbox[0]!.title).toBe('feat(overseer): replay harness v0') + expect(inbox[0]!.category).toBe('FINALE') + }) + + it('9: completed with no action falls out of the queue but stays queryable', () => { + const ctx = loadAndReplay(fixture('completed-noise')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(0) + expect(ctx.store.events.count()).toBe(1) + expect(ctx.store.events.list({ eventType: 'completed' })).toHaveLength(1) + }) + + it('10 (+1): hub-inferred stale silence is captured-only; worker self-reported stalled promotes', () => { + const ctx = loadAndReplay(fixture('stale-captured-only')) + const inbox = runPromotionPass(ctx) + // both events recorded... + expect(ctx.store.events.count()).toBe(2) + // ...but only the explicit self-report promotes to the inbox + expect(inbox).toHaveLength(1) + const vocalSessionId = ctx.sessionIdByKey.get('vocal')! + expect(inbox[0]!.relatedSessionId).toBe(vocalSessionId) + }) + + it('11: CI/worker contradiction is surfaced, not resolved', () => { + const ctx = loadAndReplay(fixture('ci-contradiction')) + const contradictions = detectContradictions(ctx.db) + expect(contradictions).toHaveLength(1) + expect(contradictions[0]!.failingEventId).toBe(ctx.eventIdBySid.get(1)!) + expect(contradictions[0]!.passingEventId).toBe(ctx.eventIdBySid.get(2)!) + }) + + it('12: operator noise demotion is recorded as a training label and clears the queue', () => { + const ctx = loadAndReplay(fixture('operator-noise-demotion')) + const inbox = runPromotionPass(ctx) + expect(inbox).toHaveLength(1) + const dismissed = ctx.store.inbox.recordOperatorAction(inbox[0]!.id, 'dismiss', 'that was noise') + expect(dismissed!.status).toBe('obsoleted') + expect(ctx.store.inbox.list({ activeOnly: true })).toHaveLength(0) + + const actions = ctx.db.prepare( + 'SELECT action, status_after FROM inbox_operator_actions WHERE inbox_item_id = ?' + ).all(inbox[0]!.id) as Array<{ action: string; status_after: string }> + expect(actions).toEqual([{ action: 'dismiss', status_after: 'obsoleted' }]) + }) +}) + +describe('Overseer replay harness — loader validation', () => { + const base = { + name: 'x', + description: 'x', + sessions: [{ key: 's' }], + events: [ + { sid: 1, ts: 1, sessionKey: 's', sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, summary: 'b' } + ] + } + + it('rejects a dangling event link', () => { + const bad = JSON.stringify({ ...base, eventLinks: [{ fromSid: 1, toSid: 999, relationType: 'blocked_by' }] }) + expect(() => parseSnapshot(bad)).toThrow(/unknown toSid/) + }) + + it('rejects an event referencing an unknown session', () => { + const bad = JSON.stringify({ + ...base, + events: [{ sid: 1, ts: 1, sessionKey: 'ghost', sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, summary: 'b' }] + }) + expect(() => parseSnapshot(bad)).toThrow(/unknown session/) + }) + + it('rejects a duplicate event sid', () => { + const bad = JSON.stringify({ + ...base, + events: [ + { sid: 1, ts: 1, sessionKey: 's', sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, summary: 'b' }, + { sid: 1, ts: 2, sessionKey: 's', sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, summary: 'c' } + ] + }) + expect(() => parseSnapshot(bad)).toThrow(/duplicate event sid/) + }) +}) diff --git a/hub/src/overseer/replayHarness.ts b/hub/src/overseer/replayHarness.ts new file mode 100644 index 0000000000..e6721021f5 --- /dev/null +++ b/hub/src/overseer/replayHarness.ts @@ -0,0 +1,454 @@ +/** + * Overseer replay / evaluation harness (Step 2.75). + * + * The harness loads a captured event-stream snapshot from disk, replays it into + * a sandbox (`:memory:`) Store WITHOUT touching the production DB, runs the + * promotion + prioritization logic once, and exposes the analytic helpers the + * golden-scenario assertions need (alarm-flood / stale / priority-distribution + * KPIs from EEMUA 191, blocked_by root-cause traversal, contradiction + * detection, and the §5 effective-priority scoring sketch). + * + * See docs/plans/2026-06-03-overseer-prioritization.md §6 (replay harness) and + * the golden-scenario table, plus docs/plans/2026-06-03-overseer-build-sequence.md + * Step 2.75. Fixtures are synthetic (contracts §7) and live under + * test/fixtures/overseer-replay/. + */ +import { readFileSync } from 'node:fs' +import { Database } from 'bun:sqlite' +import { + buildOverseerSessionIdentity, + mergeEventPayloadWithSession, + type OverseerSessionIdentity +} from '@hapi/protocol' +import { Store } from '../store' +import type { StoredInboxItem } from '../store/inboxStore' +import type { StoredSystemEvent } from '../store/events' + +/** A single captured event in a snapshot. `sid` is a snapshot-local id used for linking. */ +export type SnapshotEvent = { + sid: number + ts: number + sessionKey?: string | null + sourceKind: 'worker' | 'overseer' | 'operator' | 'system' | 'channel' + sourceRef?: string | null + sinkKind?: string | null + sinkRef?: string | null + eventType: string + attentionCandidate: 0 | 1 + operatorActionRequired?: 0 | 1 + riskDetected?: 0 | 1 + summary: string + artifactRefs?: unknown + dedupeKey?: string | null + idempotencyKey?: string | null + expiresAt?: number | null + provenance?: string | null + confidence?: number | null + severity?: number | null + payload?: Record +} + +export type SnapshotSession = { + key: string + tag?: string | null + flavor?: string + name?: string | null + path?: string | null +} + +export type SnapshotEventLink = { + fromSid: number + toSid: number + relationType: string + metadata?: unknown +} + +/** Pre-seeded baseline inbox item (for aging / stale / distribution scenarios). */ +export type SnapshotInboxItem = { + status: string + basePriority: number + category: string + title: string + summary: string + createdAt: number + sessionKey?: string | null + sourceSids?: number[] + attentionClass?: string +} + +/** + * Worker-facing dispatch records for the one-boss invariant (ADR-001). + * Empty in Step 2.75 fixtures (no dispatch writer yet) -> invariant passes + * vacuously. Step 4 populates these from the real dispatch envelope + messages + * tables and the same assertion shape activates automatically. + */ +export type SnapshotDispatchEnvelope = { + idempotencyKey: string + messageId: string + origin?: string + rationale?: string + relatedEventIds?: number[] + confirmationSource?: string +} + +export type SnapshotWorkerMessage = { + id: string + sessionKey?: string | null + role: string + renderedInstruction: string + metadata?: Record +} + +export type ReplaySnapshot = { + name: string + description: string + sessions: SnapshotSession[] + events: SnapshotEvent[] + eventLinks?: SnapshotEventLink[] + inboxItems?: SnapshotInboxItem[] + dispatchEnvelopes?: SnapshotDispatchEnvelope[] + workerMessages?: SnapshotWorkerMessage[] +} + +export type ReplayContext = { + store: Store + db: Database + snapshot: ReplaySnapshot + /** snapshot-local event id (sid) -> real inserted event id. */ + eventIdBySid: Map + /** snapshot session key -> denormalized identity used in event payloads. */ + identityByKey: Map + /** snapshot session key -> real session id. */ + sessionIdByKey: Map +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(`[overseer-replay] invalid snapshot: ${message}`) + } +} + +/** Parse + structurally validate a snapshot JSON file. Throws on malformed input. */ +export function parseSnapshot(raw: string): ReplaySnapshot { + const parsed = JSON.parse(raw) as Partial + assert(typeof parsed.name === 'string', 'missing name') + assert(typeof parsed.description === 'string', 'missing description') + assert(Array.isArray(parsed.sessions), 'sessions must be an array') + assert(Array.isArray(parsed.events), 'events must be an array') + + const sessionKeys = new Set() + for (const session of parsed.sessions!) { + assert(typeof session.key === 'string' && session.key.length > 0, 'session.key required') + assert(!sessionKeys.has(session.key), `duplicate session key ${session.key}`) + sessionKeys.add(session.key) + } + + const sids = new Set() + for (const event of parsed.events!) { + assert(typeof event.sid === 'number', 'event.sid required') + assert(!sids.has(event.sid), `duplicate event sid ${event.sid}`) + sids.add(event.sid) + assert(typeof event.ts === 'number', `event ${event.sid} missing ts`) + assert(typeof event.eventType === 'string', `event ${event.sid} missing eventType`) + assert( + event.attentionCandidate === 0 || event.attentionCandidate === 1, + `event ${event.sid} attentionCandidate must be 0 or 1` + ) + if (event.sessionKey != null) { + assert(sessionKeys.has(event.sessionKey), `event ${event.sid} references unknown session ${event.sessionKey}`) + } + } + + for (const link of parsed.eventLinks ?? []) { + assert(sids.has(link.fromSid), `link references unknown fromSid ${link.fromSid}`) + assert(sids.has(link.toSid), `link references unknown toSid ${link.toSid}`) + assert(typeof link.relationType === 'string', 'link.relationType required') + } + + return parsed as ReplaySnapshot +} + +export function loadSnapshot(path: string): ReplaySnapshot { + return parseSnapshot(readFileSync(path, 'utf8')) +} + +function serializeArtifactRefs(refs: unknown): string | null { + if (refs == null) return null + if (typeof refs === 'string') return refs + return JSON.stringify(refs) +} + +/** + * Replay a snapshot into a fresh sandbox Store. Never touches the production DB: + * the Store is constructed with `:memory:`. + */ +export function replaySnapshot(snapshot: ReplaySnapshot): ReplayContext { + const store = new Store(':memory:') + const db = (store as unknown as { db: Database }).db + + const identityByKey = new Map() + const sessionIdByKey = new Map() + + for (const session of snapshot.sessions) { + const metadata = { + flavor: session.flavor ?? 'claude', + name: session.name ?? undefined, + path: session.path ?? undefined + } + const created = store.sessions.getOrCreateSession( + session.tag ?? session.key, + metadata, + null, + 'default' + ) + sessionIdByKey.set(session.key, created.id) + identityByKey.set( + session.key, + buildOverseerSessionIdentity({ + id: created.id, + flavor: session.flavor ?? 'claude', + tag: session.tag ?? null, + metadata + }) + ) + } + + // Replay in chronological (stream) order; preserve array order for ties. + const ordered = snapshot.events + .map((event, index) => ({ event, index })) + .sort((a, b) => (a.event.ts - b.event.ts) || (a.index - b.index)) + + const eventIdBySid = new Map() + + for (const { event } of ordered) { + const sessionId = event.sessionKey ? sessionIdByKey.get(event.sessionKey) ?? null : null + const identity = event.sessionKey ? identityByKey.get(event.sessionKey) : undefined + const payloadJson = identity + ? mergeEventPayloadWithSession(event.payload ?? {}, identity) + : event.payload + ? JSON.stringify(event.payload) + : null + + const stored = store.events.insert({ + ts: event.ts, + sourceKind: event.sourceKind, + sourceRef: event.sourceRef ?? null, + sinkKind: event.sinkKind ?? null, + sinkRef: event.sinkRef ?? null, + eventType: event.eventType, + attentionCandidate: event.attentionCandidate, + operatorActionRequired: event.operatorActionRequired ?? 0, + riskDetected: event.riskDetected ?? 0, + summary: event.summary, + payloadJson, + artifactRefs: serializeArtifactRefs(event.artifactRefs), + relatedSessionId: sessionId, + dedupeKey: event.dedupeKey ?? null, + expiresAt: event.expiresAt ?? null, + provenance: event.provenance ?? null, + idempotencyKey: event.idempotencyKey ?? null, + confidence: event.confidence ?? null, + severity: event.severity ?? null + }) + assert(stored, `event ${event.sid} failed to insert`) + eventIdBySid.set(event.sid, stored.id) + } + + for (const link of snapshot.eventLinks ?? []) { + store.events.linkEvents({ + fromEventId: eventIdBySid.get(link.fromSid)!, + toEventId: eventIdBySid.get(link.toSid)!, + relationType: link.relationType, + createdAt: Date.now(), + metadataJson: link.metadata != null ? JSON.stringify(link.metadata) : null + }) + } + + for (const item of snapshot.inboxItems ?? []) { + const sessionId = item.sessionKey ? sessionIdByKey.get(item.sessionKey) ?? null : null + const sourceEventIds = (item.sourceSids ?? []) + .map((sid) => eventIdBySid.get(sid)) + .filter((id): id is number => typeof id === 'number') + db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + attention_class, created_at, updated_at, related_session_id, title, category, summary + ) VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?, ?, ?, ?) + `).run( + item.status, + item.basePriority, + item.basePriority, + JSON.stringify(sourceEventIds), + item.attentionClass ?? 'live', + item.createdAt, + item.createdAt, + sessionId, + item.title, + item.category, + item.summary + ) + } + + return { store, db, snapshot, eventIdBySid, identityByKey, sessionIdByKey } +} + +/** + * Run-once promotion + prioritization entry point. Promotes every replayed + * attention-candidate event into the inbox (mirroring the recorder's + * insert-time promotion) and returns the resulting active inbox queue. + */ +export function runPromotionPass(ctx: ReplayContext): StoredInboxItem[] { + const events = ctx.store.events.list({ limit: 200 }) + // events.list returns newest-first; promote oldest-first to mimic the stream. + const chronological = [...events].sort((a, b) => a.id - b.id) + for (const event of chronological) { + if (event.attentionCandidate === 1) { + ctx.store.inbox.promoteAttentionEvent(event) + } + } + return ctx.store.inbox.list({ activeOnly: true, limit: 200 }) +} + +export function loadAndReplay(path: string): ReplayContext { + return replaySnapshot(loadSnapshot(path)) +} + +// --------------------------------------------------------------------------- +// Analytic helpers — KPIs (EEMUA 191 / ISA-18.2) + §5 scoring sketch. +// --------------------------------------------------------------------------- + +export const ALARM_FLOOD_WINDOW_MS = 10 * 60 * 1000 +export const ALARM_FLOOD_THRESHOLD = 10 +export const STALE_ITEM_THRESHOLD_MS = 24 * 60 * 60 * 1000 + +/** + * §5 effective-priority sketch (v0). Lower number = more urgent. Aging bumps an + * item's urgency over time (classical OS aging / starvation prevention) by + * subtracting an age-proportional amount from the coarse base, floored so a + * routine item can never overtake a genuine APPROVAL/BLOCKED tier. + */ +export function computeEffectivePriority( + basePriority: number, + createdAt: number, + now: number = Date.now(), + agingSlopePerHour = 2, + maxAgingBump = 45 +): number { + const ageHours = Math.max(0, (now - createdAt) / 3_600_000) + const bump = Math.min(maxAgingBump, ageHours * agingSlopePerHour) + return basePriority - bump +} + +export type AlarmFloodResult = { + flood: boolean + peakCount: number + windowMs: number + threshold: number +} + +/** Sliding-window surface-rate / alarm-flood detection over attention candidates. */ +export function detectAlarmFlood( + events: StoredSystemEvent[], + windowMs: number = ALARM_FLOOD_WINDOW_MS, + threshold: number = ALARM_FLOOD_THRESHOLD +): AlarmFloodResult { + const candidates = events + .filter((e) => e.attentionCandidate === 1) + .map((e) => e.ts) + .sort((a, b) => a - b) + + let peak = 0 + let start = 0 + for (let end = 0; end < candidates.length; end += 1) { + while (candidates[end] - candidates[start] >= windowMs) start += 1 + peak = Math.max(peak, end - start + 1) + } + return { flood: peak > threshold, peakCount: peak, windowMs, threshold } +} + +export function countStaleItems( + items: StoredInboxItem[], + now: number = Date.now(), + thresholdMs: number = STALE_ITEM_THRESHOLD_MS +): number { + return items.filter( + (item) => isActiveStatus(item.status) && now - item.createdAt >= thresholdMs + ).length +} + +function isActiveStatus(status: string): boolean { + return status === 'new' || status === 'surfaced' || status === 'deferred' || status === 'snoozed' +} + +export type PriorityBucket = 'high' | 'medium' | 'low' + +export function priorityBucket(basePriority: number): PriorityBucket { + if (basePriority <= 20) return 'high' + if (basePriority <= 45) return 'medium' + return 'low' +} + +export function priorityDistribution(items: StoredInboxItem[]): Record { + const dist: Record = { high: 0, medium: 0, low: 0 } + for (const item of items) dist[priorityBucket(item.basePriority)] += 1 + return dist +} + +type EventLinkRow = { from_event_id: number; to_event_id: number } + +/** + * Walk `relation` edges (default `blocked_by`) from a symptom event up to the + * terminal upstream event — the root cause. Surfacing the root, not the + * symptoms, is the prioritization §6 requirement for the fan-in blocked case. + * Cycle-safe. + */ +export function findRootCauseEventId( + db: Database, + fromEventId: number, + relation = 'blocked_by' +): number { + const stmt = db.prepare( + 'SELECT from_event_id, to_event_id FROM event_links WHERE from_event_id = ? AND relation_type = ?' + ) + const seen = new Set([fromEventId]) + let current = fromEventId + for (;;) { + const next = stmt.get(current, relation) as EventLinkRow | undefined + if (!next || seen.has(next.to_event_id)) break + seen.add(next.to_event_id) + current = next.to_event_id + } + return current +} + +export type Contradiction = { + sessionId: string + failingEventId: number + passingEventId: number + note: string +} + +/** + * Detect sessions that report both failure and success without resolution. + * The Overseer must SURFACE the contradiction, not silently pick a winner + * (prioritization §6). Returns descriptors only — no resolution. + */ +export function detectContradictions(db: Database): Contradiction[] { + const rows = db.prepare(` + SELECT f.related_session_id AS sid, f.id AS failing, c.id AS passing + FROM events f + JOIN events c + ON c.related_session_id = f.related_session_id + WHERE f.related_session_id IS NOT NULL + AND f.event_type IN ('failed', 'blocked') + AND c.event_type = 'completed' + AND c.ts >= f.ts + `).all() as Array<{ sid: string; failing: number; passing: number }> + + return rows.map((row) => ({ + sessionId: row.sid, + failingEventId: row.failing, + passingEventId: row.passing, + note: 'failure and completion reported for same session; surface both, resolve neither' + })) +} diff --git a/hub/src/socket/handlers/cli/machineHandlers.ts b/hub/src/socket/handlers/cli/machineHandlers.ts index 43c0555f6a..4c98d40eb1 100644 --- a/hub/src/socket/handlers/cli/machineHandlers.ts +++ b/hub/src/socket/handlers/cli/machineHandlers.ts @@ -9,6 +9,7 @@ import type { AccessErrorReason, AccessResult } from './types' type MachineAlivePayload = { machineId: string time: number + health?: unknown } type ResolveMachineAccess = (machineId: string) => AccessResult diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 02728afaed..e22280350c 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -40,7 +40,7 @@ export type SocketServerDeps = { onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void - onMachineAlive?: (payload: { machineId: string; time: number }) => void + onMachineAlive?: (payload: { machineId: string; time: number; health?: unknown }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 0e18a859dd..6b12365897 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -33,9 +33,10 @@ export class SessionStore { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + requestedId?: string ): StoredSession { - return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, requestedId) } updateSessionMetadata( diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts index f00a037793..fb7f983c57 100644 --- a/hub/src/store/sessions.test.ts +++ b/hub/src/store/sessions.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'bun:test' import { Store } from './index' +import { randomUUID } from 'node:crypto' +import { SessionIdentityConflictError } from './sessions' function makeStore(): Store { return new Store(':memory:') @@ -10,6 +12,85 @@ function getMetadata(store: Store, id: string): Record | null { return (row?.metadata ?? null) as Record | null } +describe('getOrCreateSession: active_at', () => { + it('persists a non-null active_at on insert (never NULL)', () => { + const store = makeStore() + const created = store.sessions.getOrCreateSession( + 'active-at-write', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + + expect(typeof created.activeAt).toBe('number') + expect(created.activeAt).not.toBeNull() + expect(created.activeAt).toBe(created.createdAt) + + const reloaded = store.sessions.getSession(created.id) + expect(reloaded?.activeAt).toBe(created.createdAt) + store.close() + }) +}) + +describe('getOrCreateSession: requested identity', () => { + it('creates and idempotently reloads a client-requested id', () => { + const store = makeStore() + const requestedId = randomUUID() + + const created = store.sessions.getOrCreateSession( + 'lazy-session-tag', + { path: '/tmp/project' }, + { controlledByUser: true }, + 'default', + undefined, + undefined, + undefined, + requestedId + ) + const reloaded = store.sessions.getOrCreateSession( + 'lazy-session-tag', + { path: '/tmp/ignored' }, + null, + 'default', + undefined, + undefined, + undefined, + requestedId + ) + + expect(created.id).toBe(requestedId) + expect(reloaded.id).toBe(requestedId) + expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(1) + store.close() + }) + + it('rejects a tag already bound to another requested id', () => { + const store = makeStore() + const firstId = randomUUID() + store.sessions.getOrCreateSession( + 'conflicting-tag', {}, null, 'default', undefined, undefined, undefined, firstId + ) + + expect(() => store.sessions.getOrCreateSession( + 'conflicting-tag', {}, null, 'default', undefined, undefined, undefined, randomUUID() + )).toThrow(SessionIdentityConflictError) + store.close() + }) + + it('rejects a requested id already bound to another tag', () => { + const store = makeStore() + const requestedId = randomUUID() + store.sessions.getOrCreateSession( + 'first-tag', {}, null, 'default', undefined, undefined, undefined, requestedId + ) + + expect(() => store.sessions.getOrCreateSession( + 'second-tag', {}, null, 'default', undefined, undefined, undefined, requestedId + )).toThrow(SessionIdentityConflictError) + store.close() + }) +}) + describe('updateSessionMetadata: protocol resume token preservation', () => { it('preserves cursorSessionId when archive payload omits it (Cursor crash-archive)', () => { const store = makeStore() @@ -91,6 +172,7 @@ describe('updateSessionMetadata: protocol resume token preservation', () => { ['codexSessionId', 'codex-thread-x'], ['geminiSessionId', 'gemini-thread-x'], ['opencodeSessionId', 'opencode-thread-x'], + ['grokSessionId', 'grok-thread-x'], ['cursorSessionId', 'cursor-thread-x'], ['kimiSessionId', 'kimi-thread-x'] ])('preserves %s across an archive metadata replacement', (field, value) => { diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 537216c0ca..406e473a59 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -58,6 +58,7 @@ const SIMPLE_RESUME_TOKENS = [ 'codexSessionId', 'geminiSessionId', 'opencodeSessionId', + 'grokSessionId', 'cursorSessionId', 'kimiSessionId' ] as const @@ -187,18 +188,32 @@ export function getOrCreateSession( namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + requestedId?: string ): StoredSession { const existing = db.prepare( 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' ).get(tag, namespace) as DbSessionRow | undefined if (existing) { + if (requestedId && existing.id !== requestedId) { + throw new SessionIdentityConflictError('Session tag is already bound to a different id') + } return toStoredSession(existing) } const now = Date.now() - const id = randomUUID() + const id = requestedId ?? randomUUID() + + if (requestedId) { + const existingById = getSession(db, requestedId) + if (existingById) { + if (existingById.namespace === namespace && existingById.tag === tag) { + return existingById + } + throw new SessionIdentityConflictError('Session id is already bound to a different session') + } + } const metadataJson = JSON.stringify(metadata) const agentStateJson = agentState === null || agentState === undefined ? null : JSON.stringify(agentState) @@ -221,7 +236,7 @@ export function getOrCreateSession( @model_reasoning_effort, @effort, NULL, NULL, - 0, NULL, 0 + 0, @active_at, 0 ) `).run({ id, @@ -229,6 +244,9 @@ export function getOrCreateSession( namespace, created_at: now, updated_at: now, + // Never persist NULL — CLI SessionSchema requires numeric activeAt. + // Legacy rows may still be NULL; sessionCache coerces on read. + active_at: now, metadata: metadataJson, agent_state: agentStateJson, model: model ?? null, @@ -243,6 +261,13 @@ export function getOrCreateSession( return row } +export class SessionIdentityConflictError extends Error { + constructor(message: string) { + super(message) + this.name = 'SessionIdentityConflictError' + } +} + export function updateSessionMetadata( db: Database, id: string, diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index a218f493e4..abc02076d3 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -64,6 +64,55 @@ describe('alive incremental events', () => { expect(update.data).toEqual(expect.objectContaining({ id: machine.id, active: true })) }) + it('stores health from machine alive and rebroadcasts when it changes', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new MachineCache(store, createPublisher(events)) + + const machine = cache.getOrCreateMachine( + 'machine-health-test', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now(), + health: { + collectedAt: Date.now(), + load1m: 0.4, + cpuCount: 8, + memoryPercent: 55 + } + }) + + const updated = cache.getMachine(machine.id) + expect(updated?.health).toEqual(expect.objectContaining({ load1m: 0.4, cpuCount: 8 })) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now() + 1, + health: { + collectedAt: Date.now() + 1, + load1m: 2.1, + cpuCount: 8, + memoryPercent: 80 + } + }) + + const healthUpdate = events.find((event) => event.type === 'machine-updated') + expect(healthUpdate).toBeDefined() + if (!healthUpdate || healthUpdate.type !== 'machine-updated' || !healthUpdate.data || typeof healthUpdate.data !== 'object') { + return + } + expect(healthUpdate.data).toEqual(expect.objectContaining({ + health: expect.objectContaining({ load1m: 2.1, memoryPercent: 80 }) + })) + }) + it('marks session thinking immediately when a user message is accepted by the hub', async () => { const store = new Store(':memory:') const emittedSocketUpdates: unknown[] = [] diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index 750db3e587..1579fd75b2 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -1,9 +1,38 @@ import type { Machine, MachinePatch } from '@hapi/protocol/types' -import { MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' +import { MachineHealthSchema, MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' +type MachineAlivePayload = { + machineId: string + time: number + health?: unknown +} + +function parseMachineHealth(value: unknown): Machine['health'] { + const parsed = MachineHealthSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function healthDisplayChanged( + before: Machine['health'] | undefined, + after: Machine['health'] | null | undefined +): boolean { + if (!before && !after) { + return false + } + if (!before || !after) { + return true + } + + return before.load1m !== after.load1m + || before.cpuPercent !== after.cpuPercent + || before.memoryPercent !== after.memoryPercent + || before.cpuCount !== after.cpuCount + || before.uptimeSeconds !== after.uptimeSeconds +} + export class MachineCache { private readonly machines: Map = new Map() private readonly lastBroadcastAtByMachineId: Map = new Map() @@ -93,7 +122,8 @@ export class MachineCache { metadata, metadataVersion: stored.metadataVersion, runnerState, - runnerStateVersion: stored.runnerStateVersion + runnerStateVersion: stored.runnerStateVersion, + health: existing?.health ?? null } this.machines.set(machineId, machine) @@ -108,7 +138,7 @@ export class MachineCache { } } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: MachineAlivePayload): void { const t = clampAliveTime(payload.time) if (!t) return @@ -116,12 +146,21 @@ export class MachineCache { if (!machine) return const wasActive = machine.active + const previousHealth = machine.health ?? null machine.active = true machine.activeAt = Math.max(machine.activeAt, t) + if (payload.health !== undefined) { + machine.health = parseMachineHealth(payload.health) + } + const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtByMachineId.get(machine.id) ?? 0 - const shouldBroadcast = (!wasActive && machine.active) || (now - lastBroadcastAt > 10_000) + const healthChanged = payload.health !== undefined + && healthDisplayChanged(previousHealth, machine.health) + const shouldBroadcast = (!wasActive && machine.active) + || healthChanged + || (now - lastBroadcastAt > 10_000) if (shouldBroadcast) { this.lastBroadcastAtByMachineId.set(machine.id, now) this.publisher.emit({ type: 'machine-updated', machineId: machine.id, data: machine }) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 5a63f4547c..482167cb69 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -10,6 +10,8 @@ import type { DirectoryEntry, FileReadResponse, GeneratedImageResponse, + GrokModelsResponse, + GrokReasoningEffortResponse, ListDirectoryResponse, OpencodeModelsResponse, OpencodeModelSummary, @@ -59,6 +61,8 @@ export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse +export type RpcListGrokModelsResponse = GrokModelsResponse +export type RpcListGrokReasoningEffortOptionsResponse = GrokReasoningEffortResponse export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse export class RpcGateway { @@ -282,6 +286,18 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + async listGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListGrokModelsForCwd, { cwd }) as RpcListGrokModelsResponse + } + + async listGrokModelsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListGrokModels, {}) as RpcListGrokModelsResponse + } + + async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListGrokReasoningEffortOptions, {}) as RpcListGrokReasoningEffortOptionsResponse + } + /** 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 { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 304b01ccae..e056bbf18b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -75,9 +75,19 @@ export class SessionCache { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + requestedId?: string ): Session { - const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + const stored = this.store.sessions.getOrCreateSession( + tag, + metadata, + agentState, + namespace, + model, + effort, + modelReasoningEffort, + requestedId + ) return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })() } @@ -140,7 +150,13 @@ export class SessionCache { createdAt: stored.createdAt, updatedAt: stored.updatedAt, active: existing?.active ?? stored.active, - activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt), + // Legacy / idle rows may still have active_at NULL in SQLite. + // Public Session.activeAt is always a number for CLI Zod parse. + activeAt: existing?.activeAt + ?? stored.activeAt + ?? stored.updatedAt + ?? stored.createdAt + ?? 0, metadata, metadataVersion: stored.metadataVersion, agentState, @@ -1082,11 +1098,12 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | '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.grokSessionId) return { field: 'grokSessionId', value: metadata.grokSessionId } 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 8fa18c074a..7818b30f72 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1450,6 +1450,47 @@ describe('session model', () => { } }) + it('resolves a local resume target for a Grok session', () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'local-resume-grok', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'grok', + grokSessionId: 'grok-session-1' + }, + { controlledByUser: false }, + 'default', + 'grok-4.5', + 'low' + ) + + const result = engine.resolveLocalResumeTarget(session.id, 'default') + + expect(result.type).toBe('success') + if (result.type === 'success') { + expect(result.target).toMatchObject({ + flavor: 'grok', + agentSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low' + }) + } + } finally { + engine.stop() + } + }) + it('recovers a Claude local resume target from stored messages', () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d59f58fd47..1ca672dbc2 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -32,6 +32,8 @@ import { type RpcListCodexModelsResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcListGrokModelsResponse, + type RpcListGrokReasoningEffortOptionsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, type RpcOpencodeModel, @@ -53,6 +55,8 @@ export type { RpcListCodexModelsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcListGrokModelsResponse, + RpcListGrokReasoningEffortOptionsResponse, RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, RpcOpencodeModel, @@ -354,7 +358,7 @@ export class SyncEngine { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) } @@ -387,9 +391,19 @@ export class SyncEngine { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + requestedId?: string ): Session { - return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + return this.sessionCache.getOrCreateSession( + tag, + metadata, + agentState, + namespace, + model, + effort, + modelReasoningEffort, + requestedId + ) } getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { @@ -753,6 +767,7 @@ export class SyncEngine { if (flavor === 'codex') return metadata.codexSessionId ?? null if (flavor === 'gemini') return metadata.geminiSessionId ?? null if (flavor === 'opencode') return metadata.opencodeSessionId ?? null + if (flavor === 'grok') return metadata.grokSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null if (flavor === 'pi') return metadata.piSessionId ?? null @@ -1459,6 +1474,7 @@ export class SyncEngine { && (prev?.claudeSessionId ?? null) === (next.claudeSessionId ?? null) && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) + && (prev?.grokSessionId ?? null) === (next.grokSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) @@ -1605,6 +1621,18 @@ export class SyncEngine { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + async listGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.rpcGateway.listGrokModelsForCwd(machineId, cwd) + } + + async listGrokModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listGrokModelsForSession(sessionId) + } + + async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.rpcGateway.listGrokReasoningEffortOptionsForSession(sessionId) + } + /** 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) diff --git a/hub/src/web/qwenProxyHandler.test.ts b/hub/src/web/qwenProxyHandler.test.ts index 337bd069ba..8816ac9164 100644 --- a/hub/src/web/qwenProxyHandler.test.ts +++ b/hub/src/web/qwenProxyHandler.test.ts @@ -53,6 +53,7 @@ class FakeClient { } let lastUpstream: FakeUpstream | null = null +const origQwenRealtimeWsUrl = process.env.QWEN_REALTIME_WS_URL const FakeWebSocket = function FakeWebSocket(url: string, opts?: unknown) { const u = new FakeUpstream(url, opts) lastUpstream = u @@ -65,13 +66,47 @@ beforeEach(() => { afterEach(() => { lastUpstream = null + if (origQwenRealtimeWsUrl === undefined) delete process.env.QWEN_REALTIME_WS_URL + else process.env.QWEN_REALTIME_WS_URL = origQwenRealtimeWsUrl }) function newClient() { - return new FakeClient({ apiKey: 'k', model: 'qwen3-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket & FakeClient + return new FakeClient({ apiKey: 'k', model: 'qwen3.5-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket & FakeClient } describe('createQwenProxyWebSocketHandler ack-gate', () => { + test('preserves the international DashScope realtime endpoint by default', () => { + delete process.env.QWEN_REALTIME_WS_URL + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe('wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-flash-realtime') + }) + + test('allows overriding the DashScope realtime endpoint with QWEN_REALTIME_WS_URL', () => { + process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime' + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe('wss://example.test/realtime?model=qwen3.5-omni-flash-realtime') + }) + + test('preserves existing query parameters in QWEN_REALTIME_WS_URL', () => { + process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime?workspace=abc&model=old' + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe( + 'wss://example.test/realtime?workspace=abc&model=qwen3.5-omni-flash-realtime' + ) + }) + test('queues client frames until upstream acks hub-owned session.update with session.updated', () => { const handler = createQwenProxyWebSocketHandler(FakeWebSocket) const client = newClient() @@ -85,6 +120,7 @@ describe('createQwenProxyWebSocketHandler ack-gate', () => { const hubSetup = JSON.parse(upstream.sent[0] as string) as { type: string; session: { instructions: string } } expect(hubSetup.type).toBe('session.update') expect(typeof hubSetup.session.instructions).toBe('string') + expect(hubSetup).not.toHaveProperty('session.tool_choice') handler.message(client, JSON.stringify({ type: 'response.create' })) handler.message(client, JSON.stringify({ type: 'conversation.item.create', item: { type: 'message' } })) diff --git a/hub/src/web/qwenProxyHandler.ts b/hub/src/web/qwenProxyHandler.ts index 8c1cd18bdf..c0abb66c96 100644 --- a/hub/src/web/qwenProxyHandler.ts +++ b/hub/src/web/qwenProxyHandler.ts @@ -53,9 +53,11 @@ export function createQwenProxyWebSocketHandler( return { open(clientWs) { const data = clientWs.data as { apiKey: string; model: string; language?: string; voiceName?: string; systemInstruction?: string } - const upstreamUrl = `${process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE}?model=${encodeURIComponent(data.model)}` + const upstreamBase = process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE + const upstreamUrl = new URL(upstreamBase) + upstreamUrl.searchParams.set('model', data.model) - const upstream = new WebSocketImpl(upstreamUrl, { + const upstream = new WebSocketImpl(upstreamUrl.toString(), { headers: { 'Authorization': `Bearer ${data.apiKey}` } }) @@ -106,12 +108,17 @@ export function createQwenProxyWebSocketHandler( } } catch { /* client gone */ } } - upstream.onerror = () => { + upstream.onerror = (event) => { pendingSetupMap.delete(clientWs) setupAckedMap.delete(clientWs) pendingClientFrames.delete(clientWs) pendingClientBytes.delete(clientWs) upstreamMap.delete(clientWs) + console.error('[Voice][QwenProxy] Upstream WebSocket error', { + upstreamBase, + model: data.model, + error: event instanceof Error ? event.message : String(event) + }) try { clientWs.close(1011, 'Upstream error') } catch { /* */ } } upstream.onclose = (event) => { @@ -119,6 +126,14 @@ export function createQwenProxyWebSocketHandler( setupAckedMap.delete(clientWs) pendingClientFrames.delete(clientWs) pendingClientBytes.delete(clientWs) + if (event.code !== 1000) { + console.warn('[Voice][QwenProxy] Upstream WebSocket closed', { + upstreamBase, + model: data.model, + code: event.code, + reason: event.reason + }) + } try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* */ } upstreamMap.delete(clientWs) } diff --git a/hub/src/web/routes/cli.activeAt.test.ts b/hub/src/web/routes/cli.activeAt.test.ts new file mode 100644 index 0000000000..a8b7267674 --- /dev/null +++ b/hub/src/web/routes/cli.activeAt.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { createConfiguration } from '../../configuration' +import { Store } from '../../store' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { SyncEngine } from '../../sync/syncEngine' +import { createCliRoutes } from './cli' + +function createEngine(store: Store): SyncEngine { + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + engine.stop() + return engine +} + +function authHeaders() { + return { + authorization: 'Bearer test-token' + } +} + +function nullOutActiveAt(store: Store, sessionId: string): void { + const db = (store as unknown as { db: Database }).db + db.prepare('UPDATE sessions SET active_at = NULL WHERE id = ?').run(sessionId) +} + +beforeAll(async () => { + const config = await createConfiguration() + config._setCliApiToken('test-token', 'env', false) +}) + +describe('CLI GET /sessions/:id with null active_at', () => { + it('returns numeric activeAt coerced from createdAt', async () => { + const store = new Store(':memory:') + const engine = createEngine(store) + const created = engine.getOrCreateSession( + 'null-active-at-cli', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + nullOutActiveAt(store, created.id) + expect(store.sessions.getSession(created.id)?.activeAt).toBeNull() + + // Simulate hub restart so cache reloads from the NULL row. + const reloaded = createEngine(store) + const app = new Hono() + app.route('/cli', createCliRoutes(() => reloaded)) + + const response = await app.request(`/cli/sessions/${created.id}`, { + headers: authHeaders() + }) + + expect(response.status).toBe(200) + const body = await response.json() as { + session: { activeAt: unknown; createdAt: number; updatedAt: number } + } + expect(typeof body.session.activeAt).toBe('number') + expect(body.session.activeAt).toBe(created.createdAt) + store.close() + }) +}) diff --git a/hub/src/web/routes/cli.test.ts b/hub/src/web/routes/cli.test.ts index 448a924968..637f95bb93 100644 --- a/hub/src/web/routes/cli.test.ts +++ b/hub/src/web/routes/cli.test.ts @@ -1,8 +1,9 @@ -import { beforeAll, describe, expect, it } from 'bun:test' +import { beforeAll, describe, expect, it, mock } from 'bun:test' import { Hono } from 'hono' import type { SyncEngine } from '../../sync/syncEngine' import { createConfiguration } from '../../configuration' import { createCliRoutes } from './cli' +import { SessionIdentityConflictError } from '../../store/sessions' function createApp(engine: Partial) { const app = new Hono() @@ -114,3 +115,104 @@ describe('cli resume routes', () => { }) }) }) + +describe('cli lazy session creation', () => { + const sessionId = '11111111-1111-4111-8111-111111111111' + + it('creates the machine and requested session identity in one request', async () => { + const getOrCreateMachine = mock(() => ({ id: 'machine-1' })) + const getOrCreateSession = mock(() => ({ id: sessionId })) + const app = createApp({ + getMachine: () => null, + getOrCreateMachine, + getOrCreateSession + } as never) + + const response = await app.request('/cli/sessions', { + method: 'POST', + headers: { + ...authHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + id: sessionId, + tag: 'lazy-tag', + metadata: { path: '/tmp/project' }, + agentState: { controlledByUser: true }, + machine: { + id: 'machine-1', + metadata: { host: 'localhost' } + } + }) + }) + + expect(response.status).toBe(200) + expect(getOrCreateMachine).toHaveBeenCalledWith( + 'machine-1', + { host: 'localhost' }, + null, + 'default' + ) + expect(getOrCreateSession).toHaveBeenCalledWith( + 'lazy-tag', + { path: '/tmp/project' }, + { controlledByUser: true }, + 'default', + undefined, + undefined, + undefined, + sessionId + ) + }) + + it('rejects an embedded machine owned by another namespace', async () => { + const getOrCreateMachine = mock(() => ({ id: 'machine-1' })) + const getOrCreateSession = mock(() => ({ id: sessionId })) + const app = createApp({ + getMachine: () => ({ id: 'machine-1', namespace: 'other' }), + getOrCreateMachine, + getOrCreateSession + } as never) + + const response = await app.request('/cli/sessions', { + method: 'POST', + headers: { + ...authHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + id: sessionId, + tag: 'lazy-tag', + metadata: {}, + machine: { id: 'machine-1', metadata: {} } + }) + }) + + expect(response.status).toBe(403) + expect(getOrCreateMachine).not.toHaveBeenCalled() + expect(getOrCreateSession).not.toHaveBeenCalled() + }) + + it('returns 409 for a requested identity conflict', async () => { + const app = createApp({ + getOrCreateSession: () => { + throw new SessionIdentityConflictError('Session tag is already bound to a different id') + } + }) + + const response = await app.request('/cli/sessions', { + method: 'POST', + headers: { + ...authHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + id: sessionId, + tag: 'lazy-tag', + metadata: {} + }) + }) + + expect(response.status).toBe(409) + }) +}) diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 30a24124bc..fc0abe4372 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -10,6 +10,7 @@ import { getConfiguration } from '../../configuration' import { constantTimeEquals } from '../../utils/crypto' import { parseAccessToken } from '../../utils/accessToken' import type { Machine, Session, SyncEngine } from '../../sync/syncEngine' +import { SessionIdentityConflictError } from '../../store/sessions' const bearerSchema = z.string().regex(/^Bearer\s+(.+)$/i) @@ -94,16 +95,38 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index 9b4324ea3d..33bbfcc7be 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -46,6 +46,276 @@ function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\ writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') } +function createMirroredTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'mirrored user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'mirrored user message\n' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createMirroredAgentMessageTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'agent_message', message: 'duplicated assistant message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'duplicated assistant message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createInjectedResponseUserTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `# AGENTS.md instructions for /repo +ignore` }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: ` +2026-07-07 +workspace +` }] + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'real event user message' } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createEmbeddedEnvironmentPromptTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `Please inspect this transcript block: + +2026-07-07 + +This is part of my prompt.` }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createSameSourceDuplicateTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'same source user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'same source user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'same source assistant message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'same source assistant message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createRepeatedSameTextDistinctTurnTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'repeat user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'repeat user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'assistant answer' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'repeat user message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createMirroredAndDistinctUserTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'first user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'first user message' }] + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'second user message' } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function writeSessionIndex(codexHome: string, records: Array<{ id: string; thread_name: string; updated_at: string }>): void { + writeFileSync( + join(codexHome, 'session_index.jsonl'), + records.map((record) => JSON.stringify(record)).join('\n') + '\n', + 'utf-8' + ) +} + function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine { return { id, @@ -163,6 +433,403 @@ describe('Codex Desktop import routes', () => { } }) + it('deduplicates mirrored event_msg and response_item user messages', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-')) + const store = new Store(':memory:') + const codexSessionId = '66666666-6666-4666-8666-666666666666' + process.env.CODEX_HOME = codexHome + + try { + createMirroredTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'user', + content: { + type: 'text', + text: 'mirrored user message' + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('deduplicates adjacent mirrored agent messages with different ids', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-agent-mirror-test-')) + const store = new Store(':memory:') + const codexSessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.env.CODEX_HOME = codexHome + + try { + createMirroredAgentMessageTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { + type: 'message', + message: 'duplicated assistant message', + id: expect.any(String) + } + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('skips injected response_item user context messages', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-injected-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '88888888-8888-4888-8888-888888888888' + process.env.CODEX_HOME = codexHome + + try { + createInjectedResponseUserTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'user', + content: { + type: 'text', + text: 'real event user message' + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('preserves a real response_item user prompt containing embedded environment_context text', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-embedded-env-test-')) + const store = new Store(':memory:') + const codexSessionId = 'abababab-abab-4bab-8bab-abababababab' + process.env.CODEX_HOME = codexHome + + try { + createEmbeddedEnvironmentPromptTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: expect.stringContaining('') + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('preserves adjacent duplicate messages from the same transcript source', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-same-source-duplicate-test-')) + const store = new Store(':memory:') + const codexSessionId = 'babababa-baba-4aba-8aba-babababababa' + process.env.CODEX_HOME = codexHome + + try { + createSameSourceDuplicateTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(4) + expect(messages.map((message) => (message.content as { role?: unknown }).role)).toEqual([ + 'user', + 'user', + 'agent', + 'agent' + ]) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps a later response_item-only user turn with the same text', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-repeat-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '99999999-9999-4999-8999-999999999999' + process.env.CODEX_HOME = codexHome + + try { + createRepeatedSameTextDistinctTurnTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + const roles = messages.map((message) => (message.content as { role?: unknown }).role) + expect(roles).toEqual(['user', 'agent', 'user']) + expect(messages[0].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: 'repeat user message' + } + }) + expect(messages[2].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: 'repeat user message' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps distinct user turns while deduplicating mirrored user events', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-distinct-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '77777777-7777-4777-8777-777777777777' + process.env.CODEX_HOME = codexHome + + try { + createMirroredAndDistinctUserTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + expect(messages.map((message) => message.content)).toEqual([ + { + role: 'user', + content: { + type: 'text', + text: 'first user message' + }, + meta: { + sentFrom: 'cli' + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'second user message' + }, + meta: { + sentFrom: 'cli' + } + } + ]) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('uses the latest session_index thread_name for list and imported session title', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + writeSessionIndex(codexHome, [ + { + id: codexSessionId, + thread_name: 'old thread title', + updated_at: '2026-07-07T01:00:00.000000000Z' + }, + { + id: codexSessionId, + thread_name: 'new thread title', + updated_at: '2026-07-07T02:00:00.000000000Z' + } + ]) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'new thread title', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to transcript title when session_index is missing', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-no-index-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'normal user message', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to transcript title when session_index has no matching id', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-miss-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + writeSessionIndex(codexHome, [ + { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + thread_name: 'unrelated title', + updated_at: '2026-07-07T03:00:00.000000000Z' + } + ]) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'normal user message', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + it('binds imported transcripts to the unique online machine that owns the cwd', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-')) const store = new Store(':memory:') diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index d5723f4025..82e5d71fe3 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -88,10 +88,21 @@ type CodexImportedMessageContent = { } } +type CodexImportedMessageSource = 'event_msg' | 'response_item' +type CodexImportedMessageEntry = { + source: CodexImportedMessageSource + message: CodexImportedMessageContent +} + type CodexTranscriptImportData = CodexLocalSessionSummary & { messages: CodexImportedMessageContent[] } +type CodexSessionIndexTitle = { + threadName: string + updatedAt: string +} + type ImportCandidate = { sessionId: string active: boolean @@ -207,6 +218,10 @@ function getCodexSessionRoots(): string[] { return [join(codexHome, 'sessions')] } +function getCodexSessionIndexPath(): string { + return join(getCodexHome(), 'session_index.jsonl') +} + function collectJsonlFiles(root: string, files: string[]): void { if (!existsSync(root)) return let entries @@ -272,10 +287,13 @@ function truncateText(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value } -function shouldIgnoreSyntheticUserMessage(text: string): boolean { +function shouldIgnoreInjectedResponseUserMessage(text: string): boolean { const normalized = text.trim() - return normalized.startsWith('# AGENTS.md instructions') - || normalized.startsWith('') + const lower = normalized.toLowerCase() + const isAgentInstructions = lower.startsWith('# agents.md instructions') + const isEnvironmentContext = lower.startsWith('') + && lower.endsWith('') + return isAgentInstructions || isEnvironmentContext } function inferSessionIdFromFileName(filePath: string): string | null { @@ -371,7 +389,7 @@ function getLatestCodexUserMessage(lines: string[]): string | null { const payload = asRecord(record.payload) if (payload?.type !== 'message' || payload.role !== 'user') continue const text = extractCodexText(payload.content) - if (text && !shouldIgnoreSyntheticUserMessage(text)) { + if (text && !shouldIgnoreInjectedResponseUserMessage(text)) { return truncateText(text, 140) } } catch { @@ -384,9 +402,14 @@ function getLatestCodexUserMessage(lines: string[]): string | null { function getCodexSessionTitle( cwd: string | null | undefined, sessionId: string, + sessionIndexTitle: string | null, changedTitle: string | null, firstUserMessage: string | null ): string { + if (sessionIndexTitle) { + return truncateText(sessionIndexTitle, 80) + } + if (changedTitle) { return truncateText(changedTitle, 80) } @@ -410,7 +433,48 @@ function isSubagentSource(value: unknown): boolean { return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false } -function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { +function readCodexSessionIndexTitles(): Map { + let content: string + try { + content = readFileSync(getCodexSessionIndexPath(), 'utf-8') + } catch { + return new Map() + } + + const titles = new Map() + for (const line of content.split(/\r?\n/).filter(Boolean)) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const record = asRecord(parsed) + const id = typeof record?.id === 'string' ? record.id : null + const threadName = typeof record?.thread_name === 'string' && record.thread_name.trim() + ? record.thread_name.trim() + : null + const updatedAt = typeof record?.updated_at === 'string' && record.updated_at.trim() + ? record.updated_at.trim() + : null + if (!id || !threadName || !updatedAt) { + continue + } + + const previous = titles.get(id) + if (!previous || previous.updatedAt < updatedAt) { + titles.set(id, { threadName, updatedAt }) + } + } + + return titles +} + +function parseCodexLocalSession( + filePath: string, + sessionIndexTitles = new Map() +): CodexLocalSessionSummary | null { let content: string try { content = readFileSync(filePath, 'utf-8') @@ -461,7 +525,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu const payload = asRecord(record?.payload) if (payload?.type === 'message' && payload.role === 'user') { const text = extractCodexText(payload.content) - if (text && !shouldIgnoreSyntheticUserMessage(text)) { + if (text && !shouldIgnoreInjectedResponseUserMessage(text)) { firstUserMessage = text } } @@ -473,6 +537,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu sessionId = sessionId ?? inferSessionIdFromFileName(filePath) if (!sessionId) return null + const sessionIndexTitle = sessionIndexTitles.get(sessionId)?.threadName ?? null let modifiedAt = Date.now() try { @@ -483,7 +548,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu return { id: sessionId, - title: getCodexSessionTitle(cwd, sessionId, changedTitle, firstUserMessage), + title: getCodexSessionTitle(cwd, sessionId, sessionIndexTitle, changedTitle, firstUserMessage), lastUserMessage, cwd, file: filePath, @@ -499,9 +564,10 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex collectJsonlFiles(root, files) } + const sessionIndexTitles = readCodexSessionIndexTitles() const deduped = new Map() for (const filePath of files) { - const session = parseCodexLocalSession(filePath) + const session = parseCodexLocalSession(filePath, sessionIndexTitles) if (!session) continue const previous = deduped.get(session.id) if (!previous || previous.modifiedAt < session.modifiedAt) { @@ -557,7 +623,7 @@ function convertCodexRecordToImportedMessage(record: Record): C const text = asString(payload.message) ?? asString(payload.text) ?? asString(payload.content) - if (!text || shouldIgnoreSyntheticUserMessage(text)) { + if (!text) { return null } return buildImportedUserMessage(text) @@ -595,11 +661,11 @@ function convertCodexRecordToImportedMessage(record: Record): C if (itemType === 'message') { const role = asString(payload.role) const text = extractCodexText(payload.content) - if (!text || shouldIgnoreSyntheticUserMessage(text)) { + if (!text) { return null } if (role === 'user') { - return buildImportedUserMessage(text) + return shouldIgnoreInjectedResponseUserMessage(text) ? null : buildImportedUserMessage(text) } if (role === 'assistant') { return buildImportedAgentMessage({ type: 'message', message: text, id: randomUUID() }) @@ -639,6 +705,86 @@ function convertCodexRecordToImportedMessage(record: Record): C return null } +function getCodexImportedMessageSource(record: Record): CodexImportedMessageSource | null { + const type = asString(record.type) + return type === 'event_msg' || type === 'response_item' ? type : null +} + +function normalizeComparableUserMessage(content: unknown): string | null { + const record = asRecord(content) + if (!record || record.role !== 'user') { + return null + } + + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + + return stableSerialize({ + role: 'user', + text: body.text.trimEnd() + }) +} + +function normalizeComparableAgentMessage(content: unknown): string | null { + const record = asRecord(content) + if (!record || record.role !== 'agent') { + return null + } + + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + + const data = asRecord(body.data) + if (data?.type !== 'message' || typeof data.message !== 'string') { + return null + } + + return stableSerialize({ + role: 'agent', + type: 'message', + message: data.message + }) +} + +function normalizeAdjacentDuplicateMessage(content: unknown): string | null { + return normalizeComparableUserMessage(content) ?? normalizeComparableAgentMessage(content) +} + +function isAdjacentDuplicateImportedMessage( + previous: CodexImportedMessageContent, + next: CodexImportedMessageContent +): boolean { + const previousKey = normalizeAdjacentDuplicateMessage(previous) + const nextKey = normalizeAdjacentDuplicateMessage(next) + return previousKey !== null && previousKey === nextKey +} + +function isMirroredAdjacentDuplicate( + previous: CodexImportedMessageEntry | undefined, + next: CodexImportedMessageEntry +): boolean { + return Boolean( + previous + && previous.source !== next.source + && isAdjacentDuplicateImportedMessage(previous.message, next.message) + ) +} + +function isResponseItemDuplicateOfEventUserMessage( + entry: CodexImportedMessageEntry, + recentEventUserMessageKey: string | null +): boolean { + if (entry.source !== 'response_item' || recentEventUserMessageKey === null) { + return false + } + + return normalizeComparableUserMessage(entry.message) === recentEventUserMessageKey +} + function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { let content: string try { @@ -648,7 +794,8 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } const lines = content.split(/\r?\n/).filter(Boolean) - const messages: CodexImportedMessageContent[] = [] + const entries: CodexImportedMessageEntry[] = [] + let recentEventUserMessageKey: string | null = null for (const line of lines) { let parsed: unknown @@ -660,15 +807,38 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code const record = asRecord(parsed) if (!record) continue + const source = getCodexImportedMessageSource(record) + if (!source) continue const message = convertCodexRecordToImportedMessage(record) if (message) { - messages.push(message) + const entry = { source, message } + const userMessageKey = normalizeComparableUserMessage(message) + if (source === 'event_msg' && userMessageKey !== null) { + const previous = entries[entries.length - 1] + if (previous?.source === 'response_item' && isMirroredAdjacentDuplicate(previous, entry)) { + entries[entries.length - 1] = entry + } else { + entries.push(entry) + } + recentEventUserMessageKey = userMessageKey + continue + } + if (isResponseItemDuplicateOfEventUserMessage(entry, recentEventUserMessageKey)) { + continue + } + const previous = entries[entries.length - 1] + if (isMirroredAdjacentDuplicate(previous, entry)) { + recentEventUserMessageKey = null + continue + } + entries.push(entry) + recentEventUserMessageKey = null } } return { ...summary, - messages + messages: entries.map((entry) => entry.message) } } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 3c4e6457ba..6c0308cca3 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -26,6 +26,51 @@ function createMachine(overrides?: Partial): Machine { } describe('machines routes', () => { + it('forwards Grok Auto permission mode when spawning', async () => { + const machine = createMachine() + let capturedPermissionMode: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + _agent?: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedPermissionMode = permissionMode + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'grok', + permissionMode: 'auto' + }) + }) + + expect(response.status).toBe(200) + expect(capturedPermissionMode).toBe('auto') + }) + it('returns Codex models for an online machine', async () => { const machine = createMachine() const engine = { @@ -121,6 +166,42 @@ describe('machines routes', () => { }) }) + it('forwards cwd to listGrokModelsForCwd for Create-session discovery', async () => { + const machine = createMachine() + const calls: Array<{ machineId: string; cwd: string }> = [] + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listGrokModelsForCwd: async (machineId: string, cwd: string) => { + calls.push({ machineId, cwd }) + return { + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request( + '/api/machines/machine-1/grok-models?cwd=' + encodeURIComponent('/home/user/proj') + ) + + expect(response.status).toBe(200) + expect(calls).toEqual([{ machineId: 'machine-1', cwd: '/home/user/proj' }]) + expect(await response.json()).toEqual({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + }) + it('returns 503 when cursor-models is requested without a sync engine', async () => { const app = new Hono() app.use('*', async (c, next) => { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7288a42de0..280695a178 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -50,7 +50,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.sessionType, parsed.data.worktreeName, undefined, - parsed.data.effort + parsed.data.effort, + parsed.data.permissionMode ) return c.json(result) }) @@ -163,6 +164,31 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/grok-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) return machine + + const cwd = (c.req.query('cwd') ?? '').trim() + if (!cwd) { + return c.json({ success: false, error: 'cwd query parameter is required' }, 400) + } + + try { + return c.json(await engine.listGrokModelsForCwd(machineId, cwd)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok models' + }, 500) + } + }) + app.get('/machines/:id/cursor-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 01f6d859c9..8db9c76ea4 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -98,6 +98,22 @@ function createApp(session: Session, opts?: { ], currentModelId: 'composer-2.5' }) + const listGrokModelsForSession = async () => ({ + success: true, + availableModels: [ + { + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [{ value: 'low', name: 'Low' }] + } + ], + currentModelId: 'grok-4.5' + }) + const listGrokReasoningEffortOptionsForSession = async () => ({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low' + }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const reopenSession = opts?.reopenSession ?? (async (sessionId: string) => ({ type: 'success' as const, @@ -115,6 +131,8 @@ function createApp(session: Session, opts?: { listCursorModelsForSession, listOpencodeModelsForSession, listOpencodeReasoningEffortOptionsForSession, + listGrokModelsForSession, + listGrokReasoningEffortOptionsForSession, resumeSession, reopenSession, archiveSession: archiveSessionMock, @@ -568,6 +586,41 @@ describe('sessions routes', () => { expect(applySessionConfigCalls).toEqual([]) }) + it('applies model changes for remote Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'grok-4.5' }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'grok-4.5' }] + ]) + }) + + it('rejects model changes for local Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' }, + agentState: { controlledByUser: true, requests: {}, completedRequests: {} } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'grok-4.5' }) + }) + + expect(response.status).toBe(409) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) @@ -607,6 +660,35 @@ describe('sessions routes', () => { ]) }) + it('applies effort changes for remote Grok sessions and rejects local control', async () => { + const remote = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const remoteApp = createApp(remote) + const remoteResponse = await remoteApp.app.request('/api/sessions/session-1/effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ effort: 'low' }) + }) + expect(remoteResponse.status).toBe(200) + expect(remoteApp.applySessionConfigCalls).toEqual([ + ['session-1', { effort: 'low' }] + ]) + + const local = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' }, + agentState: { controlledByUser: true, requests: {}, completedRequests: {} } + }) + const localApp = createApp(local) + const localResponse = await localApp.app.request('/api/sessions/session-1/effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ effort: 'low' }) + }) + expect(localResponse.status).toBe(409) + expect(localApp.applySessionConfigCalls).toEqual([]) + }) + it('returns Codex models for active Codex sessions', async () => { const { app } = createApp(createSession()) @@ -640,6 +722,28 @@ describe('sessions routes', () => { }) }) + it('returns Grok model and effort catalogs for active Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const { app } = createApp(session) + + const modelsResponse = await app.request('/api/sessions/session-1/grok-models') + expect(modelsResponse.status).toBe(200) + expect(await modelsResponse.json()).toMatchObject({ + success: true, + currentModelId: 'grok-4.5' + }) + + const effortResponse = await app.request('/api/sessions/session-1/grok-reasoning-effort-options') + expect(effortResponse.status).toBe(200) + expect(await effortResponse.json()).toEqual({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low' + }) + }) + it('rejects opencode-reasoning-effort-options for non-OpenCode sessions', async () => { const { app } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index f199a2938c..c5f64f8162 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -490,6 +490,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor === 'cursor') { return c.json({ error: 'Model selection can only be changed for remote Cursor sessions' }, 409) } + if (flavor === 'grok') { + return c.json({ error: 'Model selection can only be changed for remote Grok sessions' }, 409) + } } try { @@ -558,6 +561,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!supportsEffort(flavor)) { return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } + if (flavor === 'grok' && sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Effort can only be changed for remote Grok sessions' }, 409) + } try { await engine.applySessionConfig(sessionResult.sessionId, { effort: parsed.data.effort }) @@ -823,6 +829,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/grok-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'grok') { + return c.json({ success: false, error: 'Grok models are only available for Grok sessions' }, 400) + } + try { + return c.json(await engine.listGrokModelsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok models' + }, 500) + } + }) + + app.get('/sessions/:id/grok-reasoning-effort-options', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'grok') { + return c.json({ success: false, error: 'Grok effort options are only available for Grok sessions' }, 400) + } + try { + return c.json(await engine.listGrokReasoningEffortOptionsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok effort options' + }, 500) + } + }) + app.get('/sessions/:id/cursor-models', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..e3c1a96ad8 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -191,10 +191,18 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { } function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { + const headers: Record = { + 'Content-Type': asset.mimeType + } + + if (asset.path === '/sw.js') { + headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' + headers['CDN-Cache-Control'] = 'no-store' + headers['Cloudflare-CDN-Cache-Control'] = 'no-store' + } + return new Response(Bun.file(asset.sourcePath), { - headers: { - 'Content-Type': asset.mimeType - } + headers }) } diff --git a/package.json b/package.json index c33c2acc89..1f5184492d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "test:shared": "cd shared && bun run test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/playwright.config.ts b/playwright.config.ts index 779efd169c..7ff2c2884b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test' -const PORT = 5179 +const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179) const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs new file mode 100644 index 0000000000..e137029cd1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:live'], + { + cwd: REPO_ROOT, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs new file mode 100644 index 0000000000..6914ef73e1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Bounded wrapper for mermaid lightbox Playwright (web/e2e). + * Vite lifecycle is owned by web/playwright.config.ts webServer — not this process. + * + * Usage (from repo root): + * npm run test:mermaid-lightbox:playwright + */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:e2e'], + { + cwd: WEB_DIR, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts new file mode 100644 index 0000000000..8b2fa9181b --- /dev/null +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -0,0 +1,85 @@ +/** + * Seed assistant messages with mermaid fixtures into a hub SQLite DB. + * Run on the host that owns HAPI_DB_PATH (usually the hub machine). + * + * HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID= bun run scripts/dev/mermaid-lightbox-seed-session-db.ts + */ +import { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, +} from '../../web/src/dev/mermaid-lightbox-cases' + +const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db') +/** Stable id for mermaid Playwright live session (create if missing). */ +const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +const sessionTag = 'mermaid-lightbox-e2e' + +function agentMermaidEnvelope(caseId: string, code: string) { + const text = `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`` + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: randomUUID(), + parentUuid: null, + isSidechain: false, + message: { + content: [{ type: 'text', text }], + }, + }, + }, + } +} + +const db = new Database(dbPath) +const now = Date.now() +const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as + | { id: string; tag: string | null } + | undefined + +if (existing && existing.tag !== sessionTag) { + throw new Error( + `Refusing to seed mermaid fixtures into session ${sessionId}: tag is ` + + `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. ` + + `Unset SESSION_ID or use a session created by this script.`, + ) +} + +if (!existing) { + db.prepare(` + INSERT INTO sessions ( + id, tag, namespace, created_at, updated_at, active, seq + ) VALUES (?, ?, 'default', ?, ?, 0, 0) + `).run(sessionId, sessionTag, now, now) + console.log(`created session ${sessionId} (${sessionTag})`) +} + +const insert = db.prepare(` + INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL) +`) + +db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId) + +let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as { + maxSeq: number +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + const code = MERMAID_LIGHTBOX_CASES[caseId] + const envelope = agentMermaidEnvelope(caseId, code) + const seq = (seqRow.maxSeq ?? 0) + 1 + seqRow = { maxSeq: seq } + const messageId = randomUUID() + insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now) + console.log(`seeded ${caseId} @ seq ${seq}`) +} + +db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId) +console.log(`Done. Open: /sessions/${sessionId}`) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e6381da723..bb2997cf7e 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -15,25 +15,27 @@ import type { } from './schemas' import type { SessionSummary } from './sessionSummary' +export const CreateOrLoadMachineRequestSchema = z.object({ + id: z.string().min(1), + metadata: z.unknown(), + runnerState: z.unknown().nullable().optional() +}) + +export type CreateOrLoadMachineRequest = z.infer + export const CreateOrLoadSessionRequestSchema = z.object({ + id: z.string().uuid().optional(), tag: z.string().min(1), metadata: z.unknown(), agentState: z.unknown().nullable().optional(), model: z.string().optional(), modelReasoningEffort: z.string().optional(), - effort: z.string().optional() + effort: z.string().optional(), + machine: CreateOrLoadMachineRequestSchema.optional() }) export type CreateOrLoadSessionRequest = z.infer -export const CreateOrLoadMachineRequestSchema = z.object({ - id: z.string().min(1), - metadata: z.unknown(), - runnerState: z.unknown().nullable().optional() -}) - -export type CreateOrLoadMachineRequest = z.infer - export const CliMessagesResponseSchema = z.object({ messages: z.array(z.object({ id: z.string(), @@ -249,6 +251,7 @@ export const SpawnSessionRequestSchema = z.object({ effort: z.string().optional(), modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), + permissionMode: PermissionModeSchema.optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() }) @@ -374,6 +377,34 @@ export type OpencodeModelsResponse = { export type ListOpencodeModelsResponse = OpencodeModelsResponse +export type GrokModelSummary = { + modelId: string + name?: string + reasoningEfforts?: GrokReasoningEffortOption[] +} + +export type GrokReasoningEffortOption = { + value: string + name?: string + isDefault?: boolean +} + +export type GrokModelsResponse = { + success: boolean + availableModels?: GrokModelSummary[] + currentModelId?: string | null + autoPermissionModeSupported?: boolean + error?: string +} +export type ListGrokModelsResponse = GrokModelsResponse + +export type GrokReasoningEffortResponse = { + success: boolean + options?: GrokReasoningEffortOption[] + currentValue?: string | null + error?: string +} + export type OpencodeReasoningEffortOption = { value: string name?: string diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts index 0f94540b54..a5cedeac70 100644 --- a/shared/src/buildInfo.ts +++ b/shared/src/buildInfo.ts @@ -1 +1 @@ -export const APP_VERSION = '0.20.2' +export const APP_VERSION = '0.22.3' diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 0d74595000..579fc959fb 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -6,6 +6,7 @@ import { isKnownFlavor, supportsEffort, supportsModelChange, + isCodexFamilyFlavor, } from './flavors' describe('hasCapability', () => { @@ -47,6 +48,11 @@ describe('hasCapability', () => { expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) }) + test('grok supports runtime model and effort switching through ACP', () => { + expect(hasCapability('grok', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('grok', Capabilities.Effort)).toBe(true) + }) + test('unknown flavor returns false', () => { expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false) }) @@ -66,6 +72,7 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('opencode')).toBe('OpenCode') expect(getFlavorLabel('pi')).toBe('Pi') expect(getFlavorLabel('kimi')).toBe('Kimi') + expect(getFlavorLabel('grok')).toBe('Grok Build') }) test('unknown flavor returns Unknown', () => { @@ -87,6 +94,7 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('opencode')).toBe(true) expect(isKnownFlavor('pi')).toBe(true) expect(isKnownFlavor('kimi')).toBe(true) + expect(isKnownFlavor('grok')).toBe(true) }) test('returns false for unknown/null/undefined', () => { @@ -97,6 +105,10 @@ describe('isKnownFlavor', () => { }) describe('convenience functions', () => { + test('treats Grok as a generic ACP/Codex-family permission flow', () => { + expect(isCodexFamilyFlavor('grok')).toBe(true) + }) + test('supportsModelChange matches hasCapability', () => { expect(supportsModelChange('claude')).toBe(true) expect(supportsModelChange('gemini')).toBe(true) @@ -113,6 +125,7 @@ describe('convenience functions', () => { expect(supportsEffort('codex')).toBe(false) expect(supportsEffort('gemini')).toBe(false) expect(supportsEffort('pi')).toBe(true) + expect(supportsEffort('grok')).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 15c59df385..c6ace53008 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -13,6 +13,7 @@ const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), kimi: new Set([Capabilities.ModelChange]), + grok: new Set([Capabilities.ModelChange, Capabilities.Effort]), codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), @@ -24,6 +25,7 @@ const FLAVOR_LABELS: Record = { claude: 'Claude', gemini: 'Gemini', kimi: 'Kimi', + grok: 'Grok Build', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', @@ -55,5 +57,9 @@ export function supportsEffort(flavor: string | null | undefined): boolean { } export function isCodexFamilyFlavor(flavor: string | null | undefined): boolean { - return flavor === 'codex' || flavor === 'gemini' || flavor === 'kimi' || flavor === 'opencode' + return flavor === 'codex' + || flavor === 'gemini' + || flavor === 'grok' + || flavor === 'kimi' + || flavor === 'opencode' } diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index eb97fe10f5..db4534ed0c 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, test } from 'bun:test' import { + AGENT_FLAVORS, + AgentFlavorSchema, + CREATABLE_AGENT_FLAVORS, getPermissionModeLabel, getPermissionModeOptionsForFlavor, getPermissionModeTone, @@ -7,7 +10,34 @@ import { isPermissionModeAllowedForFlavor, } from './modes' +describe('Gemini CLI sunset (read-only, not creatable)', () => { + test('gemini stays a valid flavor so existing stored sessions still validate/load', () => { + expect(AGENT_FLAVORS).toContain('gemini') + expect(AgentFlavorSchema.safeParse('gemini').success).toBe(true) + }) + + test('gemini is excluded from creatable flavors (not offered for new sessions)', () => { + expect(CREATABLE_AGENT_FLAVORS).not.toContain('gemini') + }) + + test('all other flavors remain creatable', () => { + for (const flavor of AGENT_FLAVORS) { + if (flavor === 'gemini') continue + expect(CREATABLE_AGENT_FLAVORS).toContain(flavor) + } + }) +}) + describe('getPermissionModesForFlavor', () => { + test("returns the conservative Grok modes", () => { + expect(getPermissionModesForFlavor('grok')).toEqual([ + 'default', + 'auto', + 'plan', + 'bypassPermissions' + ]) + }) + test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { expect(getPermissionModesForFlavor('pi')).toEqual([]) }) @@ -33,6 +63,15 @@ describe('getPermissionModeOptionsForFlavor', () => { }) describe('isPermissionModeAllowedForFlavor', () => { + test("allows only the supported Grok modes", () => { + expect(isPermissionModeAllowedForFlavor('default', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('plan', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('bypassPermissions', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('acceptEdits', 'grok')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('yolo', 'grok')).toBe(false) + }) + test("no mode is allowed for pi", () => { expect(isPermissionModeAllowedForFlavor('yolo', 'pi')).toBe(false) expect(isPermissionModeAllowedForFlavor('default', 'pi')).toBe(false) @@ -44,6 +83,14 @@ describe('isPermissionModeAllowedForFlavor', () => { expect(isPermissionModeAllowedForFlavor('safe-yolo', 'pi')).toBe(false) expect(isPermissionModeAllowedForFlavor('ask', 'pi')).toBe(false) }) + + test("cursor includes autoReview", () => { + expect(getPermissionModesForFlavor('cursor')).toContain('autoReview') + expect(getPermissionModeLabel('autoReview')).toBe('Auto-review') + expect(getPermissionModeTone('autoReview')).toBe('warning') + expect(isPermissionModeAllowedForFlavor('autoReview', 'cursor')).toBe(true) + expect(isPermissionModeAllowedForFlavor('autoReview', 'claude')).toBe(false) + }) }) describe('getPermissionModeLabel', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index a8d1c6659c..8c5e450764 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,10 +7,18 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode', 'pi'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) +// Flavors offered when CREATING a new session. Gemini CLI is intentionally +// excluded: Google sunset the consumer Gemini CLI (2026-06-18) so it can no +// longer be launched. It is kept in AGENT_FLAVORS / AgentFlavorSchema above so +// existing stored Gemini sessions still validate and remain viewable. +export const CREATABLE_AGENT_FLAVORS: readonly AgentFlavor[] = AGENT_FLAVORS.filter( + (flavor) => flavor !== 'gemini' +) + export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] @@ -26,10 +34,13 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] +export const GROK_PERMISSION_MODES = ['default', 'auto', 'plan', 'bypassPermissions'] as const +export type GrokPermissionMode = typeof GROK_PERMISSION_MODES[number] + export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] -export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'yolo'] as const +export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'autoReview', 'yolo'] as const export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] export const PERMISSION_MODES = [ @@ -40,6 +51,7 @@ export const PERMISSION_MODES = [ 'plan', 'ask', 'debug', + 'autoReview', 'read-only', 'safe-yolo', 'yolo' @@ -54,6 +66,7 @@ export const PERMISSION_MODE_LABELS: Record = { plan: 'Plan Mode', ask: 'Ask Mode', debug: 'Debug Mode', + autoReview: 'Auto-review', bypassPermissions: 'Yolo', 'read-only': 'Read Only', 'safe-yolo': 'Safe Yolo', @@ -69,6 +82,7 @@ export const PERMISSION_MODE_TONES: Record = plan: 'info', ask: 'info', debug: 'info', + autoReview: 'warning', bypassPermissions: 'danger', 'read-only': 'warning', 'safe-yolo': 'warning', @@ -113,6 +127,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'kimi') { return KIMI_PERMISSION_MODES } + if (flavor === 'grok') { + return GROK_PERMISSION_MODES + } if (flavor === 'opencode') { return OPENCODE_PERMISSION_MODES } diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index c4284451ca..13d22794a1 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -30,6 +30,9 @@ export const RPC_METHODS = { ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', + ListGrokModelsForCwd: 'listGrokModelsForCwd', + ListGrokModels: 'listGrokModels', + ListGrokReasoningEffortOptions: 'listGrokReasoningEffortOptions', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' } as const diff --git a/shared/src/schemas.sessionActiveAt.test.ts b/shared/src/schemas.sessionActiveAt.test.ts new file mode 100644 index 0000000000..7c18c82fab --- /dev/null +++ b/shared/src/schemas.sessionActiveAt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { SessionSchema } from './schemas' + +function baseSession(overrides: Record = {}) { + return { + id: '11111111-1111-4111-8111-111111111111', + namespace: 'default', + seq: 0, + createdAt: 1_000, + updatedAt: 2_000, + active: false, + activeAt: 1_000, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + ...overrides + } +} + +describe('SessionSchema activeAt coerce', () => { + it('keeps a numeric activeAt unchanged', () => { + const parsed = SessionSchema.safeParse(baseSession({ activeAt: 42 })) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(42) + } + }) + + it('coerces null activeAt to 0 without failing parse', () => { + const parsed = SessionSchema.safeParse(baseSession({ activeAt: null })) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(0) + } + }) + + it('coerces missing activeAt to 0 without failing parse', () => { + const raw = baseSession() + delete (raw as { activeAt?: number }).activeAt + const parsed = SessionSchema.safeParse(raw) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(0) + } + }) +}) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1b891fbef5..196f681146 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -37,6 +37,7 @@ export const MetadataSchema = z.object({ codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), + grokSessionId: z.string().optional(), cursorSessionId: z.string().optional(), cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(), // Drives the web `CursorMigrationBanner`: @@ -211,7 +212,8 @@ export const SessionSchema = z.object({ createdAt: z.number(), updatedAt: z.number(), active: z.boolean(), - activeAt: z.number(), + // Hub may still emit null for legacy SQLite rows; keep output type number. + activeAt: z.number().nullish().transform((value) => value ?? 0), metadata: MetadataSchema.nullable(), metadataVersion: z.number(), agentState: AgentStateSchema.nullable(), @@ -278,6 +280,17 @@ export const RunnerStateSchema = z.object({ export type RunnerState = z.infer +export const MachineHealthSchema = z.object({ + collectedAt: z.number(), + cpuCount: z.number().int().positive().optional(), + load1m: z.number().nonnegative().optional(), + cpuPercent: z.number().min(0).max(100).optional(), + memoryPercent: z.number().min(0).max(100).optional(), + uptimeSeconds: z.number().nonnegative().optional() +}).strict() + +export type MachineHealth = z.infer + export const MachineSchema = z.object({ id: z.string(), namespace: z.string(), @@ -289,7 +302,8 @@ export const MachineSchema = z.object({ metadata: MachineMetadataSchema.nullable(), metadataVersion: z.number(), runnerState: RunnerStateSchema.nullable(), - runnerStateVersion: z.number() + runnerStateVersion: z.number(), + health: MachineHealthSchema.nullable().optional() }) export type Machine = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index c490c1bc75..67bd83198a 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -65,6 +65,18 @@ describe('getPendingRequestKinds', () => { }) describe('toSessionSummary', () => { + it('uses grokSessionId as the native resume token', () => { + const summary = toSessionSummary(makeSession({ + metadata: { + path: '/proj', + host: 'local', + flavor: 'grok', + grokSessionId: 'grok-session-1' + } + })) + expect(summary.metadata?.agentSessionId).toBe('grok-session-1') + }) + it('includes pending request kinds and background task count', () => { const summary = toSessionSummary(makeSession({ backgroundTaskCount: 2, diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 32a3a48c23..bfa38faafc 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -119,6 +119,7 @@ export function toSessionSummary(session: Session): SessionSummary { ?? session.metadata.claudeSessionId ?? session.metadata.geminiSessionId ?? session.metadata.opencodeSessionId + ?? session.metadata.grokSessionId ?? session.metadata.cursorSessionId ?? session.metadata.kimiSessionId ?? undefined, diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index b0c06976d6..33ff4a300d 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -32,6 +32,14 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, { name: 'stats', description: 'Check session stats', source: 'builtin' }, ], + grok: [ + { name: 'compact', description: 'Compress conversation history to save context', source: 'builtin' }, + { name: 'context', description: 'Show context window usage and session stats', source: 'builtin' }, + { name: 'session-info', description: 'Show Grok session model, turns, and context usage', source: 'builtin' }, + { name: 'goal', description: 'Set, manage, or inspect an autonomous goal', source: 'builtin' }, + { name: 'always-approve', description: 'Toggle automatic tool approval', source: 'builtin' }, + { name: 'auto', description: 'Let Grok classify safe tool calls for automatic approval', source: 'builtin' }, + ], opencode: [ { name: 'help', description: 'Show supported HAPI OpenCode slash commands', source: 'builtin' }, { name: 'status', description: 'Show current OpenCode session config', source: 'builtin' }, diff --git a/shared/src/socket.ts b/shared/src/socket.ts index e050b0df98..977dcd5c9e 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -219,7 +219,7 @@ export interface ClientToServerEvents { 'messages-consumed': (data: { sid: string; localIds: string[] }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void 'update-state': (data: { sid: string; expectedVersion: number; agentState: unknown | null }, cb: (answer: UpdateStateAck) => void) => void - 'machine-alive': (data: { machineId: string; time: number }) => void + 'machine-alive': (data: { machineId: string; time: number; health?: unknown }) => void 'machine-update-metadata': (data: { machineId: string; expectedVersion: number; metadata: unknown }, cb: (answer: MachineUpdateMetadataAck) => void) => void 'machine-update-state': (data: { machineId: string; expectedVersion: number; runnerState: unknown | null }, cb: (answer: MachineUpdateStateAck) => void) => void 'rpc-register': (data: { method: string }) => void diff --git a/shared/src/types.ts b/shared/src/types.ts index 9c38fda221..ffe12c6d45 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -6,6 +6,7 @@ export type { DecryptedMessage, Metadata, Machine, + MachineHealth, MachineMetadata, MachinePatch, MachineUpdatedData, @@ -36,6 +37,7 @@ export type { CodexPermissionMode, CursorPermissionMode, GeminiPermissionMode, + GrokPermissionMode, KimiPermissionMode, OpencodePermissionMode, PermissionMode, diff --git a/shared/src/voice.ts b/shared/src/voice.ts index 0f3ac56c45..7cc4738c68 100644 --- a/shared/src/voice.ts +++ b/shared/src/voice.ts @@ -399,8 +399,10 @@ export function buildQwenSessionUpdateMessage( silence_duration_ms: 800, prefix_padding_ms: 300 }, - tools, - tool_choice: 'auto' + // Qwen-Omni-Realtime decides whether to call tools automatically. + // The official realtime API does not support OpenAI-style tool_choice / + // parallel_tool_calls parameters on session.update. + tools } } } diff --git a/test/fixtures/overseer-replay/README.md b/test/fixtures/overseer-replay/README.md new file mode 100644 index 0000000000..19914fca3f --- /dev/null +++ b/test/fixtures/overseer-replay/README.md @@ -0,0 +1,17 @@ +# Overseer replay fixtures + +Synthetic captured-event-stream snapshots for the Step 2.75 replay harness +(`hub/src/overseer/replayHarness.ts`). Each file is a `ReplaySnapshot`: sessions ++ events (+ optional event links, baseline inbox items, dispatch envelopes, +worker messages). + +**These are synthetic and hand-authored. They are NOT production transcripts.** +The Overseer contracts doc §7 (transcript retention) forbids using real operator +transcripts as fixtures; the replay harness only ever runs against invented +streams shaped to exercise a specific golden scenario. + +Golden scenarios are drawn from the prioritization doc §6 table. Time-relative +fixtures (`aging-and-stale.json`) bake a fixed reference epoch into the data; the +test passes that same `now` explicitly so the scenario is deterministic rather +than wall-clock dependent. The reference epoch is `1700000000000` +(2023-11-14T22:13:20Z). diff --git a/test/fixtures/overseer-replay/aging-and-stale.json b/test/fixtures/overseer-replay/aging-and-stale.json new file mode 100644 index 0000000000..6bb0b51d0e --- /dev/null +++ b/test/fixtures/overseer-replay/aging-and-stale.json @@ -0,0 +1,21 @@ +{ + "name": "aging-and-stale", + "description": "Baseline inbox items at a fixed reference epoch (now = 1700000000000). One 25h-old low-tier item plus a fresh review item, and a mostly-low priority distribution. Expected: countStaleItems flags the 25h item; aging makes the old item out-prioritize the fresh higher-tier one; distribution is mostly low.", + "sessions": [ + { "key": "s1", "tag": "p1", "flavor": "claude", "name": "p1" }, + { "key": "s2", "tag": "p2", "flavor": "codex", "name": "p2" } + ], + "events": [], + "inboxItems": [ + { "status": "new", "basePriority": 50, "category": "FINALE", "title": "old completed needs review", "summary": "PR open 25h", "createdAt": 1699910000000, "sessionKey": "s1" }, + { "status": "new", "basePriority": 40, "category": "REVIEW", "title": "fresh review", "summary": "just queued", "createdAt": 1699999700000, "sessionKey": "s2" }, + { "status": "new", "basePriority": 10, "category": "APPROVAL", "title": "approval high", "summary": "approve push", "createdAt": 1699999800000, "sessionKey": "s1" }, + { "status": "new", "basePriority": 30, "category": "QUESTION", "title": "decision medium", "summary": "which option", "createdAt": 1699999800000, "sessionKey": "s2" }, + { "status": "new", "basePriority": 60, "category": "STALE", "title": "low 1", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s1" }, + { "status": "new", "basePriority": 60, "category": "STALE", "title": "low 2", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s2" }, + { "status": "new", "basePriority": 70, "category": "QUESTION", "title": "low 3", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s1" }, + { "status": "new", "basePriority": 70, "category": "QUESTION", "title": "low 4", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s2" }, + { "status": "new", "basePriority": 50, "category": "FINALE", "title": "low 5", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s1" }, + { "status": "new", "basePriority": 50, "category": "FINALE", "title": "low 6", "summary": "low", "createdAt": 1699999800000, "sessionKey": "s2" } + ] +} diff --git a/test/fixtures/overseer-replay/alarm-flood.json b/test/fixtures/overseer-replay/alarm-flood.json new file mode 100644 index 0000000000..e72aa9bec7 --- /dev/null +++ b/test/fixtures/overseer-replay/alarm-flood.json @@ -0,0 +1,21 @@ +{ + "name": "alarm-flood", + "description": "11 attention-candidate events inside a 10-minute window. Expected: detectAlarmFlood reports flood=true (EEMUA 191 >10/10min).", + "sessions": [ + { "key": "a", "tag": "peer-a", "flavor": "claude", "name": "peer-a" }, + { "key": "b", "tag": "peer-b", "flavor": "codex", "name": "peer-b" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "a", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "blocked 1" }, + { "sid": 2, "ts": 1700000030000, "sessionKey": "b", "sourceKind": "worker", "eventType": "needs_decision", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "decision 1" }, + { "sid": 3, "ts": 1700000060000, "sessionKey": "a", "sourceKind": "worker", "eventType": "failed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "failed 1" }, + { "sid": 4, "ts": 1700000090000, "sessionKey": "b", "sourceKind": "worker", "eventType": "needs_review", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "review 1" }, + { "sid": 5, "ts": 1700000120000, "sessionKey": "a", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "blocked 2" }, + { "sid": 6, "ts": 1700000150000, "sessionKey": "b", "sourceKind": "worker", "eventType": "needs_decision", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "decision 2" }, + { "sid": 7, "ts": 1700000180000, "sessionKey": "a", "sourceKind": "worker", "eventType": "failed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "failed 2" }, + { "sid": 8, "ts": 1700000210000, "sessionKey": "b", "sourceKind": "worker", "eventType": "needs_review", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "review 2" }, + { "sid": 9, "ts": 1700000240000, "sessionKey": "a", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "blocked 3" }, + { "sid": 10, "ts": 1700000270000, "sessionKey": "b", "sourceKind": "worker", "eventType": "needs_decision", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "decision 3" }, + { "sid": 11, "ts": 1700000300000, "sessionKey": "a", "sourceKind": "worker", "eventType": "failed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "failed 3" } + ] +} diff --git a/test/fixtures/overseer-replay/approval-escalation.json b/test/fixtures/overseer-replay/approval-escalation.json new file mode 100644 index 0000000000..23c2300d6d --- /dev/null +++ b/test/fixtures/overseer-replay/approval-escalation.json @@ -0,0 +1,10 @@ +{ + "name": "approval-escalation", + "description": "Worker requests approval for a destructive action. Expected: surfaced as APPROVAL category at the highest coarse priority tier (lowest base_priority number).", + "sessions": [ + { "key": "s", "tag": "peer-z", "flavor": "claude", "name": "peer-z" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "system", "eventType": "approval_requested", "attentionCandidate": 1, "operatorActionRequired": 1, "riskDetected": 1, "summary": "Permission requested: Shell rm -rf node_modules", "payload": { "requestId": "req-1", "tool": "Shell" } } + ] +} diff --git a/test/fixtures/overseer-replay/blocked-by-fanin.json b/test/fixtures/overseer-replay/blocked-by-fanin.json new file mode 100644 index 0000000000..04bd301423 --- /dev/null +++ b/test/fixtures/overseer-replay/blocked-by-fanin.json @@ -0,0 +1,21 @@ +{ + "name": "blocked-by-fanin", + "description": "Three worker sessions blocked on one shared upstream dependency, linked via event_links(blocked_by). Expected: root-cause traversal from any symptom returns the upstream event (surface root, not symptoms).", + "sessions": [ + { "key": "infra", "tag": "infra", "flavor": "system", "name": "infra-dep" }, + { "key": "w1", "tag": "peer-1", "flavor": "claude", "name": "peer-1" }, + { "key": "w2", "tag": "peer-2", "flavor": "codex", "name": "peer-2" }, + { "key": "w3", "tag": "peer-3", "flavor": "claude", "name": "peer-3" } + ], + "events": [ + { "sid": 100, "ts": 1700000000000, "sessionKey": "infra", "sourceKind": "system", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "shared staging DB is down" }, + { "sid": 1, "ts": 1700000060000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "cannot run integration tests" }, + { "sid": 2, "ts": 1700000070000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "migration step times out" }, + { "sid": 3, "ts": 1700000080000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "seed script hangs" } + ], + "eventLinks": [ + { "fromSid": 1, "toSid": 100, "relationType": "blocked_by" }, + { "fromSid": 2, "toSid": 100, "relationType": "blocked_by" }, + { "fromSid": 3, "toSid": 100, "relationType": "blocked_by" } + ] +} diff --git a/test/fixtures/overseer-replay/ci-contradiction.json b/test/fixtures/overseer-replay/ci-contradiction.json new file mode 100644 index 0000000000..55485218bb --- /dev/null +++ b/test/fixtures/overseer-replay/ci-contradiction.json @@ -0,0 +1,11 @@ +{ + "name": "ci-contradiction", + "description": "CI reports failure while the worker self-reports a test pass / completion on the same session. Expected: the harness surfaces the contradiction and resolves neither side.", + "sessions": [ + { "key": "s", "tag": "peer-ci", "flavor": "claude", "name": "peer-ci" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "channel", "sourceRef": "github-actions", "eventType": "failed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "CI: test job failed (3 tests red)" }, + { "sid": 2, "ts": 1700000060000, "sessionKey": "s", "sourceKind": "worker", "eventType": "completed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "All tests pass locally, done" } + ] +} diff --git a/test/fixtures/overseer-replay/completed-noise.json b/test/fixtures/overseer-replay/completed-noise.json new file mode 100644 index 0000000000..d8858c2203 --- /dev/null +++ b/test/fixtures/overseer-replay/completed-noise.json @@ -0,0 +1,10 @@ +{ + "name": "completed-noise", + "description": "Worker completes with operator_action_required=false and risk_detected=false. Expected: falls out of the attention queue (no inbox item) but the event remains recorded and queryable.", + "sessions": [ + { "key": "s", "tag": "peer-done", "flavor": "codex", "name": "peer-done" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "worker", "eventType": "completed", "attentionCandidate": 0, "operatorActionRequired": 0, "riskDetected": 0, "summary": "Routine task finished, nothing to review" } + ] +} diff --git a/test/fixtures/overseer-replay/completed-review-pr.json b/test/fixtures/overseer-replay/completed-review-pr.json new file mode 100644 index 0000000000..0720a132c1 --- /dev/null +++ b/test/fixtures/overseer-replay/completed-review-pr.json @@ -0,0 +1,10 @@ +{ + "name": "completed-review-pr", + "description": "Worker completes with operator_action_required and a github_pr artifact. Expected: surfaced as a review-needed FINALE item whose title is the PR handle.", + "sessions": [ + { "key": "s", "tag": "peer-pr", "flavor": "claude", "name": "peer-pr" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "worker", "eventType": "completed", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "Implemented replay harness", "artifactRefs": [{ "kind": "github_pr", "title": "feat(overseer): replay harness v0", "url": "https://github.com/heavygee/hapi/pull/999" }], "payload": { "suggested_action": "review and merge PR #999" } } + ] +} diff --git a/test/fixtures/overseer-replay/idempotent-reemission.json b/test/fixtures/overseer-replay/idempotent-reemission.json new file mode 100644 index 0000000000..75a4f37018 --- /dev/null +++ b/test/fixtures/overseer-replay/idempotent-reemission.json @@ -0,0 +1,11 @@ +{ + "name": "idempotent-reemission", + "description": "Same event re-emitted with an identical idempotency_key. Expected: stored once (no double-count), one inbox item.", + "sessions": [ + { "key": "s", "tag": "peer-y", "flavor": "claude", "name": "peer-y" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "worker", "eventType": "needs_decision", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "Which migration strategy?", "idempotencyKey": "session:peer-y:message:m1:notify" }, + { "sid": 2, "ts": 1700000005000, "sessionKey": "s", "sourceKind": "worker", "eventType": "needs_decision", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "Which migration strategy?", "idempotencyKey": "session:peer-y:message:m1:notify" } + ] +} diff --git a/test/fixtures/overseer-replay/one-boss-clean.json b/test/fixtures/overseer-replay/one-boss-clean.json new file mode 100644 index 0000000000..b6f39d28b7 --- /dev/null +++ b/test/fixtures/overseer-replay/one-boss-clean.json @@ -0,0 +1,16 @@ +{ + "name": "one-boss-clean", + "description": "A future-shaped dispatched event whose worker message is correctly operator-attributed (role=user, no Overseer metadata, no attribution boilerplate). Expected: one-boss invariant checks 1 dispatch, finds 0 violations. Proves the assertion shape is wired and will activate at Step 4.", + "sessions": [ + { "key": "s", "tag": "peer-15", "flavor": "claude", "name": "peer-15" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "overseer", "eventType": "dispatched", "attentionCandidate": 0, "summary": "dispatched merge instruction to peer-15", "idempotencyKey": "dispatch:peer-15:abc123" } + ], + "dispatchEnvelopes": [ + { "idempotencyKey": "dispatch:peer-15:abc123", "messageId": "msg-1", "origin": "operator_confirmed", "rationale": "operator approved at breakpoint", "relatedEventIds": [42], "confirmationSource": "voice" } + ], + "workerMessages": [ + { "id": "msg-1", "sessionKey": "s", "role": "user", "renderedInstruction": "Rebase onto main and re-run the integration tests when green." } + ] +} diff --git a/test/fixtures/overseer-replay/one-boss-leak.json b/test/fixtures/overseer-replay/one-boss-leak.json new file mode 100644 index 0000000000..b34d183daf --- /dev/null +++ b/test/fixtures/overseer-replay/one-boss-leak.json @@ -0,0 +1,16 @@ +{ + "name": "one-boss-leak", + "description": "A dispatched event whose worker message LEAKS Overseer provenance: attribution boilerplate in the rendered instruction plus an overseer-origin metadata key. Expected: one-boss invariant catches the violations (proves it is not a no-op).", + "sessions": [ + { "key": "s", "tag": "peer-15", "flavor": "claude", "name": "peer-15" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "overseer", "eventType": "dispatched", "attentionCandidate": 0, "summary": "dispatched merge instruction to peer-15", "idempotencyKey": "dispatch:peer-15:leak1" } + ], + "dispatchEnvelopes": [ + { "idempotencyKey": "dispatch:peer-15:leak1", "messageId": "msg-1", "origin": "operator_confirmed", "rationale": "x", "relatedEventIds": [1], "confirmationSource": "voice" } + ], + "workerMessages": [ + { "id": "msg-1", "sessionKey": "s", "role": "user", "renderedInstruction": "The Overseer suggests you rebase onto main now.", "metadata": { "source": "overseer", "overseer_rationale": "fan-in blocker" } } + ] +} diff --git a/test/fixtures/overseer-replay/operator-noise-demotion.json b/test/fixtures/overseer-replay/operator-noise-demotion.json new file mode 100644 index 0000000000..19799497b4 --- /dev/null +++ b/test/fixtures/overseer-replay/operator-noise-demotion.json @@ -0,0 +1,10 @@ +{ + "name": "operator-noise-demotion", + "description": "An item the operator marks as noise. Expected: a dismiss operator-action moves the item out of the active queue (status obsoleted) and is recorded as a negative training label for future salience tuning.", + "sessions": [ + { "key": "s", "tag": "peer-noise", "flavor": "codex", "name": "peer-noise" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "worker", "eventType": "needs_review", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "linter warning batch (operator considers this noise)" } + ] +} diff --git a/test/fixtures/overseer-replay/routine-progress-flood.json b/test/fixtures/overseer-replay/routine-progress-flood.json new file mode 100644 index 0000000000..5f3559f685 --- /dev/null +++ b/test/fixtures/overseer-replay/routine-progress-flood.json @@ -0,0 +1,41 @@ +{ + "name": "routine-progress-flood", + "description": "30 routine progress events across 3 sessions. Expected: surface nothing (no attention candidates promoted).", + "sessions": [ + { "key": "w1", "tag": "peer-1", "flavor": "claude", "name": "peer-1" }, + { "key": "w2", "tag": "peer-2", "flavor": "codex", "name": "peer-2" }, + { "key": "w3", "tag": "peer-3", "flavor": "cursor", "name": "peer-3" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 2, "ts": 1700000010000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 3, "ts": 1700000020000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 4, "ts": 1700000030000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 5, "ts": 1700000040000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 6, "ts": 1700000050000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 7, "ts": 1700000060000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 8, "ts": 1700000070000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 9, "ts": 1700000080000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 10, "ts": 1700000090000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 11, "ts": 1700000100000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 12, "ts": 1700000110000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 13, "ts": 1700000120000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 14, "ts": 1700000130000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 15, "ts": 1700000140000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 16, "ts": 1700000150000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 17, "ts": 1700000160000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 18, "ts": 1700000170000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 19, "ts": 1700000180000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 20, "ts": 1700000190000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 21, "ts": 1700000200000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 22, "ts": 1700000210000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 23, "ts": 1700000220000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 24, "ts": 1700000230000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 25, "ts": 1700000240000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 26, "ts": 1700000250000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 27, "ts": 1700000260000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" }, + { "sid": 28, "ts": 1700000270000, "sessionKey": "w1", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "ran tests" }, + { "sid": 29, "ts": 1700000280000, "sessionKey": "w2", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "edited file" }, + { "sid": 30, "ts": 1700000290000, "sessionKey": "w3", "sourceKind": "worker", "eventType": "progress", "attentionCandidate": 0, "summary": "read docs" } + ] +} diff --git a/test/fixtures/overseer-replay/same-session-collapse.json b/test/fixtures/overseer-replay/same-session-collapse.json new file mode 100644 index 0000000000..d5a34d3fd5 --- /dev/null +++ b/test/fixtures/overseer-replay/same-session-collapse.json @@ -0,0 +1,11 @@ +{ + "name": "same-session-collapse", + "description": "Two attention events on the same session. Expected: collapse into ONE active inbox item with both source_event_ids merged.", + "sessions": [ + { "key": "s", "tag": "peer-x", "flavor": "codex", "name": "peer-x" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "s", "sourceKind": "worker", "eventType": "blocked", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "CI failed on push" }, + { "sid": 2, "ts": 1700000060000, "sessionKey": "s", "sourceKind": "worker", "eventType": "needs_review", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "PR ready for review" } + ] +} diff --git a/test/fixtures/overseer-replay/stale-captured-only.json b/test/fixtures/overseer-replay/stale-captured-only.json new file mode 100644 index 0000000000..83bc7d095a --- /dev/null +++ b/test/fixtures/overseer-replay/stale-captured-only.json @@ -0,0 +1,12 @@ +{ + "name": "stale-captured-only", + "description": "Hub-inferred stale silence (checkStaleSessions) is captured-only: recorded with attentionCandidate=0, NOT promoted to inbox. A worker that EXPLICITLY self-reports stalled keeps attentionCandidate=1 and DOES promote. Locks the fix/overseer-inbox-stale-noise behavior.", + "sessions": [ + { "key": "idle", "tag": "peer-idle", "flavor": "claude", "name": "peer-idle" }, + { "key": "vocal", "tag": "peer-vocal", "flavor": "codex", "name": "peer-vocal" } + ], + "events": [ + { "sid": 1, "ts": 1700000000000, "sessionKey": "idle", "sourceKind": "system", "eventType": "stale", "attentionCandidate": 0, "operatorActionRequired": 0, "summary": "No agent output for 35 minutes", "provenance": "hub-inferred from session silence threshold" }, + { "sid": 2, "ts": 1700000060000, "sessionKey": "vocal", "sourceKind": "worker", "eventType": "stale", "attentionCandidate": 1, "operatorActionRequired": 1, "summary": "I appear stuck waiting on an API key", "provenance": "AGENT_NOTIFY_SUMMARY" } + ] +} diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd8..f9f5a543f4 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ dev-dist/ +test-results/ diff --git a/web/README.md b/web/README.md index 2f6ee5eabd..3498263f4a 100644 --- a/web/README.md +++ b/web/README.md @@ -30,7 +30,14 @@ See `src/router.tsx` for route definitions. - `/sessions/$sessionId/files` - File browser with git status. - `/sessions/$sessionId/file` - File viewer with diff support. - `/sessions/$sessionId/terminal` - Terminal interface. -- `/settings` - Application settings. +- `/settings` - Settings category hub (mobile) and responsive master-detail shell. +- `/settings/general` - Language preferences. +- `/settings/display` - Appearance, typography, colors, and session list preferences. +- `/settings/chat` - Message input, tool card, and conversation color preferences. +- `/settings/voice` - Everyday voice assistant preferences. +- `/settings/voice/voices` - Full-page voice picker. +- `/settings/voice/advanced` - Voice persona, tuning, and diagnostics. +- `/settings/about` - Application links and version information. ## Features diff --git a/web/e2e-fixtures/file-md-preview-fixture.html b/web/e2e-fixtures/file-md-preview-fixture.html new file mode 100644 index 0000000000..cb3a1abea8 --- /dev/null +++ b/web/e2e-fixtures/file-md-preview-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI file markdown preview e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/file-md-preview-fixture.tsx b/web/e2e-fixtures/file-md-preview-fixture.tsx new file mode 100644 index 0000000000..7e2f27549a --- /dev/null +++ b/web/e2e-fixtures/file-md-preview-fixture.tsx @@ -0,0 +1,88 @@ +/* + * Standalone Vite-served fixture for the file-pane markdown Source | + * Preview Playwright smoke. Mounts the same toggle + MarkdownRenderer + * path as `file.tsx` without the HAPI auth / git / socket stack. + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import { MarkdownRenderer } from '../src/components/MarkdownRenderer' +import { + getInitialMarkdownPreviewMode, + persistMarkdownPreviewMode, + type MarkdownPreviewMode, +} from '../src/lib/file-markdown-preview' +import { useTranslation } from '../src/lib/use-translation' + +const SAMPLE_MARKDOWN = `# Teams and channels + +| Channel | Purpose | +| --- | --- | +| general | Day-to-day coordination | +| incidents | Outage response | + +\`\`\`ts +export const ok = true +\`\`\` + +> Preview uses the same markdown pipeline as chat. +` + +function FileMarkdownPreviewFixture() { + const { t } = useTranslation() + const [mode, setMode] = React.useState(() => getInitialMarkdownPreviewMode()) + const showSource = mode === 'source' + + const setMarkdownPreviewMode = (next: MarkdownPreviewMode) => { + setMode(next) + persistMarkdownPreviewMode(next) + } + + return ( +
+
+ + +
+ {showSource ? ( +
+                    {SAMPLE_MARKDOWN}
+                
+ ) : ( +
+ +
+ )} +
+ ) +} + +const rootEl = document.getElementById('root') +if (rootEl) { + ReactDOM.createRoot(rootEl).render( + + + + + + ) +} diff --git a/web/e2e-fixtures/mermaid-lightbox-e2e.html b/web/e2e-fixtures/mermaid-lightbox-e2e.html new file mode 100644 index 0000000000..a1cfc7ead9 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-e2e.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox e2e + + + +
+ + + diff --git a/web/e2e-fixtures/mermaid-lightbox-smoke.html b/web/e2e-fixtures/mermaid-lightbox-smoke.html new file mode 100644 index 0000000000..6e18f25a52 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-smoke.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox smoke + + + +
+ + + diff --git a/web/e2e-fixtures/settings-fixture.html b/web/e2e-fixtures/settings-fixture.html new file mode 100644 index 0000000000..10865e1cd2 --- /dev/null +++ b/web/e2e-fixtures/settings-fixture.html @@ -0,0 +1,12 @@ + + + + + + Settings responsive fixture + + +
+ + + diff --git a/web/e2e-fixtures/settings-fixture.tsx b/web/e2e-fixtures/settings-fixture.tsx new file mode 100644 index 0000000000..fa30dac66e --- /dev/null +++ b/web/e2e-fixtures/settings-fixture.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import SettingsLayout from '../src/routes/settings/layout' +import SettingsHubPage from '../src/routes/settings' +import SettingsDisplayPage from '../src/routes/settings/display' + +const rootRoute = createRootRoute({ + component: () => , +}) + +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsLayout, +}) + +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: SettingsHubPage, +}) + +const settingsDisplayRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'display', + component: SettingsDisplayPage, +}) + +const routeTree = rootRoute.addChildren([ + settingsRoute.addChildren([settingsIndexRoute, settingsDisplayRoute]), +]) + +const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/settings'] }), +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const root = document.getElementById('root') +if (root) { + ReactDOM.createRoot(root).render( + + + + + + ) +} diff --git a/web/e2e/helpers/hapi-live.ts b/web/e2e/helpers/hapi-live.ts new file mode 100644 index 0000000000..60f5aef2f5 --- /dev/null +++ b/web/e2e/helpers/hapi-live.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@playwright/test' + +export function getHapiBaseUrl(): string { + return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '') +} + +export function getMermaidTestSessionId(): string { + return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +} + +export function readCliAccessToken(): string { + if (process.env.HAPI_ACCESS_TOKEN?.trim()) { + return process.env.HAPI_ACCESS_TOKEN.trim() + } + const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string } + if (!settings.cliApiToken) { + throw new Error(`Missing cliApiToken in ${settingsPath}`) + } + return settings.cliApiToken +} + +export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) { + await page.addInitScript(({ token, url }) => { + localStorage.setItem(`hapi_access_token::${url}`, token) + }, { token: accessToken, url: baseUrl }) +} + +export async function scrollChatToBottom(page: Page) { + for (let i = 0; i < 24; i += 1) { + const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count() + if (found > 0) break + await page.evaluate(() => { + const scrollers = [...document.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 80, + ) + scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight) + const target = scrollers[0] + if (target) target.scrollTop = target.scrollHeight + window.scrollTo(0, document.body.scrollHeight) + }) + await page.waitForTimeout(400) + } +} + +export type LiveLightboxMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + hasShadowSvg: boolean + shapeTotal: number + coverage: number +} + +export async function readLiveLightboxMetrics(page: Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = + (lightboxSvg?.querySelectorAll('rect').length ?? 0) + + (lightboxSvg?.querySelectorAll('path').length ?? 0) + + (lightboxSvg?.querySelectorAll('line').length ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + hasShadowSvg: Boolean(lightboxSvg), + shapeTotal: shapes, + coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh), + } + }) +} diff --git a/web/e2e/mermaid-lightbox-session.spec.ts b/web/e2e/mermaid-lightbox-session.spec.ts new file mode 100644 index 0000000000..f5af7c9e59 --- /dev/null +++ b/web/e2e/mermaid-lightbox-session.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' +import { + getHapiBaseUrl, + getMermaidTestSessionId, + installHapiAuth, + readCliAccessToken, + readLiveLightboxMetrics, + scrollChatToBottom, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25') + +test.describe.configure({ mode: 'serial' }) + +test.describe('mermaid lightbox (live HAPI session)', () => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + test.beforeAll(() => { + readCliAccessToken() + }) + + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`live session lightbox: ${caseId}`, async ({ page }) => { + const baseUrl = getHapiBaseUrl() + const sessionId = getMermaidTestSessionId() + const token = readCliAccessToken() + + await installHapiAuth(page, baseUrl, token) + await page.goto(`${baseUrl}/sessions/${sessionId}`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2000) + await scrollChatToBottom(page) + + const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId) + const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]') + await expect( + rendered, + `Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`, + ).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 }) + + const diagram = rendered.nth(diagramIndex) + + await diagram.scrollIntoViewIfNeeded() + const before = await diagram.evaluate((el) => { + const svg = el.querySelector('svg') + const box = svg?.getBoundingClientRect() + return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 } + }) + + await diagram.click({ timeout: 15_000 }) + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + const lightboxKind = await page.waitForFunction(() => { + const dialog = document.querySelector('[role="dialog"]') + if (!dialog) return 'no-dialog' + const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + if (shadowSvg) { + const box = shadowSvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'shadow' + } + const legacySvg = dialog.querySelector('.rounded-lg svg') + if (legacySvg) { + const box = legacySvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'legacy' + } + return 'empty' + }, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise) + + expect( + lightboxKind, + `${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`, + ).toBe('shadow') + + const after = await readLiveLightboxMetrics(page) + const areaRatio = + before.inlineW > 0 && before.inlineH > 0 + ? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH) + : 0 + + expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true) + expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0) + expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE) + expect( + areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05, + `${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`, + ).toBe(true) + + if (caseId === 'sequence') { + const seqShapes = await page.evaluate(() => { + const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + return { + rect: svg?.querySelectorAll('rect').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + }) + expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true) + } + + await page.keyboard.press('Escape') + await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined) + }) + } +}) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts new file mode 100644 index 0000000000..2f1c7f3452 --- /dev/null +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' + +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +type LightboxMetrics = { + hasShadowSvg: boolean + usesDataUrlImg: boolean + svgW: number + svgH: number + coverageW: number + coverageH: number + shapeTotal: number + shapes: { rect: number; path: number; line: number } +} + +type ExpandMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + areaRatio: number +} + +async function readExpandMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0) + const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0, + } + }) +} + +async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + const shapeTotal = + shapes.rect + + shapes.path + + shapes.line + + (svg?.querySelectorAll('text').length ?? 0) + + (svg?.querySelectorAll('circle').length ?? 0) + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapeTotal, + shapes, + } + }) +} + +function assertMetrics(caseId: string, metrics: LightboxMetrics) { + expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true) + expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false) + expect( + metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX, + `${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`, + ).toBe(true) + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + const wideShort = caseId === 'gantt' || caseId === 'kanban' + if (wideShort) { + expect( + metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600, + `${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`, + ).toBe(true) + } else { + expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual( + MIN_COVERAGE, + ) + } + expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0) + if (caseId === 'sequence') { + expect( + metrics.shapes.rect >= 2 || metrics.shapes.line >= 2, + `${caseId}: sequence actors/lines`, + ).toBe(true) + } +} + +/** Lightbox should be visibly larger than the inline chat preview after click. */ +const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4') + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`mermaid lightbox: ${caseId}`, async ({ page }) => { + await page.goto(`/e2e-fixtures/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + + const beforeExpand = await readExpandMetrics(page) + expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0) + + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => undefined) + + const expand = await readExpandMetrics(page) + const inlineMax = Math.max(expand.inlineW, expand.inlineH) + const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH) + const expandedVisibly = + expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05 + expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true) + + const metrics = await readLightboxMetrics(page) + assertMetrics(caseId, metrics) + + test.info().annotations.push({ + type: 'expand', + description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`, + }) + }) +} diff --git a/web/package.json b/web/package.json index ba28bffca1..c6822aba3b 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -18,6 +19,7 @@ "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", @@ -67,6 +69,7 @@ "typescript": "^5.9.3", "unified": "^11.0.5", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "@playwright/test": "1.60.0" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000000..e80617226a --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +export default defineConfig({ + testDir: './e2e', + timeout: 45_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5173', + viewport: { width: 1440, height: 900 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/web/playwright.live.config.ts b/web/playwright.live.config.ts new file mode 100644 index 0000000000..73b93f45af --- /dev/null +++ b/web/playwright.live.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'mermaid-lightbox-session.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1100 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d37e0828bc..dbbdcd8739 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -31,6 +31,8 @@ import type { DeleteUploadResponse, FileReadResponse, GitCommandResponse, + GrokModelsResponse, + GrokReasoningEffortResponse, ListDirectoryResponse, MachineListDirectoryResponse, MachinePathsExistsResponse, @@ -596,11 +598,22 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + permissionMode?: PermissionMode ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ + directory, + agent, + model, + modelReasoningEffort, + yolo, + sessionType, + worktreeName, + effort, + permissionMode + }) }) } @@ -654,6 +667,24 @@ export class ApiClient { ) } + async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` + ) + } + + async getSessionGrokModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/grok-models` + ) + } + + async getSessionGrokReasoningEffortOptions(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/grok-reasoning-effort-options` + ) + } + async getSlashCommands(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index 6917c6c52b..d270fb5128 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -10,6 +10,10 @@ describe('getContextBudgetTokens', () => { expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000) }) + it('uses the large budget for a full Claude model name carrying a [1m] suffix', () => { + expect(getContextBudgetTokens('claude-opus-4-8[1m]', 'claude')).toBe(990_000) + }) + it('uses Codex app-server context window with headroom', () => { expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400) }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 65466a54fe..b6d9b427a5 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -74,14 +74,11 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? if (!trimmedModel) { return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (isClaudeModelPreset(trimmedModel)) { + if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) { return trimmedModel.endsWith('[1m]') ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (trimmedModel.startsWith('claude-')) { - return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS - } return null })() diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 4ad3aec119..93e1d3db52 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -3,7 +3,7 @@ import { reduceChatBlocks } from './reducer' import { normalizeDecryptedMessage } from './normalize' import type { NormalizedMessage } from './types' import type { DecryptedMessage } from '@/types/api' -import type { ThreadGoal, ThreadGoalStatus } from '@/types/api' +import type { AgentState, ThreadGoal, ThreadGoalStatus } from '@/types/api' function userMessage(id: string, text: string, createdAt: number): NormalizedMessage { return { @@ -80,6 +80,51 @@ function decryptedMessage(id: string, content: unknown, createdAt: number): Decr } describe('reduceChatBlocks', () => { + it('renders Codex proposed plan tool messages as a completed plan card', () => { + const plan = '# Plan\n\n1. Inspect\n2. Implement' + const messages = [ + decryptedMessage('plan-call', { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'ExitPlanMode', + callId: 'codex-proposed-plan:plan-1', + input: { plan }, + id: 'plan-1' + } + } + }, 1), + decryptedMessage('plan-result', { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call-result', + callId: 'codex-proposed-plan:plan-1', + output: null, + id: 'plan-1:result' + } + } + }, 2) + ].map(message => normalizeDecryptedMessage(message)) + .filter((message): message is NormalizedMessage => message !== null) + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks).toContainEqual(expect.objectContaining({ + kind: 'tool-call', + id: 'codex-proposed-plan:plan-1', + tool: expect.objectContaining({ + name: 'ExitPlanMode', + state: 'completed', + input: { plan }, + result: null + }) + })) + }) + it('ignores child agent usage when calculating parent latest usage', () => { const messages: NormalizedMessage[] = [ { @@ -279,4 +324,42 @@ describe('reduceChatBlocks', () => { tokensUsed: 8016 }) }) + + it('does not pin a resolved request as a bottom card when its message is not in the window', () => { + // agentState keeps completedRequests after an ask is answered. With no + // tool_use message loaded for it, the permission-only synthesis used to + // append an "answered" card at the end of the timeline (no re-sort), + // pinning it above the composer forever. + const messages = [userMessage('u1', 'hello', 1_700_000_000_000)] + const agentState = { + requests: {}, + completedRequests: { + 'ask-done': { + tool: 'AskUserQuestion', + arguments: { questions: [] }, + status: 'approved', + createdAt: 1_700_000_000_500, + completedAt: 1_700_000_000_600 + } + } + } as unknown as AgentState + + const reduced = reduceChatBlocks(messages, agentState) + expect(reduced.blocks.some(b => b.kind === 'tool-call' && b.id === 'ask-done')).toBe(false) + }) + + it('still synthesizes a card for a pending request with no message in the window', () => { + const messages = [userMessage('u1', 'hello', 1_700_000_000_000)] + const agentState = { + requests: { + 'ask-pending': { tool: 'AskUserQuestion', arguments: { questions: [] }, createdAt: 1_700_000_000_500 } + }, + completedRequests: {} + } as unknown as AgentState + + const reduced = reduceChatBlocks(messages, agentState) + const block = reduced.blocks.find(b => b.kind === 'tool-call' && b.id === 'ask-pending') + expect(block).toBeDefined() + expect(block?.kind === 'tool-call' ? block.tool.permission?.status : null).toBe('pending') + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 5cb627c155..dca7b9ba9c 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -118,14 +118,23 @@ export function reduceChatBlocks( const rootResult = reduceTimeline(root, reducerContext) let hasReadyEvent = rootResult.hasReadyEvent - // Only create permission-only tool cards when there is no tool call/result in the transcript. - // Also skip if the permission is older than the oldest message in the current view, - // to avoid mixing old tool cards with newer messages when paginating. + // Synthesize a tool card only for a *pending* permission that has no tool + // call/result in the transcript — so the user can still answer it when its + // tool_use message hasn't loaded. A resolved request (approved/denied/ + // canceled) is history: agentState keeps it in completedRequests, but + // synthesizing it here appends a card to the end of the timeline (there is + // no chronological re-sort), pinning a stale "answered" card above the + // composer forever. Resolved requests render only via their own message, + // when it is in the window. + // Also skip if the permission is older than the oldest message in the + // current view, to avoid mixing old tool cards with newer messages when + // paginating. const oldestMessageTime = normalized.length > 0 ? Math.min(...normalized.map(m => m.createdAt)) : null for (const [id, entry] of permissionsById) { + if (entry.permission.status !== 'pending') continue if (toolIdsInMessages.has(id)) continue if (rootResult.toolBlocksById.has(id)) continue @@ -137,7 +146,7 @@ export function reduceChatBlocks( continue } - const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { + ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { createdAt, localId: null, name: entry.toolName, @@ -145,20 +154,6 @@ export function reduceChatBlocks( description: null, permission: entry.permission }) - - if (entry.permission.status === 'approved') { - block.tool.state = 'completed' - block.tool.completedAt = entry.permission.completedAt ?? createdAt - if (block.tool.result === undefined) { - block.tool.result = 'Approved' - } - } else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') { - block.tool.state = 'error' - block.tool.completedAt = entry.permission.completedAt ?? createdAt - if (block.tool.result === undefined && entry.permission.reason) { - block.tool.result = { error: entry.permission.reason } - } - } } // Calculate latest usage from messages (find the most recent message with usage data) diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index 796bea956f..99438fb69d 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', }, + grok: { + label: 'Gr', + colors: 'bg-[#111111] text-white', + }, pi: { label: 'Pi', colors: 'bg-[#5b21b6] text-white', diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx new file mode 100644 index 0000000000..f9966d60df --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ModelEffortSettingsSection } from './HappyComposer'; + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string) => key === 'misc.variant' ? 'Variant' : key + }) +})); + +describe('ModelEffortSettingsSection', () => { + it('renders Cursor variant choices and marks the selected variant', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Variant')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Composer 2.5 Fast/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ }).innerHTML).toContain('bg-[var(--app-link)]'); + }); +}); diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 10287ae6e7..9e28c9a71e 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -35,6 +35,7 @@ import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' +import { getDisplayedCodexServiceTier } from './codexFastMode' import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupported } from './piThinkingLevelOptions' import { groupModelsByProvider } from './piModelGroups' import { PiModelPanel } from './PiModelPanel' @@ -81,6 +82,57 @@ export type ComposerSendError = { const defaultSuggestionHandler = async (): Promise => [] +export function ModelEffortSettingsSection(props: { + agentFlavor?: string | null + options: Array<{ value: string; label: string }> + selectedValue: string | null | undefined + controlsDisabled: boolean + onChange: (value: string) => void +}) { + const { t } = useTranslation() + const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props + + return ( +
+
+ {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} +
+ {options.map((option) => { + const isSelected = selectedValue === option.value + return ( + + ) + })} +
+ ) +} + export function HappyComposer(props: { sessionId?: string disabled?: boolean @@ -107,6 +159,7 @@ export function HappyComposer(props: { * disambiguates when two providers share a modelId). */ piSelectedModel?: { provider: string; modelId: string } | null availableModelReasoningEffortOptions?: Array<{ value: string; name?: string }> + availableEffortOptions?: Array<{ value: string; name?: string }> /** Cursor: selected base model key (not wire id). */ selectedModelBase?: string | null /** Cursor: selected variant sku/wire for highlight when session stores an ACP wire id. */ @@ -174,6 +227,7 @@ export function HappyComposer(props: { piModels, piSelectedModel, availableModelReasoningEffortOptions, + availableEffortOptions, selectedModelBase, selectedModelVariant, modelEffortOptions, @@ -208,6 +262,7 @@ export function HappyComposer(props: { const modelReasoningEffort = rawModelReasoningEffort ?? null const effort = rawEffort ?? null const serviceTier = rawServiceTier ?? null + const displayedServiceTier = getDisplayedCodexServiceTier(serviceTier) const api = useAssistantApi() const { composerEnterBehavior } = useComposerEnterBehavior() @@ -418,7 +473,7 @@ export function HappyComposer(props: { ? getCodexComposerReasoningEffortOptions( modelReasoningEffort, agentFlavor, - agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined + availableModelReasoningEffortOptions ) : [], [agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions] @@ -455,8 +510,16 @@ export function HappyComposer(props: { const claudeEffortOptions = useMemo( () => agentFlavor === 'pi' ? getPiThinkingLevelOptions(effort, selectedPiModel?.thinkingLevelMap) + : agentFlavor === 'grok' && availableEffortOptions && availableEffortOptions.length > 0 + ? [ + { value: null, label: 'Default' }, + ...availableEffortOptions.map((option) => ({ + value: option.value, + label: option.name ?? option.value + })) + ] : getClaudeComposerEffortOptions(effort), - [agentFlavor, effort, selectedPiModel] + [agentFlavor, effort, selectedPiModel, availableEffortOptions] ) const permissionModes = useMemo( () => permissionModeOptions.map((option) => option.mode), @@ -984,6 +1047,24 @@ export function HappyComposer(props: { ) : null} + {showModelSettings && showModelEffortSettings ? ( +
+ ) : null} + + {showModelEffortSettings ? ( + + ) : null} + + {(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? ( +
+ ) : null} + {(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
) : null} @@ -1090,16 +1171,16 @@ export function HappyComposer(props: { >
- {serviceTier === option.value && ( + {displayedServiceTier === option.value && (
)}
- + {option.label} @@ -1156,7 +1237,7 @@ export function HappyComposer(props: { model, modelReasoningEffort, effort, - serviceTier, + displayedServiceTier, collaborationModeOptions, permissionModeOptions, handleCollaborationChange, diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 7032e7b6d5..32582ef138 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -10,6 +10,7 @@ import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types import type { ConversationStatus } from '@/realtime/types' import type { ThreadGoal } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' +import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels' import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' @@ -124,12 +125,6 @@ function formatTokenCount(value: number): string { return String(value) } -function formatCodexReasoningLabel(effort?: string | null): string { - const normalized = effort?.trim().toLowerCase() - if (!normalized || normalized === 'default') return 'reasoning default' - return `reasoning ${normalized}` -} - function isCodexFastMode(model?: string | null, effort?: string | null): boolean { const normalizedEffort = effort?.trim().toLowerCase() if (normalizedEffort === 'none' || normalizedEffort === 'minimal' || normalizedEffort === 'low') { @@ -212,7 +207,7 @@ export function StatusBar(props: { const collaborationModeLabel = displayCollaborationMode ? getCodexCollaborationModeLabel(displayCollaborationMode) : null - const codexReasoningLabel = (props.agentFlavor === 'codex' || props.agentFlavor === 'opencode') + const codexReasoningLabel = shouldShowCodexReasoningLabel(props.agentFlavor) ? formatCodexReasoningLabel(props.modelReasoningEffort) : null // Prefer the explicit service tier (the real Fast-mode toggle) when set; diff --git a/web/src/components/AssistantChat/codexFastMode.test.ts b/web/src/components/AssistantChat/codexFastMode.test.ts index 88baf15f9b..f7f661bfdd 100644 --- a/web/src/components/AssistantChat/codexFastMode.test.ts +++ b/web/src/components/AssistantChat/codexFastMode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { codexModelAdvertisesFastTier, isFastServiceTier } from './codexFastMode' +import { + codexModelAdvertisesFastTier, + getDisplayedCodexServiceTier, + isFastServiceTier +} from './codexFastMode' // Mirrors the real Codex catalog: the Fast tier's id is 'priority' and its // display name is 'Fast', so the CLI captures both as lowercased tokens @@ -49,3 +53,15 @@ describe('isFastServiceTier', () => { expect(isFastServiceTier('standard')).toBe(false) }) }) + +describe('getDisplayedCodexServiceTier', () => { + it('displays untouched and explicit non-fast tiers as standard', () => { + expect(getDisplayedCodexServiceTier(null)).toBe('standard') + expect(getDisplayedCodexServiceTier(undefined)).toBe('standard') + expect(getDisplayedCodexServiceTier('standard')).toBe('standard') + }) + + it('preserves the fast selection', () => { + expect(getDisplayedCodexServiceTier('fast')).toBe('fast') + }) +}) diff --git a/web/src/components/AssistantChat/codexFastMode.ts b/web/src/components/AssistantChat/codexFastMode.ts index 7d467d692c..7b85999007 100644 --- a/web/src/components/AssistantChat/codexFastMode.ts +++ b/web/src/components/AssistantChat/codexFastMode.ts @@ -49,3 +49,12 @@ export function codexModelAdvertisesFastTier( export function isFastServiceTier(serviceTier?: string | null): boolean { return serviceTier?.trim().toLowerCase() === 'fast' } + +/** + * The persisted null tier means the user has not chosen an override. The + * current two-option control still needs a visible selection, so display that + * untouched state as Standard without changing the value sent to the backend. + */ +export function getDisplayedCodexServiceTier(serviceTier?: string | null): 'standard' | 'fast' { + return isFastServiceTier(serviceTier) ? 'fast' : 'standard' +} diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts index 1b531f34e7..901e2c715c 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts @@ -23,6 +23,39 @@ describe('getCodexComposerReasoningEffortOptions', () => { ]) }) + it('uses arbitrary model-reported efforts for Codex', () => { + expect(getCodexComposerReasoningEffortOptions('extreme', 'codex', [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + { value: 'ultra' }, + { value: 'extreme' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'max', label: 'Max' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'extreme', label: 'Extreme' } + ]) + }) + + it('keeps an unsupported current Codex effort visible', () => { + expect(getCodexComposerReasoningEffortOptions('ultra', 'codex', [ + { value: 'low' }, + { value: 'max' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'low', label: 'Low' }, + { value: 'max', label: 'Max' } + ]) + }) + it('returns no options for OpenCode until dynamic options are available', () => { expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([]) expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([]) diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts index b2dfc9d09f..692a51ee00 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record = { medium: 'Medium', high: 'High', xhigh: 'XHigh', - max: 'Max' + max: 'Max', + ultra: 'Ultra' } function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null { @@ -31,7 +32,7 @@ function formatCodexReasoningEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } -function buildOpencodeComposerReasoningEffortOptions( +function buildDynamicReasoningEffortOptions( currentEffort: string | null, dynamicOptions: ComposerReasoningEffortSourceOption[] ): CodexComposerReasoningEffortOption[] { @@ -66,7 +67,11 @@ export function getCodexComposerReasoningEffortOptions( if (!dynamicOptions || dynamicOptions.length === 0) { return [] } - return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + } + + if (dynamicOptions && dynamicOptions.length > 0) { + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) } const options: CodexComposerReasoningEffortOption[] = [ diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 3cb2baf577..4391f9b99d 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -1,17 +1,13 @@ -import { useState } from 'react' import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' import { CliOutputBlock } from '@/components/CliOutputBlock' -import { CopyIcon, CheckIcon } from '@/components/icons' -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText' import { getConversationMessageAnchorId } from '@/chat/outline' -import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard' -import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp' +import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' const TOOL_COMPONENTS = { Fallback: HappyToolMessage @@ -25,8 +21,6 @@ const MESSAGE_PART_COMPONENTS = { } as const export function HappyAssistantMessage() { - const { copied, copy } = useCopyToClipboard() - const [showMetadata, setShowMetadata] = useState(false) const messageId = useAssistantState(({ message }) => message.id) const isCliOutput = useAssistantState(({ message }) => { const custom = message.metadata.custom as Partial | undefined @@ -51,187 +45,28 @@ export function HappyAssistantMessage() { return getAssistantCopyText(message.content) }) - const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs) const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.usage) const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.model) const turnCount = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.turnCount) - const hasMetadata = invokedAt != null - || (typeof durationMs === 'number' && durationMs >= 0) - || usage != null - || (messageModel != null && messageModel !== '') - || (typeof turnCount === 'number' && turnCount >= 2) + const metadata = { durationMs, usage, model: messageModel ?? null, turnCount } const rootClass = toolOnly ? 'py-1 min-w-0 max-w-full overflow-x-hidden' : 'px-1 min-w-0 max-w-full overflow-x-hidden' - if (isCliOutput) { - return ( - - -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- ) - } - - if (codexReview) { - return ( - -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
-
- ) - } - - if (toolOnly) { - return ( - -
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
-
- ) - } - return ( -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
+ {isCliOutput + ? + : codexReview + ? + : } +
) } diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx new file mode 100644 index 0000000000..89117c1db1 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import type { ComponentProps, PropsWithChildren } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { MessageActions } from './MessageActions' + +const copy = vi.fn() + +vi.mock('@assistant-ui/react', () => ({ + useAssistantState: (selector: (state: { message: { createdAt: Date } }) => unknown) => selector({ + message: { createdAt: new Date(2026, 6, 12, 10, 30) } + }) +})) + +vi.mock('@radix-ui/react-popover', () => ({ + Root: ({ children }: PropsWithChildren) => <>{children}, + Trigger: ({ children }: PropsWithChildren) => <>{children}, + Portal: ({ children }: PropsWithChildren) => <>{children}, + Content: ({ children }: PropsWithChildren) =>
{children}
+})) + +vi.mock('@/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copied: false, copy }) +})) + +function renderActions(props: ComponentProps) { + return render( + + + + ) +} + +describe('MessageActions', () => { + beforeEach(() => { + copy.mockReset() + localStorage.clear() + }) + + it('copies the supplied message text', () => { + renderActions({ align: 'start', copyText: 'message body' }) + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + + expect(copy).toHaveBeenCalledWith('message body') + }) + + it('shows meaningful assistant metadata in a popover without invoke time', () => { + renderActions({ + align: 'start', + metadata: { + durationMs: 1250, + model: 'gpt-5.2-codex', + usage: { input_tokens: 100, output_tokens: 25 } + } + }) + + expect(screen.getByRole('button', { name: 'Message details' })).toBeTruthy() + expect(screen.getByText('Duration: 1.3s')).toBeTruthy() + expect(screen.getByText('Model: gpt-5.2-codex')).toBeTruthy() + expect(screen.getByText('Usage: 125 billable tokens (100 in / 25 out)')).toBeTruthy() + expect(screen.queryByText(/^Invoke:/)).toBeNull() + }) + + it('omits the info action when no display metadata exists', () => { + renderActions({ align: 'end', copyText: 'message body', metadata: {} }) + + expect(screen.queryByRole('button', { name: 'Message details' })).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx new file mode 100644 index 0000000000..1238a4d442 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -0,0 +1,82 @@ +import * as Popover from '@radix-ui/react-popover' +import { CheckIcon, CopyIcon, InfoIcon } from '@/components/icons' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { useTranslation } from '@/lib/use-translation' +import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps } from './MessageMetadata' +import { MessageTimestamp } from './MessageTimestamp' +import { cn } from '@/lib/utils' + +type MessageActionsProps = { + align: 'start' | 'end' + copyText?: string + metadata?: Omit +} + +export function MessageActions({ align, copyText, metadata }: MessageActionsProps) { + const { copied, copy } = useCopyToClipboard() + const { t } = useTranslation() + const canCopy = Boolean(copyText) + const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false + + return ( +
+ {align === 'end' ? : null} + {canCopy ? ( + + ) : null} + {hasMetadata && metadata ? : null} + {align === 'start' ? : null} +
+ ) +} + +function DesktopTimestamp() { + return ( + + + + ) +} + +function MessageInfoPopover({ metadata }: { metadata: Omit }) { + const { t } = useTranslation() + return ( + + + + + + + + + + + ) +} diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts index e06946cdc2..f44274b526 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts +++ b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts @@ -54,22 +54,11 @@ describe('buildMessageMetadataLabels', () => { expect(parts).toContain('Duration: 0.0s') }) - it('does not drop an Invoke line when invokedAt is the unix epoch', () => { - const parts = buildMessageMetadataLabels({ invokedAt: 0 }) - expect(parts.some(p => p.startsWith('Invoke:'))).toBe(true) - }) - - it('omits the Invoke line when invokedAt is null or undefined', () => { - expect(buildMessageMetadataLabels({ invokedAt: null }).some(p => p.startsWith('Invoke:'))).toBe(false) - expect(buildMessageMetadataLabels({}).some(p => p.startsWith('Invoke:'))).toBe(false) - }) - // Proof of Invariance — single-turn inputs (turnCount omitted, or < 2) // must produce byte-identical output to the pre-aggregate footer so // existing single-turn cards do not regress visually. it('single-turn input is byte-identical with or without turnCount=1', () => { const base = { - invokedAt: 1700000000000, durationMs: 1234, model: 'claude-sonnet-4-6', usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' } @@ -84,7 +73,6 @@ describe('buildMessageMetadataLabels', () => { // formatting would surface visually in single-turn cards. it('pre-aggregate single-turn call produces the exact label sequence', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, durationMs: 1234, model: 'claude-sonnet-4-6', usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' } @@ -92,9 +80,7 @@ describe('buildMessageMetadataLabels', () => { // The Invoke value depends on the runner's timezone, so match its // shape rather than a literal time string. The remaining labels are // timezone-independent and locked exactly. - expect(parts).toHaveLength(4) - expect(parts[0]).toMatch(/^Invoke: \d{2}:\d{2}:\d{2}$/) - expect(parts.slice(1)).toEqual([ + expect(parts).toEqual([ 'Duration: 1.2s', 'Model: claude-sonnet-4-6', 'Usage: 22 billable tokens (3 in / 19 out)' @@ -103,7 +89,6 @@ describe('buildMessageMetadataLabels', () => { it('switches to Models/Total/N turns labels only when turnCount >= 2', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001', usage: { input_tokens: 100, output_tokens: 200, service_tier: 'standard' }, turnCount: 3 @@ -119,7 +104,6 @@ describe('buildMessageMetadataLabels', () => { // Mid-session model switch is rare; the common multi-turn case is one // model repeated across N turns. The label must stay singular then. const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6', usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' }, turnCount: 2 @@ -131,7 +115,6 @@ describe('buildMessageMetadataLabels', () => { it('omits Duration on aggregated footers when durationMs is undefined', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001', usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' }, turnCount: 2 diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.tsx b/web/src/components/AssistantChat/messages/MessageMetadata.tsx index 3135814750..bb73aca307 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.tsx +++ b/web/src/components/AssistantChat/messages/MessageMetadata.tsx @@ -1,20 +1,18 @@ import type { UsageData } from '@/chat/types' export type MessageMetadataProps = { - invokedAt?: number | null durationMs?: number usage?: UsageData model?: string | null /** * Distinct turn count for the surrounding response group. Single-turn - * footers pass `undefined` (or any value < 2) so the existing - * `Invoke · Model · Usage` output is preserved byte-for-byte. + * footers pass `undefined` (or any value < 2). */ turnCount?: number className?: string } -export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount }: Omit): string[] { +export function buildMessageMetadataLabels({ durationMs, usage, model, turnCount }: Omit): string[] { const parts: string[] = [] // Aggregated footers represent a response group with multiple distinct // turns. When the caller passes `turnCount >= 2` they have already @@ -22,18 +20,6 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model // across turns; we adjust the labels to reflect that. const isAggregated = typeof turnCount === 'number' && turnCount >= 2 - // Explicit nullish checks — `if (invokedAt)` would drop epoch 0, and - // `if (durationMs)` would drop legitimate 0 ms turns. - if (invokedAt != null) { - const time = new Date(invokedAt).toLocaleTimeString([], { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - parts.push(`Invoke: ${time}`) - } - if (typeof durationMs === 'number' && durationMs >= 0) { parts.push(`Duration: ${(durationMs / 1000).toFixed(1)}s`) } @@ -68,14 +54,14 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model return parts } -export function MessageMetadata({ invokedAt, durationMs, usage, model, turnCount, className }: MessageMetadataProps) { - const parts = buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount }) +export function MessageMetadata({ durationMs, usage, model, turnCount, className }: MessageMetadataProps) { + const parts = buildMessageMetadataLabels({ durationMs, usage, model, turnCount }) if (parts.length === 0) return null return ( -
+
{parts.map((part, i) => ( - {part} + {part} ))}
) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 8ebe3dc91e..a8ad631dfa 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { useHappyChatContext } from '@/components/AssistantChat/context' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' @@ -6,16 +5,11 @@ import { MessageStatusIndicator } from '@/components/AssistantChat/messages/Mess import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments' import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble' import { CliOutputBlock } from '@/components/CliOutputBlock' -import { CopyIcon, CheckIcon } from '@/components/icons' -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { getConversationMessageAnchorId } from '@/chat/outline' -import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' -import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp' +import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' export function HappyUserMessage() { const ctx = useHappyChatContext() - const { copied, copy } = useCopyToClipboard() - const [showMetadata, setShowMetadata] = useState(false) const role = useAssistantState(({ message }) => message.role) const messageId = useAssistantState(({ message }) => message.id) const text = useAssistantState(({ message }) => { @@ -46,10 +40,6 @@ export function HappyUserMessage() { if (custom?.kind !== 'cli-output') return '' return message.content.find((part) => part.type === 'text')?.text ?? '' }) - const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) - - const hasMetadata = invokedAt != null - if (role !== 'user') return null const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined @@ -59,26 +49,11 @@ export function HappyUserMessage() { return (
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )} +
) @@ -90,49 +65,22 @@ export function HappyUserMessage() { return ( -
+
{hasText ? : null} {hasAttachments ? : null}
- {(hasText || showStatus) && ( + {showStatus && (
- {hasText && ( - - )} {showStatus ? : null}
)}
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )}
+ ) } diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index a7900cec3f..438a0cc6cb 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -154,6 +154,16 @@ describe('getModelOptionsForFlavor', () => { { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' } ]) }) + + it('returns only default/current for grok without falling back to Claude models', () => { + expect(getModelOptionsForFlavor('grok')).toEqual([ + { value: null, label: 'Default' } + ]) + expect(getModelOptionsForFlavor('grok', 'grok-4.5')).toEqual([ + { value: null, label: 'Default' }, + { value: 'grok-4.5', label: 'grok-4.5' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -219,6 +229,10 @@ describe('getNextModelForFlavor', () => { expect(next).toBe('claude-sonnet-4-5') }) + it('keeps the current grok model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('grok', 'grok-4.5')).toBe('grok-4.5') + }) + it('returns null for pi without a current model (no Claude fallback)', () => { const next = getNextModelForFlavor('pi', null) expect(next).toBeNull() diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 9f350ecd16..7e4aed68fb 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -126,6 +126,9 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + if (flavor === 'grok') { + 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 @@ -175,6 +178,9 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + if (flavor === 'grok') { + 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') { diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx index cc523f0620..d24744feed 100644 --- a/web/src/components/HoverTooltip.tsx +++ b/web/src/components/HoverTooltip.tsx @@ -5,6 +5,9 @@ import { cn } from '@/lib/utils' export const SESSION_ROW_TOOLTIP_FOCUS_CLASS = 'group-focus-visible/session-row:opacity-100 group-focus-visible/session-row:visible' +export const MACHINE_ROW_TOOLTIP_FOCUS_CLASS = + 'group-focus-visible/machine-row:opacity-100 group-focus-visible/machine-row:visible' + /** * Lightweight CSS-driven tooltip used by the session list to surface "why is * this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal, @@ -30,21 +33,25 @@ export function HoverTooltip(props: { /** Rich tooltip content. Plain text or a small fragment with headings/lists. */ children: ReactNode side?: 'top' | 'bottom' - align?: 'start' | 'center' | 'end' + align?: 'start' | 'center' | 'end' | 'row' className?: string /** Parent-focus reveal classes (e.g. SESSION_ROW_TOOLTIP_FOCUS_CLASS). */ revealOnParentFocusClass?: string + /** Optional classes for the tooltip panel (e.g. wider popover). */ + tooltipClassName?: string }) { const side = props.side ?? 'bottom' const align = props.align ?? 'center' + const spansRow = align === 'row' - const alignClasses = - align === 'start' ? 'left-0' + const alignClasses = spansRow + ? 'left-1 right-1 w-auto' + : align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2' return ( - + {props.target} @@ -52,7 +59,8 @@ export function HoverTooltip(props: { role="tooltip" id={props.id} className={cn( - 'pointer-events-none absolute z-30 max-w-[14rem] whitespace-normal', + 'pointer-events-none absolute z-30 whitespace-normal', + spansRow ? 'max-w-none' : 'max-w-[14rem]', 'rounded-md border border-[var(--app-border)] bg-[var(--app-secondary-bg)]', 'px-2 py-1 text-xs leading-snug text-[var(--app-fg)] shadow-lg', side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1', @@ -60,6 +68,7 @@ export function HoverTooltip(props: { 'opacity-0 invisible', 'group-hover:opacity-100 group-hover:visible', props.revealOnParentFocusClass, + props.tooltipClassName, 'transition-opacity duration-100' )} > diff --git a/web/src/components/MachineGroupHeader.test.tsx b/web/src/components/MachineGroupHeader.test.tsx new file mode 100644 index 0000000000..1193cbeb9d --- /dev/null +++ b/web/src/components/MachineGroupHeader.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Machine } from '@/types/api' +import { MachineGroupHeader } from './MachineGroupHeader' +import { I18nProvider } from '@/lib/i18n-context' + +const machine: Machine = { + id: 'Teemo', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { + host: 'Teemo', + platform: 'win32', + happyCliVersion: '0.20.2', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, +} + +describe('MachineGroupHeader', () => { + it('renders a single-row machine tile with os label and compact health', () => { + render( + + {}} + machine={machine} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' }, + ], + overallTone: 'warn', + status: 'elevated', + }} + /> + + ) + + expect(screen.getByRole('button', { name: /Teemo/i })).toBeTruthy() + expect(screen.getByText('Windows')).toBeTruthy() + expect(screen.getByText('(4)')).toBeTruthy() + expect(screen.getByLabelText(/CPU 12 percent; RAM 88 percent/i)).toBeTruthy() + }) + + it('shows compact uptime in the machine meta row', () => { + render( + + {}} + machine={{ + ...machine, + metadata: { ...machine.metadata!, host: 'proxmox', platform: 'linux' }, + }} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 40, tone: 'ok' }, + ], + overallTone: 'ok', + status: 'healthy', + uptimeDetail: '1h 54m', + }} + /> + + ) + + expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineGroupHeader.tsx b/web/src/components/MachineGroupHeader.tsx new file mode 100644 index 0000000000..6f8e5d5fd9 --- /dev/null +++ b/web/src/components/MachineGroupHeader.tsx @@ -0,0 +1,133 @@ +import { useId } from 'react' +import type { Machine } from '@/types/api' +import { MACHINE_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip' +import { MachineHealthIndicator } from '@/components/MachineHealthIndicator' +import { + getMachineHost, + getMachinePlatform, + resolveMachineOsLabel, + shouldShowMachineHostSubtitle, + type MachineHealthPresentation, +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function MachineIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function ChevronIcon(props: { className?: string; collapsed?: boolean }) { + return ( + + + + ) +} + +function formatOsLabel( + osLabel: ReturnType, + t: (key: string) => string +): string { + if (osLabel.kind === 'raw') { + return osLabel.value + } + return t(osLabel.key) +} + +export function MachineGroupHeader(props: { + label: string + sessionCount: number + collapsed: boolean + onToggle: () => void + machine?: Machine + healthPresentation: MachineHealthPresentation | null +}) { + const { t } = useTranslation() + const healthTooltipId = useId() + const platform = getMachinePlatform(props.machine) + const host = getMachineHost(props.machine) + const osLabel = resolveMachineOsLabel(platform) + const osText = formatOsLabel(osLabel, t) + const showHost = shouldShowMachineHostSubtitle(props.label, host) + const uptimeText = props.healthPresentation?.uptimeDetail + const metaParts = [osText] + if (showHost && host) { + metaParts.push(host) + } + if (uptimeText) { + metaParts.push(t('machine.health.uptimeCompact', { value: uptimeText })) + } + const machineMeta = metaParts.join(' · ') + const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0 + + return ( + + ) +} diff --git a/web/src/components/MachineHealthIndicator.test.tsx b/web/src/components/MachineHealthIndicator.test.tsx new file mode 100644 index 0000000000..677846f484 --- /dev/null +++ b/web/src/components/MachineHealthIndicator.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { MachineHealthIndicator } from './MachineHealthIndicator' +import { I18nProvider } from '@/lib/i18n-context' + +describe('MachineHealthIndicator', () => { + it('renders labeled cpu and ram meter bars', () => { + render( + + + + ) + + expect(screen.getByText('CPU')).toBeTruthy() + expect(screen.getByText('RAM')).toBeTruthy() + expect(screen.getByText('CPU across all 6 cores')).toBeTruthy() + expect(screen.getByLabelText(/CPU 72/i)).toBeTruthy() + }) + + it('renders inline percent labels', () => { + render( + + + + ) + + expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineHealthIndicator.tsx b/web/src/components/MachineHealthIndicator.tsx new file mode 100644 index 0000000000..457039d49f --- /dev/null +++ b/web/src/components/MachineHealthIndicator.tsx @@ -0,0 +1,181 @@ +import { useId } from 'react' +import { HoverTooltip } from '@/components/HoverTooltip' +import { + MACHINE_HEALTH_BAR_FILL_CLASS, + MACHINE_HEALTH_CHIP_CLASS, + getCpuMetricTooltipLabel, + type MachineHealthMetricPresentation, + type MachineHealthPresentation +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function HealthMeterBar(props: { + label: string + percent: number + tone: MachineHealthPresentation['overallTone'] + layout: 'stack' | 'inline' + compact?: boolean +}) { + const barWidthClass = props.compact ? 'w-8' : props.layout === 'inline' ? 'w-14' : 'w-11' + const labelWidthClass = props.compact ? 'w-5 text-[8px]' : 'w-6 text-[9px]' + + return ( +
+ + {props.label} + + + ) +} + +function TooltipMetricStat(props: { + metric: MachineHealthMetricPresentation + label: string +}) { + return ( + + {props.label} + + {props.metric.percent}% + + + ) +} + +function MachineHealthTooltipBody(props: { + presentation: MachineHealthPresentation +}) { + const { t } = useTranslation() + const { presentation } = props + const statusKey = `machine.health.status.${presentation.status}` as const + + return ( + + + {t('machine.health.tooltip.title')} + {t(statusKey)} + + + {presentation.metrics.map((metric) => ( + + ))} + {presentation.loadDetail ? ( + + {t('machine.health.tooltip.loadShort')} + + {presentation.loadDetail} + + + ) : null} + {presentation.uptimeDetail ? ( + + {t('machine.health.tooltip.uptimeShort')} + + {presentation.uptimeDetail} + + + ) : null} + + + {t('machine.health.tooltip.hint')} + + + ) +} + +export function MachineHealthIndicator(props: { + presentation: MachineHealthPresentation + className?: string + layout?: 'stack' | 'inline' + compact?: boolean + tooltipId?: string + revealOnParentFocusClass?: string +}) { + const { t } = useTranslation() + const generatedTooltipId = useId() + const tooltipId = props.tooltipId ?? generatedTooltipId + const { presentation, layout = 'stack', compact = false } = props + + const ariaLabel = presentation.metrics.length > 0 + ? presentation.metrics + .map((metric) => t(`machine.health.aria.${metric.id}`, { n: metric.percent })) + .join('; ') + : t('machine.health.aria.unknown') + + const chip = ( + + {presentation.metrics.map((metric) => ( + + ))} + + ) + + return ( + + + + ) +} diff --git a/web/src/components/MarkdownRenderer.test.tsx b/web/src/components/MarkdownRenderer.test.tsx new file mode 100644 index 0000000000..ed4834990d --- /dev/null +++ b/web/src/components/MarkdownRenderer.test.tsx @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { MarkdownRenderer } from './MarkdownRenderer' + +describe('MarkdownRenderer', () => { + afterEach(() => { + cleanup() + }) + + it('renders fenced code blocks with the shared syntax highlighter shell in standalone mode', () => { + render( + + + + ) + + expect(document.querySelector('.aui-md-codeblock')).toBeTruthy() + }) + + it('renders inline code without the fenced-code shell in standalone mode', () => { + render( + + + + ) + + expect(document.querySelector('.aui-md-codeblock')).toBeFalsy() + expect(document.querySelector('.aui-md-code')).toBeTruthy() + }) +}) diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index ecc71e736f..e66122cd6e 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -1,6 +1,8 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown' import { TextMessagePartProvider } from '@assistant-ui/react' +import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS, @@ -11,6 +13,8 @@ import { denyOnlyTransform, UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' +import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' +import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown' import { cn } from '@/lib/utils' interface MarkdownRendererProps { @@ -18,6 +22,73 @@ interface MarkdownRendererProps { components?: MarkdownTextPrimitiveProps['components'] className?: string preserveSingleLineBreaks?: boolean + /** Render outside assistant-ui thread context (file pane, fixtures). */ + standalone?: boolean +} + +function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) { + const Code = defaultComponents.code! + return +} + +function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) { + const child = Children.toArray(props.children)[0] + if (!isValidElement>(child)) { + const Pre = defaultComponents.pre! + return
+    }
+
+    const className = String(child.props.className ?? '')
+    const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
+    const code = String(child.props.children ?? '').replace(/\n$/, '')
+    const Highlighter: ComponentType =
+        MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
+        ?? SyntaxHighlighter
+    const CodeHeader = defaultComponents.CodeHeader as ComponentType
+    const Pre = defaultComponents.pre!
+    const Code = defaultComponents.code!
+
+    return (
+        <>
+            
+            
+        
+    )
+}
+
+function StandaloneMarkdownContent(props: MarkdownRendererProps) {
+    const mergedComponents = props.components
+        ? { ...defaultComponents, ...props.components }
+        : defaultComponents
+
+    const {
+        pre: _pre,
+        code: _code,
+        SyntaxHighlighter: _sh,
+        CodeHeader: _header,
+        ...componentsRest
+    } = mergedComponents as typeof mergedComponents & Record
+
+    const components = useMemo(() => ({
+        ...(componentsRest as Components),
+        pre: StandalonePre,
+        code: StandaloneCode,
+    }), [componentsRest])
+
+    return (
+        
+            
+ + {props.content} + +
+
+ ) } function MarkdownContent(props: MarkdownRendererProps) { @@ -42,5 +113,8 @@ function MarkdownContent(props: MarkdownRendererProps) { } export function MarkdownRenderer(props: MarkdownRendererProps) { + if (props.standalone) { + return + } return } diff --git a/web/src/components/NewSession/AgentSelector.test.tsx b/web/src/components/NewSession/AgentSelector.test.tsx new file mode 100644 index 0000000000..fbb2ebba4b --- /dev/null +++ b/web/src/components/NewSession/AgentSelector.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { AgentSelector } from './AgentSelector' +import type { AgentType } from './types' + +function renderedAgentValues(): string[] { + const { container } = render( + {}} /> + ) + return Array.from(container.querySelectorAll('input[type="radio"]')) + .map((el) => (el as HTMLInputElement).value) +} + +describe('AgentSelector', () => { + it('does not offer the sunset Gemini CLI as a new-session agent', () => { + expect(renderedAgentValues()).not.toContain('gemini') + }) + + it('offers exactly the creatable agent flavors', () => { + expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS]) + }) +}) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 4146f30815..95d693a459 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,4 +1,4 @@ -import { AGENT_FLAVORS } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS, getFlavorLabel } from '@hapi/protocol' import type { AgentType } from './types' import { useTranslation } from '@/lib/use-translation' @@ -15,7 +15,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {AGENT_FLAVORS.map((agentType) => ( + {CREATABLE_AGENT_FLAVORS.map((agentType) => ( ))}
diff --git a/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx b/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx new file mode 100644 index 0000000000..519b744adf --- /dev/null +++ b/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { GrokPermissionModeSelector } from './GrokPermissionModeSelector' + +describe('GrokPermissionModeSelector', () => { + it('offers Auto when Grok advertises the account feature', () => { + render( + + ) + + expect(screen.getByRole('option', { name: 'Auto' })).not.toBeDisabled() + }) + + it('shows Auto as unavailable when Grok does not advertise it', () => { + render( + + ) + + expect(screen.getByRole('option', { name: 'Auto (unavailable)' })).toBeDisabled() + expect(screen.getByText(/did not enable Auto permissions/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/NewSession/GrokPermissionModeSelector.tsx b/web/src/components/NewSession/GrokPermissionModeSelector.tsx new file mode 100644 index 0000000000..0d72f71eee --- /dev/null +++ b/web/src/components/NewSession/GrokPermissionModeSelector.tsx @@ -0,0 +1,47 @@ +import { + getPermissionModeOptionsForFlavor, + type GrokPermissionMode +} from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import type { AgentType } from './types' + +export function GrokPermissionModeSelector(props: { + agent: AgentType + value: GrokPermissionMode + autoPermissionModeSupported: boolean | null + isDisabled: boolean + onChange: (value: GrokPermissionMode) => void +}) { + const { t } = useTranslation() + + if (props.agent !== 'grok') return null + + return ( +
+ + + {props.autoPermissionModeSupported === false ? ( + + {t('newSession.grokAutoUnavailableDesc')} + + ) : null} +
+ ) +} diff --git a/web/src/components/NewSession/LaunchEffortSelector.test.tsx b/web/src/components/NewSession/LaunchEffortSelector.test.tsx new file mode 100644 index 0000000000..bffcacbd71 --- /dev/null +++ b/web/src/components/NewSession/LaunchEffortSelector.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { LaunchEffortSelector } from './LaunchEffortSelector' + +describe('LaunchEffortSelector', () => { + it('renders Grok low/medium/high effort and forwards the selection', () => { + const onChange = vi.fn() + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + + expect(Array.from(select.options).map((option) => option.value)).toEqual([ + 'auto', 'low', 'medium', 'high' + ]) + fireEvent.change(select, { target: { value: 'low' } }) + expect(onChange).toHaveBeenCalledWith('low') + }) +}) diff --git a/web/src/components/NewSession/ClaudeEffortSelector.tsx b/web/src/components/NewSession/LaunchEffortSelector.tsx similarity index 66% rename from web/src/components/NewSession/ClaudeEffortSelector.tsx rename to web/src/components/NewSession/LaunchEffortSelector.tsx index 08e8e6990a..97e1c569a0 100644 --- a/web/src/components/NewSession/ClaudeEffortSelector.tsx +++ b/web/src/components/NewSession/LaunchEffortSelector.tsx @@ -1,19 +1,24 @@ -import type { AgentType, ClaudeEffort } from './types' -import { CLAUDE_EFFORT_OPTIONS } from './types' +import type { AgentType, LaunchEffort } from './types' +import { CLAUDE_EFFORT_OPTIONS, GROK_EFFORT_OPTIONS } from './types' import { useTranslation } from '@/lib/use-translation' -export function ClaudeEffortSelector(props: { +export function LaunchEffortSelector(props: { agent: AgentType - effort: ClaudeEffort + effort: LaunchEffort isDisabled: boolean - onEffortChange: (value: ClaudeEffort) => void + onEffortChange: (value: LaunchEffort) => void + grokOptions?: Array<{ value: string; label: string }> }) { const { t } = useTranslation() - if (props.agent !== 'claude') { + if (props.agent !== 'claude' && props.agent !== 'grok') { return null } + const options = props.agent === 'grok' + ? (props.grokOptions ?? GROK_EFFORT_OPTIONS) + : CLAUDE_EFFORT_OPTIONS + return (
props.onChange(event.target.checked)} className="peer sr-only" aria-label={props.label} /> + + + + } /> + ) +} + +export function SettingsChoiceGroup(props: { + label: string + value: T + options: ReadonlyArray<{ value: T; label: string; description?: string }> + onChange: (value: T) => void + columns?: 2 | 4 | 5 +}) { + const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + return ( +
+ {props.label} +
+ {props.options.map((option) => { + const selected = props.value === option.value + return ( + + ) + })} +
+
+ ) +} + +export function SettingsLinkRow(props: { label: string; value?: string; description?: string; onClick: () => void }) { + return ( + + ) +} diff --git a/web/src/dev/mermaid-lightbox-cases.ts b/web/src/dev/mermaid-lightbox-cases.ts new file mode 100644 index 0000000000..c926802151 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-cases.ts @@ -0,0 +1,96 @@ +/** Minimal valid sources for Playwright lightbox coverage (one case per diagram kind). */ +export const MERMAID_LIGHTBOX_CASES = { + flowchart: `flowchart LR + Hub --> WebUI + WebUI --> Lightbox`, + + sequence: `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + M-->>U: Lightbox`, + + class: `classDiagram + Animal <|-- Duck + Animal : +int age + Duck : +swim()`, + + state: `stateDiagram-v2 + [*] --> Still + Still --> Moving + Moving --> Still`, + + er: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains`, + + journey: `journey + title Checkout + section Browse + Open site: 5: User + Add item: 3: User`, + + gantt: `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Ship feature :2024-06-01, 3d`, + + pie: `pie title Pets + "Dogs" : 386 + "Cats" : 214`, + + quadrant: `quadrantChart + title Reach and engagement + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + Product A: [0.3, 0.6] + Product B: [0.45, 0.23]`, + + requirement: `requirementDiagram + requirement test_req { + id: 1 + text: the tested requirement. + risk: high + verifymethod: test + }`, + + gitGraph: `gitGraph + commit + branch develop + checkout develop + commit + checkout main + merge develop`, + + c4: `C4Context + title System + Person(user, "User") + System(app, "Application") + Rel(user, app, "Uses")`, + + mindmap: `mindmap + root((HAPI)) + Chat + Hub + Web`, + + timeline: `timeline + title History + 2024 : Alpha + 2025 : Beta`, + + kanban: `kanban + title Board + column Todo + task1[Task 1] + column Done + task2[Task 2]`, +} as const + +export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES + +export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[] diff --git a/web/src/dev/mermaid-lightbox-e2e.tsx b/web/src/dev/mermaid-lightbox-e2e.tsx new file mode 100644 index 0000000000..e38b007a17 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-e2e.tsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, + type MermaidLightboxCaseId, +} from '@/dev/mermaid-lightbox-cases' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +const params = new URLSearchParams(window.location.search) +const caseId = params.get('case') as MermaidLightboxCaseId | null +const code = caseId && caseId in MERMAID_LIGHTBOX_CASES + ? MERMAID_LIGHTBOX_CASES[caseId] + : MERMAID_LIGHTBOX_CASES.flowchart + +document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}` + +createRoot(root).render( + +
+
,
+                    Code: (props) => ,
+                }}
+            />
+        
+
, +) + +// Expose case list for Playwright discovery without importing TS in Node. +;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS] diff --git a/web/src/dev/mermaid-lightbox-smoke.tsx b/web/src/dev/mermaid-lightbox-smoke.tsx new file mode 100644 index 0000000000..80ff954632 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-smoke.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +createRoot(root).render( + + Lightbox\n Lightbox --> Visible'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                Code: (props) => ,
+            }}
+        />
+    ,
+)
diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts
index bf61825f01..4c30f3358f 100644
--- a/web/src/hooks/mutations/useSpawnSession.ts
+++ b/web/src/hooks/mutations/useSpawnSession.ts
@@ -1,5 +1,5 @@
 import { useMutation, useQueryClient } from '@tanstack/react-query'
-import type { AgentFlavor } from '@hapi/protocol'
+import type { AgentFlavor, PermissionMode } from '@hapi/protocol'
 import type { ApiClient } from '@/api/client'
 import type { SpawnResponse } from '@/types/api'
 import { queryKeys } from '@/lib/query-keys'
@@ -12,6 +12,7 @@ type SpawnInput = {
     effort?: string
     modelReasoningEffort?: string
     yolo?: boolean
+    permissionMode?: PermissionMode
     sessionType?: 'simple' | 'worktree'
     worktreeName?: string
 }
@@ -37,7 +38,8 @@ export function useSpawnSession(api: ApiClient | null): {
                 input.yolo,
                 input.sessionType,
                 input.worktreeName,
-                input.effort
+                input.effort,
+                input.permissionMode
             )
         },
         onSuccess: () => {
diff --git a/web/src/hooks/queries/useGrokModels.ts b/web/src/hooks/queries/useGrokModels.ts
new file mode 100644
index 0000000000..41a0d489c4
--- /dev/null
+++ b/web/src/hooks/queries/useGrokModels.ts
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query'
+import type { ApiClient } from '@/api/client'
+import type { GrokModelSummary } from '@/types/api'
+import { queryKeys } from '@/lib/query-keys'
+
+export function useGrokModels(args: {
+    api: ApiClient | null
+    sessionId?: string | null
+    enabled?: boolean
+}): {
+    availableModels: GrokModelSummary[]
+    currentModelId: string | null
+    isLoading: boolean
+    error: string | null
+} {
+    const enabled = Boolean(args.enabled && args.api && args.sessionId)
+    const query = useQuery({
+        queryKey: args.sessionId
+            ? queryKeys.sessionGrokModels(args.sessionId)
+            : ['session-grok-models', 'unknown'] as const,
+        queryFn: async () => {
+            if (!args.api || !args.sessionId) throw new Error('Grok session unavailable')
+            return await args.api.getSessionGrokModels(args.sessionId)
+        },
+        enabled,
+        staleTime: 30_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 Grok models')
+            : query.error instanceof Error ? query.error.message : null,
+    }
+}
diff --git a/web/src/hooks/queries/useGrokModelsForCwd.ts b/web/src/hooks/queries/useGrokModelsForCwd.ts
new file mode 100644
index 0000000000..4dcceca580
--- /dev/null
+++ b/web/src/hooks/queries/useGrokModelsForCwd.ts
@@ -0,0 +1,50 @@
+import { useQuery } from '@tanstack/react-query'
+import type { ApiClient } from '@/api/client'
+import type { GrokModelSummary } from '@/types/api'
+import { queryKeys } from '@/lib/query-keys'
+
+export function useGrokModelsForCwd(args: {
+    api: ApiClient | null
+    machineId?: string | null
+    cwd?: string | null
+    enabled?: boolean
+}): {
+    availableModels: GrokModelSummary[]
+    currentModelId: string | null
+    autoPermissionModeSupported: boolean | null
+    isLoading: boolean
+    error: string | null
+} {
+    const { api, machineId, cwd } = args
+    const trimmedCwd = typeof cwd === 'string' ? cwd.trim() : ''
+    const enabled = Boolean(args.enabled && api && machineId && trimmedCwd)
+
+    const query = useQuery({
+        queryKey: machineId && trimmedCwd
+            ? queryKeys.machineGrokModelsForCwd(machineId, trimmedCwd)
+            : ['machine-grok-models', 'unknown', 'unknown'] as const,
+        queryFn: async () => {
+            if (!api || !machineId || !trimmedCwd) {
+                throw new Error('Grok models target unavailable')
+            }
+            return await api.getMachineGrokModelsForCwd(machineId, trimmedCwd)
+        },
+        enabled,
+        staleTime: 60_000,
+        retry: false,
+    })
+
+    return {
+        availableModels: query.data?.availableModels ?? [],
+        currentModelId: query.data?.currentModelId ?? null,
+        autoPermissionModeSupported: query.data?.autoPermissionModeSupported ?? null,
+        isLoading: query.isLoading,
+        error: query.data?.success === false
+            ? (query.data.error ?? 'Failed to load Grok models')
+            : query.error instanceof Error
+                ? query.error.message
+                : query.error
+                    ? 'Failed to load Grok models'
+                    : null,
+    }
+}
diff --git a/web/src/hooks/queries/useGrokReasoningEffortOptions.ts b/web/src/hooks/queries/useGrokReasoningEffortOptions.ts
new file mode 100644
index 0000000000..9a82fbe1a8
--- /dev/null
+++ b/web/src/hooks/queries/useGrokReasoningEffortOptions.ts
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query'
+import type { ApiClient } from '@/api/client'
+import type { GrokReasoningEffortOption } from '@/types/api'
+import { queryKeys } from '@/lib/query-keys'
+
+export function useGrokReasoningEffortOptions(args: {
+    api: ApiClient | null
+    sessionId?: string | null
+    enabled?: boolean
+}): {
+    options: GrokReasoningEffortOption[]
+    currentValue: string | null
+    isLoading: boolean
+    error: string | null
+} {
+    const enabled = Boolean(args.enabled && args.api && args.sessionId)
+    const query = useQuery({
+        queryKey: args.sessionId
+            ? queryKeys.sessionGrokReasoningEffortOptions(args.sessionId)
+            : ['session-grok-reasoning-effort-options', 'unknown'] as const,
+        queryFn: async () => {
+            if (!args.api || !args.sessionId) throw new Error('Grok session unavailable')
+            return await args.api.getSessionGrokReasoningEffortOptions(args.sessionId)
+        },
+        enabled,
+        staleTime: 30_000,
+        retry: false,
+    })
+
+    return {
+        options: query.data?.options ?? [],
+        currentValue: query.data?.currentValue ?? null,
+        isLoading: query.isLoading,
+        error: query.data?.success === false
+            ? (query.data.error ?? 'Failed to load Grok effort options')
+            : query.error instanceof Error ? query.error.message : null,
+    }
+}
diff --git a/web/src/hooks/useAppGoBack.test.ts b/web/src/hooks/useAppGoBack.test.ts
new file mode 100644
index 0000000000..91101ff330
--- /dev/null
+++ b/web/src/hooks/useAppGoBack.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { getSettingsBackTarget } from './useAppGoBack'
+
+describe('getSettingsBackTarget', () => {
+    it.each([
+        ['/settings', '/sessions'],
+        ['/settings/general', '/settings'],
+        ['/settings/display', '/settings'],
+        ['/settings/voice', '/settings'],
+        ['/settings/voice/voices', '/settings/voice'],
+        ['/settings/voice/advanced', '/settings/voice'],
+        ['/sessions', null],
+    ])('maps %s to %s', (pathname, target) => {
+        expect(getSettingsBackTarget(pathname)).toBe(target)
+    })
+})
diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts
index 9836166938..470e95d754 100644
--- a/web/src/hooks/useAppGoBack.ts
+++ b/web/src/hooks/useAppGoBack.ts
@@ -1,6 +1,13 @@
 import { useCallback } from 'react'
 import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
 
+export function getSettingsBackTarget(pathname: string): string | null {
+    if (pathname === '/settings') return '/sessions'
+    if (pathname === '/settings/voice/advanced' || pathname === '/settings/voice/voices') return '/settings/voice'
+    if (pathname.startsWith('/settings/')) return '/settings'
+    return null
+}
+
 export function useAppGoBack(): () => void {
     const navigate = useNavigate()
     const router = useRouter()
@@ -14,9 +21,10 @@ export function useAppGoBack(): () => void {
             return
         }
 
-        // Settings page always goes back to sessions
-        if (pathname === '/settings') {
-            navigate({ to: '/sessions' })
+        // Settings uses explicit parent routes so mobile drill-down remains predictable.
+        const settingsBackTarget = getSettingsBackTarget(pathname)
+        if (settingsBackTarget) {
+            navigate({ to: settingsBackTarget })
             return
         }
 
diff --git a/web/src/index.css b/web/src/index.css
index 5833f9d74a..612b0704d0 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -433,6 +433,33 @@ body {
     margin-top: max(0px, calc((var(--app-chat-line-height) - var(--app-message-action-size)) / 2));
 }
 
+.happy-message-actions-desktop-only,
+.happy-message-actions-desktop-only-row {
+    display: none;
+}
+
+@media (hover: hover) and (pointer: fine) {
+    .happy-message-actions {
+        opacity: 0;
+        pointer-events: none;
+        transition: opacity 150ms ease;
+    }
+
+    .happy-message:hover .happy-message-actions,
+    .happy-message:focus-within .happy-message-actions {
+        opacity: 1;
+        pointer-events: auto;
+    }
+
+    .happy-message-actions-desktop-only {
+        display: inline-flex;
+    }
+
+    .happy-message-actions-desktop-only-row {
+        display: flex;
+    }
+}
+
 .aui-md :where(.contains-task-list) {
     list-style: none;
     padding-left: 0;
diff --git a/web/src/lib/codexModelCapabilities.test.ts b/web/src/lib/codexModelCapabilities.test.ts
new file mode 100644
index 0000000000..fb19eafa70
--- /dev/null
+++ b/web/src/lib/codexModelCapabilities.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import type { CodexModelSummary } from '@/types/api'
+import {
+    getCodexModelReasoningEfforts,
+    resolveCodexModel,
+    supportsCodexReasoningEffort
+} from './codexModelCapabilities'
+
+const models: CodexModelSummary[] = [
+    {
+        id: 'gpt-5.6-sol',
+        displayName: 'GPT-5.6-Sol',
+        isDefault: true,
+        supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
+    },
+    {
+        id: 'gpt-5.6-luna',
+        displayName: 'GPT-5.6-Luna',
+        isDefault: false,
+        supportedReasoningEfforts: ['LOW', 'medium', 'max', 'max', '  ']
+    }
+]
+
+describe('Codex model capabilities', () => {
+    it('resolves auto and missing model ids to the reported default model', () => {
+        expect(resolveCodexModel(models, 'auto')?.id).toBe('gpt-5.6-sol')
+        expect(resolveCodexModel(models, null)?.id).toBe('gpt-5.6-sol')
+        expect(resolveCodexModel(models, { modelId: 'gpt-5.6-luna' })?.id).toBe('gpt-5.6-luna')
+    })
+
+    it('returns normalized, de-duplicated efforts for the selected model', () => {
+        expect(getCodexModelReasoningEfforts(models, 'gpt-5.6-luna')).toEqual([
+            'low',
+            'medium',
+            'max'
+        ])
+    })
+
+    it('distinguishes unsupported efforts from unavailable capability data', () => {
+        expect(supportsCodexReasoningEffort(models, 'gpt-5.6-sol', 'ultra')).toBe(true)
+        expect(supportsCodexReasoningEffort(models, 'gpt-5.6-luna', 'ultra')).toBe(false)
+        expect(supportsCodexReasoningEffort(models, 'unknown', 'ultra')).toBeUndefined()
+    })
+})
diff --git a/web/src/lib/codexModelCapabilities.ts b/web/src/lib/codexModelCapabilities.ts
new file mode 100644
index 0000000000..e622b9b108
--- /dev/null
+++ b/web/src/lib/codexModelCapabilities.ts
@@ -0,0 +1,44 @@
+import type { CodexModelSummary } from '@/types/api'
+
+type ModelSelection = string | { modelId: string } | null | undefined
+
+function normalizeEfforts(efforts: readonly string[] | undefined): string[] | undefined {
+    if (!efforts) return undefined
+
+    const normalized = [...new Set(
+        efforts
+            .map((effort) => effort.trim().toLowerCase())
+            .filter(Boolean)
+    )]
+    return normalized.length > 0 ? normalized : undefined
+}
+
+export function resolveCodexModel(
+    models: readonly CodexModelSummary[],
+    model: ModelSelection
+): CodexModelSummary | null {
+    const normalizedModelId = (typeof model === 'string' ? model : model?.modelId)?.trim()
+    if (!normalizedModelId || normalizedModelId === 'auto') {
+        return models.find((model) => model.isDefault) ?? models[0] ?? null
+    }
+
+    return models.find((model) => model.id === normalizedModelId) ?? null
+}
+
+export function getCodexModelReasoningEfforts(
+    models: readonly CodexModelSummary[],
+    model: ModelSelection
+): string[] | undefined {
+    return normalizeEfforts(resolveCodexModel(models, model)?.supportedReasoningEfforts)
+}
+
+export function supportsCodexReasoningEffort(
+    models: readonly CodexModelSummary[],
+    model: ModelSelection,
+    effort: string | null | undefined
+): boolean | undefined {
+    const supportedEfforts = getCodexModelReasoningEfforts(models, model)
+    if (!supportedEfforts) return undefined
+    if (!effort) return true
+    return supportedEfforts.includes(effort.trim().toLowerCase())
+}
diff --git a/web/src/lib/codexStatusLabels.test.ts b/web/src/lib/codexStatusLabels.test.ts
new file mode 100644
index 0000000000..779f5a072a
--- /dev/null
+++ b/web/src/lib/codexStatusLabels.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from './codexStatusLabels'
+
+describe('codexStatusLabels', () => {
+    it('formats unset and default effort as reasoning default', () => {
+        expect(formatCodexReasoningLabel(null)).toBe('reasoning default')
+        expect(formatCodexReasoningLabel(undefined)).toBe('reasoning default')
+        expect(formatCodexReasoningLabel('default')).toBe('reasoning default')
+        expect(formatCodexReasoningLabel('  DEFAULT  ')).toBe('reasoning default')
+    })
+
+    it('formats selected efforts', () => {
+        expect(formatCodexReasoningLabel('xhigh')).toBe('reasoning xhigh')
+        expect(formatCodexReasoningLabel('Ultra')).toBe('reasoning ultra')
+    })
+
+    it('only shows the label for codex and opencode', () => {
+        expect(shouldShowCodexReasoningLabel('codex')).toBe(true)
+        expect(shouldShowCodexReasoningLabel('opencode')).toBe(true)
+        expect(shouldShowCodexReasoningLabel('claude')).toBe(false)
+        expect(shouldShowCodexReasoningLabel(null)).toBe(false)
+    })
+})
diff --git a/web/src/lib/codexStatusLabels.ts b/web/src/lib/codexStatusLabels.ts
new file mode 100644
index 0000000000..d4960c6b3b
--- /dev/null
+++ b/web/src/lib/codexStatusLabels.ts
@@ -0,0 +1,11 @@
+/** Labels shared by SessionHeader and composer StatusBar for Codex/OpenCode. */
+
+export function formatCodexReasoningLabel(effort?: string | null): string {
+    const normalized = effort?.trim().toLowerCase()
+    if (!normalized || normalized === 'default') return 'reasoning default'
+    return `reasoning ${normalized}`
+}
+
+export function shouldShowCodexReasoningLabel(agentFlavor: string | null | undefined): boolean {
+    return agentFlavor === 'codex' || agentFlavor === 'opencode'
+}
diff --git a/web/src/lib/file-markdown-preview.test.ts b/web/src/lib/file-markdown-preview.test.ts
new file mode 100644
index 0000000000..68d57a6dd5
--- /dev/null
+++ b/web/src/lib/file-markdown-preview.test.ts
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+    DEFAULT_MARKDOWN_PREVIEW_MODE,
+    MARKDOWN_PREVIEW_MODE_STORAGE_KEY,
+    getInitialMarkdownPreviewMode,
+    isMarkdownFile,
+    persistMarkdownPreviewMode,
+} from './file-markdown-preview'
+
+describe('file-markdown-preview helpers', () => {
+    beforeEach(() => {
+        window.localStorage.clear()
+    })
+
+    it('detects markdown file extensions', () => {
+        expect(isMarkdownFile('README.md')).toBe(true)
+        expect(isMarkdownFile('docs/guide/page.mdx')).toBe(true)
+        expect(isMarkdownFile('src/file.ts')).toBe(false)
+        expect(isMarkdownFile('noext')).toBe(false)
+    })
+
+    it('defaults to preview when storage is missing or invalid', () => {
+        expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
+        window.localStorage.setItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, 'nope')
+        expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
+    })
+
+    it('reads and persists a valid preview mode', () => {
+        persistMarkdownPreviewMode('source')
+        expect(getInitialMarkdownPreviewMode()).toBe('source')
+
+        persistMarkdownPreviewMode('preview')
+        expect(window.localStorage.getItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)).toBeNull()
+        expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
+    })
+})
diff --git a/web/src/lib/file-markdown-preview.ts b/web/src/lib/file-markdown-preview.ts
new file mode 100644
index 0000000000..a5d399208c
--- /dev/null
+++ b/web/src/lib/file-markdown-preview.ts
@@ -0,0 +1,69 @@
+export type MarkdownPreviewMode = 'source' | 'preview'
+
+export const MARKDOWN_PREVIEW_MODE_STORAGE_KEY = 'hapi.filePreview.markdownMode.v1'
+export const DEFAULT_MARKDOWN_PREVIEW_MODE: MarkdownPreviewMode = 'preview'
+
+function isBrowser(): boolean {
+    return typeof window !== 'undefined' && typeof document !== 'undefined'
+}
+
+function safeGetItem(key: string): string | null {
+    if (!isBrowser()) {
+        return null
+    }
+    try {
+        return localStorage.getItem(key)
+    } catch {
+        return null
+    }
+}
+
+function safeSetItem(key: string, value: string): void {
+    if (!isBrowser()) {
+        return
+    }
+    try {
+        localStorage.setItem(key, value)
+    } catch {
+        // Ignore storage errors
+    }
+}
+
+function safeRemoveItem(key: string): void {
+    if (!isBrowser()) {
+        return
+    }
+    try {
+        localStorage.removeItem(key)
+    } catch {
+        // Ignore storage errors
+    }
+}
+
+export function isMarkdownFile(path: string): boolean {
+    const parts = path.split('.')
+    if (parts.length <= 1) {
+        return false
+    }
+    const ext = parts[parts.length - 1]?.toLowerCase()
+    return ext === 'md' || ext === 'mdx'
+}
+
+function parseMarkdownPreviewMode(raw: string | null): MarkdownPreviewMode {
+    if (raw === 'source' || raw === 'preview') {
+        return raw
+    }
+    return DEFAULT_MARKDOWN_PREVIEW_MODE
+}
+
+export function getInitialMarkdownPreviewMode(): MarkdownPreviewMode {
+    return parseMarkdownPreviewMode(safeGetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY))
+}
+
+export function persistMarkdownPreviewMode(mode: MarkdownPreviewMode): void {
+    if (mode === DEFAULT_MARKDOWN_PREVIEW_MODE) {
+        safeRemoveItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)
+        return
+    }
+    safeSetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, mode)
+}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index dcaeeb20d3..6e24f10892 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -8,6 +8,9 @@ export default {
   'loading.files': 'Loading files…',
   'loading.messages': 'Loading messages…',
   'loading.machines': 'Loading machines…',
+  'message.copy': 'Copy',
+  'message.copied': 'Copied',
+  'message.info': 'Message details',
 
   // Login / Auth
   'login.title': 'HAPI',
@@ -196,6 +199,12 @@ export default {
   'session.export.toast.success.body': 'Downloaded {filename}',
   'session.export.toast.error.title': 'Export failed',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': 'Open diagram full screen',
+  'mermaid.viewerTitle': 'Diagram',
+  'mermaid.loading': 'Loading diagram…',
+  'mermaid.renderError': 'Could not render diagram.',
+
   // Common buttons
   'button.cancel': 'Cancel',
   'button.save': 'Save',
@@ -216,7 +225,7 @@ export default {
   'newSession.type.simple': 'Simple',
   'newSession.type.simple.desc': 'Use selected directory as-is',
   'newSession.type.worktree': 'Worktree',
-  'newSession.type.worktree.desc': 'Create a new worktree next to repo',
+  'newSession.type.worktree.desc': 'Create a new worktree next to repo (Cursor uses native ~/.cursor/worktrees)',
   'newSession.type.worktree.placeholder': 'feature-x (default 1228-xxxx)',
   'newSession.agent': 'Agent',
   'newSession.model': 'Model',
@@ -234,6 +243,8 @@ export default {
   'newSession.yolo': 'YOLO mode',
   'newSession.yolo.title': 'Bypass approvals and sandbox',
   'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.',
+  'newSession.grokAutoUnavailable': 'unavailable',
+  'newSession.grokAutoUnavailableDesc': 'Grok did not enable Auto permissions for this account or CLI build.',
   'newSession.create': 'Create',
   'newSession.creating': 'Creating…',
 
@@ -245,6 +256,27 @@ export default {
 
   // Machine
   'machine.unknown': 'Unknown platform',
+  'machine.os.windows': 'Windows',
+  'machine.os.linux': 'Linux',
+  'machine.os.macos': 'macOS',
+  'machine.os.unknown': 'Unknown OS',
+  'machine.header.sessionCount': '{n} sessions',
+  'machine.health.tooltip.title': 'Machine capacity',
+  'machine.health.status.healthy': 'Healthy — room for more agents',
+  'machine.health.status.elevated': 'Elevated — new agents may run slower',
+  'machine.health.status.high': 'High pressure — avoid spawning more here',
+  'machine.health.status.unknown': 'Metrics unavailable',
+  'machine.health.metric.cpu': 'CPU across all cores',
+  'machine.health.metric.cpuWithCount': 'CPU across all {n} cores',
+  'machine.health.metric.ram': 'RAM in use',
+  'machine.health.tooltip.load': 'Run queue (1 min): {value}',
+  'machine.health.tooltip.loadShort': 'Load (1m)',
+  'machine.health.tooltip.uptimeShort': 'Uptime',
+  'machine.health.uptimeCompact': 'up {value}',
+  'machine.health.tooltip.hint': 'Updated every ~20s from the runner on this machine.',
+  'machine.health.aria.cpu': 'CPU {n} percent',
+  'machine.health.aria.ram': 'RAM {n} percent',
+  'machine.health.aria.unknown': 'Machine health unavailable',
 
   // Chat
   'chat.placeholder': 'Type a message…',
@@ -315,6 +347,8 @@ export default {
   'file.page.download': 'Download file',
   'file.page.tab.diff': 'Diff',
   'file.page.tab.file': 'File',
+  'file.page.tab.source': 'Source',
+  'file.page.tab.preview': 'Preview',
   'file.page.missingPath': 'No file path provided.',
   'file.page.binary': 'This looks like a binary file. It cannot be displayed.',
   'file.page.imagePreviewAlt': 'Image preview for {name}',
@@ -376,6 +410,7 @@ export default {
   'tool.requestUserInput.textPlaceholder': 'Type your answer…',
   'tool.requestUserInput.noteLabel': 'Additional note (optional)',
   'tool.requestUserInput.notePlaceholder': 'Add a note…',
+  'tool.requestUserInput.popupBlocked': 'Could not open the sign-in page. Allow popups and try again.',
   'toolGroup.title': 'Tool activity',
   'toolGroup.primary.fileTargets': '{target} +{n}',
   'toolGroup.primary.commandTargets': '{target} +{n}',
@@ -510,10 +545,18 @@ export default {
   'language.chinese': '简体中文',
 
   // Settings page
+  'common.back': 'Back',
   'settings.title': 'Settings',
+  'settings.hub.description': 'Choose a category to adjust HAPI to your workflow.',
+  'settings.hub.voice.summary': 'Voice, language, and behavior',
+  'settings.general.title': 'General',
+  'settings.general.description': 'Language and general application preferences.',
   'settings.language.title': 'Language',
   'settings.language.label': 'Language',
   'settings.display.title': 'Display',
+  'settings.display.description': 'Appearance, typography, and session list preferences.',
+  'settings.display.typography': 'Typography',
+  'settings.display.sessions': 'Session list',
   'settings.display.appearance': 'Appearance',
   'settings.display.appearance.system': 'Follow System',
   'settings.display.appearance.dark': 'Dark',
@@ -523,6 +566,7 @@ export default {
   'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.',
   'settings.display.themeColors.reset': 'Reset',
   'settings.display.themeColors.resetAll': 'Reset all',
+  'settings.display.themeColors.expand': 'Customize',
   'settings.display.themeColors.key.background': 'Background',
   'settings.display.themeColors.key.surface': 'Cards & surfaces',
   'settings.display.themeColors.key.text': 'Text',
@@ -542,6 +586,10 @@ export default {
   'settings.display.sessionListStatus.detailed': 'Detailed',
   'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).',
   'settings.chat.title': 'Chat',
+  'settings.chat.description': 'Message input, tool cards, and conversation colors.',
+  'settings.chat.input': 'Input',
+  'settings.chat.tools': 'Tool cards',
+  'settings.chat.colors': 'Conversation colors',
   'settings.chat.enterBehavior': 'Enter Key',
   'settings.chat.enterBehavior.send': 'Send message',
   'settings.chat.enterBehavior.newline': 'Insert newline',
@@ -556,6 +604,8 @@ export default {
   'settings.chat.surfaceColor.softYellow': 'Soft yellow',
   'settings.chat.surfaceColor.custom': 'Custom color',
   'settings.voice.title': 'Voice Assistant',
+  'settings.voice.description': 'Voice connection, language, and everyday behavior.',
+  'settings.voice.voices.description': 'Choose the voice used by the selected backend.',
   'settings.voice.sounds.title': 'How It Sounds',
   'settings.voice.responds.title': 'How It Responds',
   'settings.voice.connection.title': 'Connection & provider',
@@ -636,11 +686,14 @@ export default {
   'settings.voice.tuning.selectBackend': 'Per-backend sliders appear when a voice backend is selected.',
   'settings.voice.preview.elevenlabsOnly': 'Voice preview is only available for ElevenLabs',
   'settings.voice.preview.unavailable': 'Preview unavailable without an ElevenLabs API key',
+  'settings.voice.preview.play': 'Preview voice',
+  'settings.voice.preview.stop': 'Stop preview',
   'settings.voice.staticCatalogHint': 'Open the list to see voice character notes. Audio preview is ElevenLabs only.',
   'settings.voice.session.label': 'Session behavior',
   'settings.voice.proactive': 'Start voice session with summary',
   'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.',
   'settings.about.title': 'About',
+  'settings.about.description': 'HAPI links and version information.',
   'settings.about.website': 'Website',
   'settings.about.appVersion': 'App Version',
   'settings.about.protocolVersion': 'Protocol Version',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index 7508af1039..0fe0ad31e4 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -8,6 +8,9 @@ export default {
   'loading.files': '加载文件…',
   'loading.messages': '加载消息…',
   'loading.machines': '加载机器…',
+  'message.copy': '复制',
+  'message.copied': '已复制',
+  'message.info': '消息详情',
 
   // Login / Auth
   'login.title': 'HAPI',
@@ -200,6 +203,12 @@ export default {
   'session.export.toast.success.body': '已下载 {filename}',
   'session.export.toast.error.title': '导出失败',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': '全屏查看图表',
+  'mermaid.viewerTitle': '图表',
+  'mermaid.loading': '正在加载图表…',
+  'mermaid.renderError': '无法渲染图表。',
+
   // Common buttons
   'button.cancel': '取消',
   'button.save': '保存',
@@ -220,7 +229,7 @@ export default {
   'newSession.type.simple': '简单',
   'newSession.type.simple.desc': '直接使用选定的目录',
   'newSession.type.worktree': '工作树',
-  'newSession.type.worktree.desc': '在仓库旁创建新工作树',
+  'newSession.type.worktree.desc': '在仓库旁创建新工作树(Cursor 使用原生 ~/.cursor/worktrees)',
   'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)',
   'newSession.agent': '代理',
   'newSession.model': '模型',
@@ -238,6 +247,8 @@ export default {
   'newSession.yolo': 'YOLO 模式',
   'newSession.yolo.title': '跳过审批和沙箱',
   'newSession.yolo.desc': '启动时使用危险的代理标志。',
+  'newSession.grokAutoUnavailable': '不可用',
+  'newSession.grokAutoUnavailableDesc': 'Grok 尚未为此账号或 CLI 版本启用自动权限模式。',
   'newSession.create': '创建',
   'newSession.creating': '创建中…',
 
@@ -249,6 +260,27 @@ export default {
 
   // Machine
   'machine.unknown': '未知平台',
+  'machine.os.windows': 'Windows',
+  'machine.os.linux': 'Linux',
+  'machine.os.macos': 'macOS',
+  'machine.os.unknown': '未知系统',
+  'machine.header.sessionCount': '{n} 个会话',
+  'machine.health.tooltip.title': '机器负载',
+  'machine.health.status.healthy': '健康 — 还可运行更多代理',
+  'machine.health.status.elevated': '偏高 — 新代理可能变慢',
+  'machine.health.status.high': '高压 — 避免在此继续启动',
+  'machine.health.status.unknown': '指标不可用',
+  'machine.health.metric.cpu': '全部核心的 CPU',
+  'machine.health.metric.cpuWithCount': '全部 {n} 个核心的 CPU',
+  'machine.health.metric.ram': '内存占用',
+  'machine.health.tooltip.load': '运行队列 (1 分钟): {value}',
+  'machine.health.tooltip.loadShort': '负载 (1 分钟)',
+  'machine.health.tooltip.uptimeShort': '运行时间',
+  'machine.health.uptimeCompact': '已运行 {value}',
+  'machine.health.tooltip.hint': '约每 20 秒由该机器上的 runner 更新。',
+  'machine.health.aria.cpu': 'CPU {n}%',
+  'machine.health.aria.ram': '内存 {n}%',
+  'machine.health.aria.unknown': '机器健康数据不可用',
 
   // Chat
   'chat.placeholder': '输入消息…',
@@ -319,6 +351,8 @@ export default {
   'file.page.download': '下载文件',
   'file.page.tab.diff': 'Diff',
   'file.page.tab.file': '文件',
+  'file.page.tab.source': '源码',
+  'file.page.tab.preview': '预览',
   'file.page.missingPath': '未提供文件路径。',
   'file.page.binary': '该文件看起来是二进制文件,无法显示。',
   'file.page.imagePreviewAlt': '{name} 图片预览',
@@ -380,6 +414,7 @@ export default {
   'tool.requestUserInput.textPlaceholder': '输入您的答案…',
   'tool.requestUserInput.noteLabel': '补充说明(可选)',
   'tool.requestUserInput.notePlaceholder': '添加备注…',
+  'tool.requestUserInput.popupBlocked': '无法打开登录页面。请允许弹出窗口后重试。',
   'toolGroup.title': '工具活动',
   'toolGroup.primary.fileTargets': '{target} 等 +{n}',
   'toolGroup.primary.commandTargets': '{target} 等 +{n}',
@@ -514,10 +549,18 @@ export default {
   'language.chinese': '简体中文',
 
   // Settings page
+  'common.back': '返回',
   'settings.title': '设置',
+  'settings.hub.description': '选择一个分类,按你的工作方式调整 HAPI。',
+  'settings.hub.voice.summary': '声音、语言和行为',
+  'settings.general.title': '通用',
+  'settings.general.description': '语言和通用应用偏好。',
   'settings.language.title': '语言',
   'settings.language.label': '语言',
   'settings.display.title': '显示',
+  'settings.display.description': '外观、字体和会话列表偏好。',
+  'settings.display.typography': '字体',
+  'settings.display.sessions': '会话列表',
   'settings.display.appearance': '外观',
   'settings.display.appearance.system': '跟随系统',
   'settings.display.appearance.dark': '深色',
@@ -527,6 +570,7 @@ export default {
   'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
   'settings.display.themeColors.reset': '重置',
   'settings.display.themeColors.resetAll': '全部重置',
+  'settings.display.themeColors.expand': '自定义',
   'settings.display.themeColors.key.background': '背景',
   'settings.display.themeColors.key.surface': '卡片与表面',
   'settings.display.themeColors.key.text': '文字',
@@ -546,6 +590,10 @@ export default {
   'settings.display.sessionListStatus.detailed': '详细',
   'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。',
   'settings.chat.title': '聊天',
+  'settings.chat.description': '消息输入、工具卡片和对话颜色。',
+  'settings.chat.input': '输入',
+  'settings.chat.tools': '工具卡片',
+  'settings.chat.colors': '对话颜色',
   'settings.chat.enterBehavior': '回车键行为',
   'settings.chat.enterBehavior.send': '发送消息',
   'settings.chat.enterBehavior.newline': '插入换行',
@@ -560,6 +608,8 @@ export default {
   'settings.chat.surfaceColor.softYellow': '柔和黄',
   'settings.chat.surfaceColor.custom': '自定义颜色',
   'settings.voice.title': '语音助手',
+  'settings.voice.description': '语音连接、语言和常用行为。',
+  'settings.voice.voices.description': '选择当前后端使用的声音。',
   'settings.voice.sounds.title': '声音效果',
   'settings.voice.responds.title': '对话风格',
   'settings.voice.connection.title': '连接与提供商',
@@ -640,11 +690,14 @@ export default {
   'settings.voice.tuning.selectBackend': '选择语音后端后显示对应滑块。',
   'settings.voice.preview.elevenlabsOnly': '仅 ElevenLabs 支持试听',
   'settings.voice.preview.unavailable': '未配置 ElevenLabs API 密钥时无法试听',
+  'settings.voice.preview.play': '预览声音',
+  'settings.voice.preview.stop': '停止预览',
   'settings.voice.staticCatalogHint': '展开列表可查看声音特点说明;仅 ElevenLabs 支持试听。',
   'settings.voice.session.label': '会话行为',
   'settings.voice.proactive': '以摘要开始语音会话',
   'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。',
   'settings.about.title': '关于',
+  'settings.about.description': 'HAPI 链接和版本信息。',
   'settings.about.website': '官方网站',
   'settings.about.appVersion': '应用版本',
   'settings.about.protocolVersion': '协议版本',
diff --git a/web/src/lib/machineHealth.test.ts b/web/src/lib/machineHealth.test.ts
new file mode 100644
index 0000000000..9770d61a2e
--- /dev/null
+++ b/web/src/lib/machineHealth.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from 'vitest'
+import {
+    formatMachineUptimeSeconds,
+    presentMachineHealth,
+    resolveMachineOsLabel,
+    shouldShowMachineHostSubtitle,
+    getCpuMetricTooltipLabel,
+} from './machineHealth'
+
+describe('presentMachineHealth', () => {
+    it('builds cpu and ram metrics for visual meters', () => {
+        const result = presentMachineHealth({
+            collectedAt: Date.now(),
+            load1m: 2.4,
+            cpuCount: 8,
+            cpuPercent: 72,
+            memoryPercent: 81
+        }, 'linux')
+
+        expect(result?.metrics).toEqual([
+            { id: 'cpu', shortLabel: 'CPU', percent: 72, tone: 'ok' },
+            { id: 'ram', shortLabel: 'RAM', percent: 81, tone: 'warn' }
+        ])
+        expect(result?.overallTone).toBe('warn')
+        expect(result?.status).toBe('elevated')
+        expect(result?.loadDetail).toBe('2.4/8')
+        expect(result?.cpuCount).toBe(8)
+    })
+
+    it('marks high pressure when ram is critical', () => {
+        const result = presentMachineHealth({
+            collectedAt: Date.now(),
+            cpuPercent: 42,
+            memoryPercent: 93
+        }, 'linux')
+
+        expect(result?.overallTone).toBe('critical')
+        expect(result?.status).toBe('high')
+    })
+
+    it('returns null when health is missing', () => {
+        expect(presentMachineHealth(null, 'linux')).toBeNull()
+    })
+
+    it('formats uptime for tooltip and tile meta', () => {
+        const result = presentMachineHealth({
+            collectedAt: Date.now(),
+            cpuPercent: 10,
+            memoryPercent: 20,
+            uptimeSeconds: 6_540,
+        }, 'linux')
+
+        expect(result?.uptimeDetail).toBe('1h 49m')
+    })
+})
+
+describe('formatMachineUptimeSeconds', () => {
+    it('formats days, hours, minutes, and seconds', () => {
+        expect(formatMachineUptimeSeconds(90_000)).toBe('1d 1h')
+        expect(formatMachineUptimeSeconds(3_720)).toBe('1h 2m')
+        expect(formatMachineUptimeSeconds(300)).toBe('5m')
+        expect(formatMachineUptimeSeconds(45)).toBe('45s')
+    })
+})
+
+describe('resolveMachineOsLabel', () => {
+    it('maps known platforms to i18n keys', () => {
+        expect(resolveMachineOsLabel('win32')).toEqual({ kind: 'i18n', key: 'machine.os.windows' })
+        expect(resolveMachineOsLabel('linux')).toEqual({ kind: 'i18n', key: 'machine.os.linux' })
+        expect(resolveMachineOsLabel('darwin')).toEqual({ kind: 'i18n', key: 'machine.os.macos' })
+    })
+
+    it('falls back to raw platform string when unknown', () => {
+        expect(resolveMachineOsLabel('freebsd')).toEqual({ kind: 'raw', value: 'freebsd' })
+    })
+})
+
+describe('getCpuMetricTooltipLabel', () => {
+    const t = (key: string, params?: Record) => {
+        if (key === 'machine.health.metric.cpuWithCount') {
+            return `CPU across all ${params?.n} cores`
+        }
+        return 'CPU across all cores'
+    }
+
+    it('includes core count when known', () => {
+        expect(getCpuMetricTooltipLabel(6, t)).toBe('CPU across all 6 cores')
+    })
+
+    it('falls back when core count is missing', () => {
+        expect(getCpuMetricTooltipLabel(undefined, t)).toBe('CPU across all cores')
+    })
+})
+describe('shouldShowMachineHostSubtitle', () => {
+    it('hides host when it matches the display label', () => {
+        expect(shouldShowMachineHostSubtitle('Teemo', 'Teemo')).toBe(false)
+    })
+
+    it('shows host when it differs from the display label', () => {
+        expect(shouldShowMachineHostSubtitle('f9bb3c9e', 'proxmox')).toBe(true)
+    })
+})
diff --git a/web/src/lib/machineHealth.ts b/web/src/lib/machineHealth.ts
new file mode 100644
index 0000000000..24b074a7bf
--- /dev/null
+++ b/web/src/lib/machineHealth.ts
@@ -0,0 +1,198 @@
+import type { Machine, MachineHealth } from '@/types/api'
+
+export type MachineHealthTone = 'ok' | 'warn' | 'critical' | 'unknown'
+
+export type MachineHealthMetricPresentation = {
+    id: 'cpu' | 'ram'
+    shortLabel: 'CPU' | 'RAM'
+    percent: number
+    tone: MachineHealthTone
+}
+
+export type MachineHealthPresentation = {
+    metrics: MachineHealthMetricPresentation[]
+    overallTone: MachineHealthTone
+    loadDetail?: string
+    uptimeDetail?: string
+    cpuCount?: number
+    status: 'healthy' | 'elevated' | 'high' | 'unknown'
+}
+
+/** Compact uptime for sidebar tiles, e.g. 1h 54m, 2d 4h, 5m. */
+export function formatMachineUptimeSeconds(totalSeconds: number): string | null {
+    if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
+        return null
+    }
+
+    const seconds = Math.floor(totalSeconds)
+    const days = Math.floor(seconds / 86_400)
+    const hours = Math.floor((seconds % 86_400) / 3_600)
+    const minutes = Math.floor((seconds % 3_600) / 60)
+
+    if (days > 0) {
+        return `${days}d ${hours}h`
+    }
+    if (hours > 0) {
+        return `${hours}h ${minutes}m`
+    }
+    if (minutes > 0) {
+        return `${minutes}m`
+    }
+    return `${seconds}s`
+}
+
+function formatLoad(load1m: number, cpuCount?: number): string {
+    if (cpuCount && cpuCount > 0) {
+        return `${load1m.toFixed(1)}/${cpuCount}`
+    }
+    return load1m.toFixed(1)
+}
+
+function loadTone(load1m: number, cpuCount?: number): MachineHealthTone {
+    const cores = cpuCount && cpuCount > 0 ? cpuCount : 1
+    const ratio = load1m / cores
+    if (ratio >= 1.5) return 'critical'
+    if (ratio >= 1) return 'warn'
+    return 'ok'
+}
+
+function percentTone(value: number): MachineHealthTone {
+    if (value >= 90) return 'critical'
+    if (value >= 75) return 'warn'
+    return 'ok'
+}
+
+function worstTone(...tones: MachineHealthTone[]): MachineHealthTone {
+    if (tones.includes('critical')) return 'critical'
+    if (tones.includes('warn')) return 'warn'
+    if (tones.includes('unknown')) return 'unknown'
+    return 'ok'
+}
+
+function statusFromTone(tone: MachineHealthTone): MachineHealthPresentation['status'] {
+    if (tone === 'critical') return 'high'
+    if (tone === 'warn') return 'elevated'
+    if (tone === 'ok') return 'healthy'
+    return 'unknown'
+}
+
+export function presentMachineHealth(
+    health: MachineHealth | null | undefined,
+    platform?: string | null
+): MachineHealthPresentation | null {
+    if (!health) {
+        return null
+    }
+
+    const metrics: MachineHealthMetricPresentation[] = []
+    const tones: MachineHealthTone[] = []
+
+    if (health.cpuPercent !== undefined) {
+        const tone = percentTone(health.cpuPercent)
+        metrics.push({
+            id: 'cpu',
+            shortLabel: 'CPU',
+            percent: health.cpuPercent,
+            tone
+        })
+        tones.push(tone)
+    }
+
+    if (health.memoryPercent !== undefined) {
+        const tone = percentTone(health.memoryPercent)
+        metrics.push({
+            id: 'ram',
+            shortLabel: 'RAM',
+            percent: health.memoryPercent,
+            tone
+        })
+        tones.push(tone)
+    }
+
+    const loadDetail = health.load1m !== undefined && platform !== 'win32'
+        ? formatLoad(health.load1m, health.cpuCount)
+        : undefined
+    const uptimeDetail = health.uptimeSeconds !== undefined
+        ? formatMachineUptimeSeconds(health.uptimeSeconds)
+        : undefined
+
+    if (loadDetail !== undefined) {
+        tones.push(loadTone(health.load1m!, health.cpuCount))
+    }
+
+    if (metrics.length === 0 && loadDetail === undefined && uptimeDetail === undefined) {
+        return {
+            metrics: [],
+            overallTone: 'unknown',
+            status: 'unknown'
+        }
+    }
+
+    const overallTone = metrics.length > 0 ? worstTone(...tones) : loadTone(health.load1m!, health.cpuCount)
+
+    return {
+        metrics,
+        overallTone,
+        loadDetail,
+        uptimeDetail: uptimeDetail ?? undefined,
+        cpuCount: health.cpuCount,
+        status: statusFromTone(overallTone)
+    }
+}
+
+export function getCpuMetricTooltipLabel(
+    cpuCount: number | undefined,
+    t: (key: string, params?: Record) => string
+): string {
+    if (cpuCount !== undefined && cpuCount > 0) {
+        return t('machine.health.metric.cpuWithCount', { n: cpuCount })
+    }
+    return t('machine.health.metric.cpu')
+}
+
+export function getMachinePlatform(machine: Machine | null | undefined): string | null {
+    return machine?.metadata?.platform ?? null
+}
+
+export function getMachineHost(machine: Machine | null | undefined): string | null {
+    return machine?.metadata?.host ?? null
+}
+
+export type MachineOsLabel =
+    | { kind: 'i18n'; key: 'machine.os.windows' | 'machine.os.linux' | 'machine.os.macos' | 'machine.os.unknown' }
+    | { kind: 'raw'; value: string }
+
+export function resolveMachineOsLabel(platform: string | null | undefined): MachineOsLabel {
+    switch (platform) {
+        case 'win32':
+            return { kind: 'i18n', key: 'machine.os.windows' }
+        case 'linux':
+            return { kind: 'i18n', key: 'machine.os.linux' }
+        case 'darwin':
+            return { kind: 'i18n', key: 'machine.os.macos' }
+        default:
+            if (platform?.trim()) {
+                return { kind: 'raw', value: platform.trim() }
+            }
+            return { kind: 'i18n', key: 'machine.os.unknown' }
+    }
+}
+
+export function shouldShowMachineHostSubtitle(label: string, host: string | null | undefined): boolean {
+    if (!host?.trim()) return false
+    return host.trim().toLowerCase() !== label.trim().toLowerCase()
+}
+
+export const MACHINE_HEALTH_BAR_FILL_CLASS: Record = {
+    ok: 'bg-[var(--app-link)]/70',
+    warn: 'bg-[var(--app-badge-warning-text)]',
+    critical: 'bg-[var(--app-badge-error-text)]',
+    unknown: 'bg-[var(--app-hint)]/50'
+}
+
+export const MACHINE_HEALTH_CHIP_CLASS: Record = {
+    ok: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/80',
+    warn: 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)]/40',
+    critical: 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)]/40',
+    unknown: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/60 opacity-70'
+}
diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts
index e7adcfb309..fe9dee3dfa 100644
--- a/web/src/lib/query-keys.ts
+++ b/web/src/lib/query-keys.ts
@@ -22,5 +22,8 @@ export const queryKeys = {
     sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const,
     sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const,
     machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const,
+    machineGrokModelsForCwd: (machineId: string, cwd: string) => ['machine-grok-models', machineId, cwd] as const,
+    sessionGrokModels: (sessionId: string) => ['session-grok-models', sessionId] as const,
+    sessionGrokReasoningEffortOptions: (sessionId: string) => ['session-grok-reasoning-effort-options', sessionId] as const,
     skills: (sessionId: string) => ['skills', sessionId] as const,
 }
diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts
index 5cb9fd587c..05540cc8e1 100644
--- a/web/src/lib/sessionResume.ts
+++ b/web/src/lib/sessionResume.ts
@@ -16,6 +16,7 @@ export function resolveAgentSessionIdFromMetadata(
         case 'codex': return metadata.codexSessionId ?? undefined
         case 'gemini': return metadata.geminiSessionId ?? undefined
         case 'opencode': return metadata.opencodeSessionId ?? undefined
+        case 'grok': return metadata.grokSessionId ?? undefined
         case 'cursor': return metadata.cursorSessionId ?? undefined
         case 'kimi': return metadata.kimiSessionId ?? undefined
         case 'pi': return metadata.piSessionId ?? undefined
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 84222fef70..067c806f9c 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -46,7 +46,15 @@ import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } fr
 import FilesPage from '@/routes/sessions/files'
 import FilePage from '@/routes/sessions/file'
 import TerminalPage from '@/routes/sessions/terminal'
-import SettingsPage from '@/routes/settings'
+import SettingsLayout from '@/routes/settings/layout'
+import SettingsHubPage from '@/routes/settings'
+import SettingsGeneralPage from '@/routes/settings/general'
+import SettingsDisplayPage from '@/routes/settings/display'
+import SettingsChatPage from '@/routes/settings/chat'
+import SettingsVoicePage from '@/routes/settings/voice'
+import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices'
+import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced'
+import SettingsAboutPage from '@/routes/settings/about'
 import SharePage from '@/routes/share'
 import { setSharePendingTransfer } from '@/lib/sharePendingState'
 import { deleteShareTransfer } from '@/lib/shareTransfer'
@@ -190,6 +198,13 @@ function SessionsPage() {
         }
         return labels
     }, [machines])
+    const machinesById = useMemo(() => {
+        const byId: Record = {}
+        for (const machine of machines) {
+            byId[machine.id] = machine
+        }
+        return byId
+    }, [machines])
     const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
     const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null
     const selectedSession = useMemo(
@@ -531,6 +546,7 @@ function SessionsPage() {
                         renderHeader={false}
                         api={api}
                         machineLabelsById={machineLabelsById}
+                        machinesById={machinesById}
                     />
                 
@@ -1218,7 +1234,55 @@ const browseRoute = createRoute({ const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', - component: SettingsPage, + component: SettingsLayout, +}) + +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: SettingsHubPage, +}) + +const settingsGeneralRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'general', + component: SettingsGeneralPage, +}) + +const settingsDisplayRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'display', + component: SettingsDisplayPage, +}) + +const settingsChatRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'chat', + component: SettingsChatPage, +}) + +const settingsVoiceRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice', + component: SettingsVoicePage, +}) + +const settingsVoiceVoicesRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice/voices', + component: SettingsVoiceVoicesPage, +}) + +const settingsVoiceAdvancedRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice/advanced', + component: SettingsVoiceAdvancedPage, +}) + +const settingsAboutRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'about', + component: SettingsAboutPage, }) // Web Share Target landing route. Service worker (`web/src/sw.ts`) @@ -1252,7 +1316,16 @@ export const routeTree = rootRoute.addChildren([ ]), ]), browseRoute, - settingsRoute, + settingsRoute.addChildren([ + settingsIndexRoute, + settingsGeneralRoute, + settingsDisplayRoute, + settingsChatRoute, + settingsVoiceRoute, + settingsVoiceVoicesRoute, + settingsVoiceAdvancedRoute, + settingsAboutRoute, + ]), shareRoute, ]) diff --git a/web/src/routes/sessions/file.test.tsx b/web/src/routes/sessions/file.test.tsx new file mode 100644 index 0000000000..a653e37dee --- /dev/null +++ b/web/src/routes/sessions/file.test.tsx @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { I18nProvider } from '@/lib/i18n-context' +import { encodeBase64 } from '@/lib/utils' +import FilePage from './file' + +const goBackMock = vi.fn() + +const sampleMarkdown = '# Heading\n\n| Col A | Col B |\n| --- | --- |\n| one | two |' +const filePath = 'docs/README.md' +const encodedPath = encodeBase64(filePath) +const encodedContent = encodeBase64(sampleMarkdown) + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ sessionId: 'session-1' }), + useSearch: () => ({ + path: encodedPath, + staged: undefined, + }), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ + api: { + getGitDiffFile: vi.fn(async () => ({ success: true, stdout: '' })), + readSessionFile: vi.fn(async () => ({ + success: true, + content: encodedContent, + })), + }, + }), +})) + +vi.mock('@/hooks/useAppGoBack', () => ({ + useAppGoBack: () => goBackMock, +})) + +vi.mock('@/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ + copied: false, + copy: vi.fn(), + }), +})) + +vi.mock('@/lib/shiki', () => ({ + langAlias: { md: 'markdown' }, + useShikiHighlighter: (content: string) => content, +})) + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: (props: { content: string }) => ( +
{props.content}
+ ), +})) + +function renderWithProviders() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + return render( + + + + + + ) +} + +describe('FilePage markdown preview', () => { + beforeEach(() => { + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('renders markdown preview by default and toggles to source', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Heading') + }) + expect(screen.getByRole('button', { name: 'Preview' })).toHaveClass('opacity-80') + + fireEvent.click(screen.getByRole('button', { name: 'Source' })) + + await waitFor(() => { + expect(screen.getByRole('code')).toHaveTextContent('# Heading') + }) + expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index ab2b89d961..b9df79e515 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -13,6 +13,13 @@ import { langAlias, useShikiHighlighter } from '@/lib/shiki' import { useTranslation } from '@/lib/use-translation' import { decodeBase64 } from '@/lib/utils' import { ImagePreview } from '@/components/ImagePreview' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { + getInitialMarkdownPreviewMode, + isMarkdownFile, + persistMarkdownPreviewMode, + type MarkdownPreviewMode, +} from '@/lib/file-markdown-preview' const MAX_COPYABLE_FILE_BYTES = 1_000_000 const IMAGE_MIME_BY_EXTENSION: Record = { @@ -193,6 +200,7 @@ export default function FilePage() { const filePath = useMemo(() => decodePath(encodedPath), [encodedPath]) const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName') const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath]) + const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath]) const diffQuery = useQuery({ queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged), @@ -234,7 +242,12 @@ export default function FilePage() { : null const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType]) - const highlighted = useShikiHighlighter(imageMimeType ? '' : decodedContent, language) + const [markdownMode, setMarkdownMode] = useState(getInitialMarkdownPreviewMode) + const showMarkdownSource = !markdownFile || markdownMode === 'source' + const highlighted = useShikiHighlighter( + imageMimeType || (markdownFile && !showMarkdownSource) ? '' : decodedContent, + language + ) const contentSizeBytes = useMemo( () => (decodedContent ? getUtf8ByteLength(decodedContent) : 0), [decodedContent] @@ -248,6 +261,11 @@ export default function FilePage() { const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') + const setMarkdownPreviewMode = (mode: MarkdownPreviewMode) => { + setMarkdownMode(mode) + persistMarkdownPreviewMode(mode) + } + useEffect(() => { if (imageMimeType) { setDisplayMode('file') @@ -313,23 +331,46 @@ export default function FilePage() {
- {diffContent ? ( + {diffContent || (markdownFile && displayMode === 'file') ? (
- - + {diffContent ? ( + <> + + + + ) : null} + {markdownFile && displayMode === 'file' ? ( + <> + {diffContent ?
) : null} @@ -364,21 +405,37 @@ export default function FilePage() {
) : ( decodedContent ? ( -
- {canCopyContent ? ( - - ) : null} -
-                                        {highlighted ?? decodedContent}
-                                    
-
+ markdownFile && !showMarkdownSource ? ( +
+ {canCopyContent ? ( + + ) : null} + +
+ ) : ( +
+ {canCopyContent ? ( + + ) : null} +
+                                            {highlighted ?? decodedContent}
+                                        
+
+ ) ) : (
{t('file.page.empty')}
) diff --git a/web/src/routes/settings/about.tsx b/web/src/routes/settings/about.tsx new file mode 100644 index 0000000000..680ebbe7ef --- /dev/null +++ b/web/src/routes/settings/about.tsx @@ -0,0 +1,18 @@ +import { PROTOCOL_VERSION } from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives' + +export default function SettingsAboutPage() { + const { t } = useTranslation() + return ( + + + hapi.run + } /> + {__APP_VERSION__}} /> + {PROTOCOL_VERSION}} /> + + + ) +} diff --git a/web/src/routes/settings/categories.ts b/web/src/routes/settings/categories.ts new file mode 100644 index 0000000000..1eb1aec624 --- /dev/null +++ b/web/src/routes/settings/categories.ts @@ -0,0 +1,13 @@ +export const settingsCategories = [ + { id: 'general', path: '/settings/general', titleKey: 'settings.general.title' }, + { id: 'display', path: '/settings/display', titleKey: 'settings.display.title' }, + { id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' }, + { id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' }, + { id: 'about', path: '/settings/about', titleKey: 'settings.about.title' }, +] as const + +export type SettingsCategory = typeof settingsCategories[number] + +export function getSettingsCategory(pathname: string): SettingsCategory | undefined { + return settingsCategories.find((category) => pathname === category.path || pathname.startsWith(`${category.path}/`)) +} diff --git a/web/src/routes/settings/chat.tsx b/web/src/routes/settings/chat.tsx new file mode 100644 index 0000000000..6f44beea4b --- /dev/null +++ b/web/src/routes/settings/chat.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from '@/lib/use-translation' +import { getComposerEnterBehaviorOptions, useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' +import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' +import { + getChatSurfaceColorPickerValue, + getChatSurfaceColorPresetOptions, + toCustomChatSurfaceColorPreference, + toPresetChatSurfaceColorPreference, + useChatSurfaceColors, + type ChatSurfaceColorPreference, + type ChatSurfaceColorPreset, +} from '@/hooks/useChatSurfaceColors' +import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' + +function ChatSurfaceColorControl(props: { + label: string + preference: ChatSurfaceColorPreference + onPresetChange: (preset: ChatSurfaceColorPreset) => void + onCustomChange: (value: string) => void +}) { + const { t } = useTranslation() + const pickerValue = getChatSurfaceColorPickerValue(props.preference) + return ( +
+ {props.label} +
+ {getChatSurfaceColorPresetOptions().map((option) => { + const preference = toPresetChatSurfaceColorPreference(option.value) + const selected = props.preference === preference + return ( + + ) + })} +
+ +
+ ) +} + +export default function SettingsChatPage() { + const { t } = useTranslation() + const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() + const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() + const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors() + return ( + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setComposerEnterBehavior} + /> + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setTerminalToolDisplayMode} + /> + + + setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} /> + setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} /> + + + ) +} diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx new file mode 100644 index 0000000000..ffc5c42702 --- /dev/null +++ b/web/src/routes/settings/display.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from '@/lib/use-translation' +import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme' +import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale' +import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize' +import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' +import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' +import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' +import { SettingsChoiceGroup, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' + +function MinusIcon() { + return +} + +function PlusIcon() { + return +} + +function SessionPreviewLimitControl() { + const { t } = useTranslation() + const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit() + const [draft, setDraft] = useState(String(sessionPreviewLimit)) + + useEffect(() => setDraft(String(sessionPreviewLimit)), [sessionPreviewLimit]) + + const commit = () => { + const parsed = draft.trim() === '' ? sessionPreviewLimit : Number(draft) + const next = normalizeSessionPreviewLimit(parsed) + setSessionPreviewLimit(next) + setDraft(String(next)) + } + const step = (delta: number) => setSessionPreviewLimit(normalizeSessionPreviewLimit(sessionPreviewLimit + delta)) + + return ( + + + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === 'Enter') { commit(); event.currentTarget.blur() } + if (event.key === 'Escape') { setDraft(String(sessionPreviewLimit)); event.currentTarget.blur() } + }} + className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm text-[var(--app-fg)] outline-none" + /> + +
+ } /> + ) +} + +function ThemeColorControls() { + const { t } = useTranslation() + const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors() + return ( +
+ + + {t('settings.display.themeColors.title')} + {t('settings.display.themeColors.description')} + + {hasAnyCustom ? t('settings.voice.advanced.customizedBadge') : t('settings.display.themeColors.expand')} + +
+ {hasAnyCustom ?
: null} +
+ {keys.map((key) => ( +
+ {t(key.labelKey)} + + {isCustomized(key.id) ? : null} + setColor(key.id as ThemeColorKeyId, event.target.value)} className="h-8 w-10 cursor-pointer border-0 bg-transparent p-0" /> + +
+ ))} +
+
+
+ ) +} + +export default function SettingsDisplayPage() { + const { t } = useTranslation() + const { appearance, setAppearance } = useAppearance() + const { fontScale, setFontScale } = useFontScale() + const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() + const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() + const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() + + return ( + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setAppearance} + /> + + + + + + + + + + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setSessionListStatusMode} + /> + {sessionListStatusMode === 'detailed' ?
{t('settings.display.sessionListStatus.detailedDescription')}
: null} +
+
+ ) +} diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx new file mode 100644 index 0000000000..7197fab9ea --- /dev/null +++ b/web/src/routes/settings/general.tsx @@ -0,0 +1,18 @@ +import { useTranslation, type Locale } from '@/lib/use-translation' +import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' + +const locales: ReadonlyArray<{ value: Locale; label: string }> = [ + { value: 'en', label: 'English' }, + { value: 'zh-CN', label: '简体中文' }, +] + +export default function SettingsGeneralPage() { + const { t, locale, setLocale } = useTranslation() + return ( + + + + + + ) +} diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index cd437768b2..e1698b676a 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,42 +1,89 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react' -import { I18nContext, I18nProvider } from '@/lib/i18n-context' -import { en } from '@/lib/locales' -import { PROTOCOL_VERSION } from '@hapi/protocol' -import SettingsPage from './index' - -vi.mock('@hapi/protocol', () => ({ - PROTOCOL_VERSION: 1, +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import SettingsHubPage from './index' +import SettingsGeneralPage from './general' +import SettingsDisplayPage from './display' +import SettingsChatPage from './chat' +import SettingsAboutPage from './about' +import SettingsVoicePage from './voice' +import SettingsVoiceVoicesPage from './voice-voices' +import SettingsVoiceAdvancedPage from './voice-advanced' + +const { navigate, setAppearance, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({ + navigate: vi.fn(), + setAppearance: vi.fn(), + setFontScale: vi.fn(), + setTerminalFontSize: vi.fn(), + setComposerEnterBehavior: vi.fn(), + setVoice: vi.fn(), })) -// Mock the router hooks vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), - useRouter: () => ({ history: { back: vi.fn() } }), - useLocation: () => '/settings', + useNavigate: () => navigate, +})) + +vi.mock('@hapi/protocol', () => ({ PROTOCOL_VERSION: 1 })) + +vi.mock('@/hooks/useTheme', () => ({ + useAppearance: () => ({ appearance: 'system', setAppearance }), + getAppearanceOptions: () => [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'oled', labelKey: 'settings.display.appearance.oled' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ], })) -// Mock useFontScale hook vi.mock('@/hooks/useFontScale', () => ({ - useFontScale: () => ({ fontScale: 1, setFontScale: vi.fn() }), + useFontScale: () => ({ fontScale: 1, setFontScale }), getFontScaleOptions: () => [ - { value: 0.875, label: '87.5%' }, - { value: 1, label: '100%' }, - { value: 1.125, label: '112.5%' }, + { value: 0.8, label: '80%' }, { value: 0.9, label: '90%' }, { value: 1, label: '100%' }, + { value: 1.1, label: '110%' }, { value: 1.2, label: '120%' }, ], })) vi.mock('@/hooks/useTerminalFontSize', () => ({ - useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize: vi.fn() }), + useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize }), getTerminalFontSizeOptions: () => [ - { value: 9, label: '9px' }, - { value: 13, label: '13px' }, - { value: 17, label: '17px' }, + { value: 9, label: '9px' }, { value: 11, label: '11px' }, { value: 13, label: '13px' }, + { value: 15, label: '15px' }, { value: 17, label: '17px' }, + ], +})) + +vi.mock('@/hooks/useSessionListStatusMode', () => ({ + useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }), + getSessionListStatusModeOptions: () => [ + { value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' }, + { value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' }, ], })) +vi.mock('@/hooks/useShowActiveSessionsOnly', () => ({ + useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }), +})) + +vi.mock('@/hooks/useSessionPreviewLimit', () => ({ + MIN_SESSION_PREVIEW_LIMIT: 1, + MAX_SESSION_PREVIEW_LIMIT: 99, + normalizeSessionPreviewLimit: (value: number) => Math.max(1, Math.min(99, Math.round(value))), + useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }), +})) + +vi.mock('@/hooks/useThemeColors', () => ({ + useThemeColors: () => ({ + keys: [], + getPickerValue: vi.fn(), + isCustomized: vi.fn(() => false), + hasAnyCustom: false, + setColor: vi.fn(), + resetColor: vi.fn(), + resetAll: vi.fn(), + }), +})) + vi.mock('@/hooks/useComposerEnterBehavior', () => ({ - useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior: vi.fn() }), + useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior }), getComposerEnterBehaviorOptions: () => [ { value: 'send', labelKey: 'settings.chat.enterBehavior.send' }, { value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' }, @@ -51,21 +98,6 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({ ], })) -vi.mock('@/hooks/useSessionListStatusMode', () => ({ - useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }), - getSessionListStatusModeOptions: () => [ - { value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' }, - { value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' }, - ], -})) - -vi.mock('@/hooks/useSessionPreviewLimit', () => ({ - MIN_SESSION_PREVIEW_LIMIT: 1, - MAX_SESSION_PREVIEW_LIMIT: 99, - normalizeSessionPreviewLimit: (value: number) => Number.isInteger(value) ? Math.min(99, Math.max(1, value)) : 8, - useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }), -})) - vi.mock('@/hooks/useChatSurfaceColors', () => ({ useChatSurfaceColors: () => ({ toolGroupBackground: 'default', @@ -76,440 +108,108 @@ vi.mock('@/hooks/useChatSurfaceColors', () => ({ getChatSurfaceColorPresetOptions: () => [ { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, - { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, - { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, ], getChatSurfaceColorPickerValue: () => '#7db7ff', toPresetChatSurfaceColorPreference: (value: string) => value === 'default' ? 'default' : `preset:${value}`, toCustomChatSurfaceColorPreference: (value: string) => `custom:${value}`, })) -// Mock useTheme hook -vi.mock('@/hooks/useTheme', () => ({ - useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), - getAppearanceOptions: () => [ - { value: 'system', labelKey: 'settings.display.appearance.system' }, - { value: 'dark', labelKey: 'settings.display.appearance.dark' }, - { value: 'light', labelKey: 'settings.display.appearance.light' }, - ], +vi.mock('@/components/settings/VoiceAdvancedControls', () => ({ + VoiceRespondsControls: () =>
Response length controls
, + VoiceSoundsControls: () =>
Sound controls
, + VoicePersonaControls: () =>
Persona controls
, + VoiceDiagnosticsControls: () =>
Diagnostics controls
, })) -// Mock languages -vi.mock('@/lib/languages', () => ({ - getElevenLabsSupportedLanguages: () => [ - { code: null, name: 'Auto-detect' }, - { code: 'en', name: 'English' }, - ], - getLanguageDisplayName: (lang: { code: string | null; name: string }) => lang.name, -})) - -// Use vi.hoisted so these mocks are available when vi.mock factories run -const { mockFetchVoices, mockFetchVoiceBackend, mockApi } = vi.hoisted(() => { - const mockFetchVoices = vi.fn(() => Promise.resolve([])) - const mockFetchVoiceBackend = vi.fn(() => Promise.resolve({ - backend: 'elevenlabs' as 'elevenlabs' | 'gemini-live' | 'qwen-realtime', - backends: ['elevenlabs'] as Array<'elevenlabs' | 'gemini-live' | 'qwen-realtime'> - })) - const mockApi = { - fetchVoices: vi.fn(() => Promise.resolve({ voices: [] })), - } - return { mockFetchVoices, mockFetchVoiceBackend, mockApi } -}) - -// Mock static voices list -vi.mock('@/lib/voices', () => ({ - VOICES: [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }], - DEFAULT_VOICE_ID: 'voice1', - getVoiceById: (id: string | null) => - id === 'voice1' ? { id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' } : undefined, - getFallbackVoices: () => [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }], -})) - -// Mock fetchVoices to return a resolved list by default -vi.mock('@/api/voice', () => ({ - fetchVoices: mockFetchVoices, - fetchVoiceBackend: mockFetchVoiceBackend, - fetchVoiceToken: vi.fn(() => Promise.resolve({ allowed: true, token: 'tok' })), -})) - -// Mock useAppContext so the page doesn't throw "AppContext is not available" -vi.mock('@/lib/app-context', () => ({ - useAppContext: () => ({ api: mockApi, token: 'test', baseUrl: '' }), - AppContextProvider: ({ children }: { children: React.ReactNode }) => children, +vi.mock('./useVoiceSettings', () => ({ + useVoiceSettings: () => ({ + configuredBackends: ['elevenlabs'], + backend: 'elevenlabs', + setBackend: vi.fn(), + voiceId: null, + setVoice, + voices: [ + { id: 'voice-1', name: 'Jessica', description: 'Warm', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ], + voiceLanguage: null, + setVoiceLanguage: vi.fn(), + voiceLanguages: [{ code: null, name: 'Auto-detect' }, { code: 'en', name: 'English' }], + playingVoiceId: null, + previewVoice: vi.fn(), + }), })) - -afterEach(() => { - cleanup() -}) -function renderWithProviders(ui: React.ReactElement) { - return render( - - {ui} - - ) -} - -function renderWithSpyT(ui: React.ReactElement) { - const translations = en as Record - const spyT = vi.fn((key: string) => translations[key] ?? key) - render( - - {ui} - - ) - return spyT +function renderPage(page: React.ReactElement) { + return render({page}) } -describe('SettingsPage', () => { +describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() - mockFetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) - // Reset fetchVoices mock to return empty list by default - mockFetchVoices.mockResolvedValue([]) - // Mock localStorage - const localStorageMock = { - getItem: vi.fn(() => null), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(() => null), - length: 0, - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) - }) - - it('renders the About section', () => { - renderWithProviders() - expect(screen.getByText('About')).toBeInTheDocument() - }) - - it('displays the App Version with correct value', () => { - renderWithProviders() - expect(screen.getAllByText('App Version').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText(__APP_VERSION__).length).toBeGreaterThanOrEqual(1) + localStorage.clear() }) - it('displays the Protocol Version with correct value', () => { - renderWithProviders() - expect(screen.getAllByText('Protocol Version').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText(String(PROTOCOL_VERSION)).length).toBeGreaterThanOrEqual(1) + it('renders the mobile hub categories with current summaries', () => { + renderPage() + expect(screen.getByText('General')).toBeInTheDocument() + expect(screen.getAllByText('Display').length).toBeGreaterThan(0) + expect(screen.getByText('Voice, language, and behavior')).toBeInTheDocument() + expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument() }) - it('displays the website link with correct URL and security attributes', () => { - renderWithProviders() - expect(screen.getAllByText('Website').length).toBeGreaterThanOrEqual(1) - const links = screen.getAllByRole('link', { name: 'hapi.run' }) - expect(links.length).toBeGreaterThanOrEqual(1) - const link = links[0] - expect(link).toHaveAttribute('href', 'https://hapi.run') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + it('navigates from the hub to a category route', () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: /General/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/general' }) }) - it('uses correct i18n keys for About section', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.about.title') - expect(calledKeys).toContain('settings.about.website') - expect(calledKeys).toContain('settings.about.appVersion') - expect(calledKeys).toContain('settings.about.protocolVersion') + it('changes the application language inline', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: '简体中文' })) + expect(localStorage.getItem('hapi-lang')).toBe('zh-CN') }) - it('renders the Appearance setting', () => { - renderWithProviders() - expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1) + it('renders compact display controls without dropdown popovers', () => { + renderPage() + expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument() + expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() }) - it('uses correct i18n keys for Appearance setting', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.display.appearance') - expect(calledKeys).toContain('settings.display.appearance.system') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase') - expect(calledKeys).toContain('settings.display.sessionListStatus') - expect(calledKeys).toContain('settings.display.sessionListStatus.standard') + it('keeps chat enum choices inline', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: 'Insert newline' })) + expect(setComposerEnterBehavior).toHaveBeenCalledWith('newline') + expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument() }) - it('renders the Terminal Font Size setting', () => { - renderWithProviders() - expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1) + it('renders About metadata on its own route page', () => { + renderPage() + expect(screen.getByText('App Version')).toBeInTheDocument() + expect(screen.getByText(String(__APP_VERSION__))).toBeInTheDocument() + expect(screen.getByText('Protocol Version')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'hapi.run' })).toHaveAttribute('rel', 'noopener noreferrer') }) - it('renders the Session Preview Limit setting', () => { - renderWithProviders() - expect(screen.getAllByText('Sessions Before Folding').length).toBeGreaterThanOrEqual(1) - expect(screen.getByLabelText('Sessions Before Folding')).toHaveValue(8) - expect(screen.getAllByLabelText('Show fewer sessions before folding').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByLabelText('Show more sessions before folding').length).toBeGreaterThanOrEqual(1) + it('links common voice settings to full-page voices and advanced pages', () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: /Voice/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/voices' }) + fireEvent.click(screen.getByRole('button', { name: /Advanced voice settings/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/advanced' }) }) - it('renders the Session list status setting', () => { - renderWithProviders() - expect(screen.getAllByText('Session list status').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(1) + it('selects a voice from the full-page picker', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: /Jessica/ })) + expect(setVoice).toHaveBeenCalledWith('voice-1') }) - it('renders the Enter Key setting', () => { - renderWithProviders() - expect(screen.getAllByText('Enter Key').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Send message').length).toBeGreaterThanOrEqual(1) - }) - - it('renders the Terminal Tool Display setting', () => { - renderWithProviders() - expect(screen.getAllByText('Terminal Tool Cards').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Compact (command only)').length).toBeGreaterThanOrEqual(1) - }) - - it('renders grouped tool and user message background settings', () => { - renderWithProviders() - expect(screen.getAllByText('Grouped Tool Use Background').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('User Message Background').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Default color').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft blue').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft green').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft yellow').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByLabelText('Custom color').length).toBeGreaterThanOrEqual(2) - }) - - it('uses correct i18n keys for the Enter Key setting', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.chat.title') - expect(calledKeys).toContain('settings.chat.enterBehavior') - expect(calledKeys).toContain('settings.chat.enterBehavior.send') - expect(calledKeys).toContain('settings.chat.terminalToolDisplay') - expect(calledKeys).toContain('settings.chat.terminalToolDisplay.compact') - expect(calledKeys).toContain('settings.chat.groupedToolBackground') - expect(calledKeys).toContain('settings.chat.userMessageBackground') - expect(calledKeys).toContain('settings.chat.surfaceColor.default') - }) - - // Voice picker tests - it('renders the Voice section with "Voice" label', () => { - renderWithProviders() - expect(screen.getAllByText('Voice').length).toBeGreaterThanOrEqual(1) - }) - - it('uses correct i18n keys for the voice picker', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.voice.voice') - expect(calledKeys).toContain('settings.voice.voiceDefault') - }) - - it('voice picker shows "Default" option when opened', () => { - renderWithProviders() - // The current value "Default" is shown in the closed picker button - expect(screen.getAllByText('Default').length).toBeGreaterThanOrEqual(1) - }) - - it('opens voice picker and shows "Default" option in the list', () => { - renderWithProviders() - // Click the voice picker button (aria-label target via the label text) - const voiceButtons = screen.getAllByRole('button', { name: /Default/i }) - // Find the button that has aria-haspopup — that's the voice picker trigger - const pickerButton = voiceButtons.find(btn => btn.getAttribute('aria-haspopup') === 'listbox') - expect(pickerButton).toBeTruthy() - fireEvent.click(pickerButton!) - // The listbox should appear with a "Default" option inside - const listbox = screen.getByRole('listbox', { name: 'Voice' }) - expect(listbox).toBeInTheDocument() - expect(listbox.textContent).toContain('Default') - }) - - it('shows dynamic voices in picker when fetchVoices returns a list', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - { id: 'dyn2', name: 'Bob', previewUrl: 'https://example.com/bob.mp3', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await waitFor(() => { - expect(screen.getByText('Alice')).toBeInTheDocument() - expect(screen.getByText('Bob')).toBeInTheDocument() - }) - }) - - - it('shows a disabled preview button with tooltip when previewUrl is missing', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const previewButton = await screen.findByLabelText('Preview voice') - expect(previewButton).toBeDisabled() - expect(previewButton).toHaveAttribute('title', 'Preview unavailable without an ElevenLabs API key') - }) - - it('shows a play button for voices with a previewUrl', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await screen.findByText('Alice') - expect(screen.getByLabelText('Preview voice')).toBeInTheDocument() - expect(screen.getByLabelText('Preview voice')).not.toBeDisabled() - }) - - it('stops preview audio on unmount', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' }, - ]) - - const pause = vi.fn() - const play = vi.fn(() => Promise.resolve()) - const addEventListener = vi.fn() - class MockAudio { - pause = pause - play = play - addEventListener = addEventListener - constructor(_url: string) {} - } - const OriginalAudio = globalThis.Audio - const OriginalWindowAudio = window.Audio - // @ts-expect-error test override - globalThis.Audio = MockAudio - // @ts-expect-error test override - window.Audio = MockAudio - - const view = renderWithProviders() - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - const aliceLabel = await screen.findByText('Alice') - const optionRow = aliceLabel.closest('[role="option"]') - expect(optionRow).toBeTruthy() - const enabledPreview = optionRow?.querySelector('button[aria-label="Preview voice"]') as HTMLButtonElement | null - expect(enabledPreview).toBeTruthy() - expect(enabledPreview?.disabled).toBe(false) - fireEvent.click(enabledPreview as HTMLElement) - - view.unmount() - expect(pause).toHaveBeenCalled() - - globalThis.Audio = OriginalAudio - window.Audio = OriginalWindowAudio - }) - - it('selecting a voice calls localStorage.setItem with the voice id', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const alice = await screen.findByText('Alice') - fireEvent.click(alice) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-elevenlabs', 'dyn1') - }) - - it('shows Gemini static voices when hub backend is gemini-live', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await waitFor(() => { - expect(screen.getByText('Puck')).toBeInTheDocument() - expect(screen.getByText('Aoede')).toBeInTheDocument() - expect(screen.getByText('Conversational, friendly')).toBeInTheDocument() - }) - expect(mockFetchVoices).not.toHaveBeenCalled() - }) - - it('shows static catalog hint when Gemini backend is selected', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('Open the list to see voice character notes. Audio preview is ElevenLabs only.')).toBeInTheDocument() - }) - }) - - it('persists Gemini voice selection under gemini storage key', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const puck = await screen.findByText('Puck') - fireEvent.click(puck) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-gemini', 'Puck') - }) - - it('shows backend chooser when hub has multiple configured backends', async () => { - mockFetchVoiceBackend.mockResolvedValue({ - backend: 'gemini-live', - backends: ['elevenlabs', 'gemini-live'] - }) - ;(window.localStorage.getItem as ReturnType).mockImplementation((key: string) => { - if (key === 'hapi-voice-backend') return 'elevenlabs' - return null - }) - - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('Voice backend')).toBeInTheDocument() - expect(screen.getByText('Voice Assistant')).toBeInTheDocument() - expect(screen.getByText('Connection & provider')).toBeInTheDocument() - }) - - const backendButton = screen.getByRole('button', { name: /Voice backend\s*ElevenLabs/i }) - fireEvent.click(backendButton) - - const listbox = screen.getByRole('listbox', { name: 'Voice backend' }) - expect(listbox.textContent).toContain('Gemini Live') - }) - - it('switches to ElevenLabs voices when backend chooser selects elevenlabs', async () => { - mockFetchVoiceBackend.mockResolvedValue({ - backend: 'gemini-live', - backends: ['elevenlabs', 'gemini-live'] - }) - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const backendButton = await screen.findByRole('button', { name: /Voice backend/i }) - fireEvent.click(backendButton) - fireEvent.click(screen.getByRole('option', { name: /ElevenLabs/i })) - - const voicePickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(voicePickerButton) - - await waitFor(() => { - expect(screen.getByText('Alice')).toBeInTheDocument() - }) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-backend', 'elevenlabs') + it('keeps persona, tuning, and diagnostics on the advanced route page', () => { + renderPage() + expect(screen.getByText('Persona controls')).toBeInTheDocument() + expect(screen.getByText('Sound controls')).toBeInTheDocument() + expect(screen.getByText('Diagnostics controls')).toBeInTheDocument() }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 256033628c..3436a85c4c 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -1,1289 +1,18 @@ -import { useState, useRef, useEffect } from 'react' -import { useTranslation, type Locale } from '@/lib/use-translation' -import { useAppGoBack } from '@/hooks/useAppGoBack' -import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' -import { VOICES, getFallbackVoices } from '@/lib/voices' -import { useAppContext } from '@/lib/app-context' -import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice' -import { - getStaticVoiceOptions, - readStoredVoiceSelection, - resolveSelectedVoiceBackend, - writeStoredVoiceBackendPreference, - writeStoredVoiceSelection, -} from '@/lib/voicePickerPreferences' -import { VOICE_BACKEND_LABELS } from '@hapi/protocol/voicePickerCatalog' -import type { VoiceBackendType } from '@hapi/protocol/voice' -import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' -import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize' -import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' -import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' -import { getSessionListStatusModeOptions, useSessionListStatusMode, type SessionListStatusMode } from '@/hooks/useSessionListStatusMode' -import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' -import { - MAX_SESSION_PREVIEW_LIMIT, - MIN_SESSION_PREVIEW_LIMIT, - normalizeSessionPreviewLimit, - useSessionPreviewLimit, -} from '@/hooks/useSessionPreviewLimit' -import { - getChatSurfaceColorPickerValue, - getChatSurfaceColorPresetOptions, - toCustomChatSurfaceColorPreference, - toPresetChatSurfaceColorPreference, - useChatSurfaceColors, - type ChatSurfaceColorPreference, - type ChatSurfaceColorPreset, -} from '@/hooks/useChatSurfaceColors' -import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' -import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' -import { PROTOCOL_VERSION } from '@hapi/protocol' -import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls' - -const locales: { value: Locale; nativeLabel: string }[] = [ - { value: 'en', nativeLabel: 'English' }, - { value: 'zh-CN', nativeLabel: '简体中文' }, -] - -const voiceLanguages = getElevenLabsSupportedLanguages() - -function BackIcon(props: { className?: string }) { - return ( - - - - ) -} - -function CheckIcon(props: { className?: string }) { - return ( - - - - ) -} - -function ChevronDownIcon(props: { className?: string }) { - return ( - - - - ) -} - -function PlayIcon(props: { className?: string }) { - return ( - - - - ) -} - -function StopIcon(props: { className?: string }) { - return ( - - - - ) -} - -function MinusIcon(props: { className?: string }) { - return ( - - - - ) -} - -function PlusIcon(props: { className?: string }) { - return ( - - - - - ) -} - -function SessionPreviewLimitControl(props: { - label: string - value: number - onChange: (value: number) => void - decreaseLabel: string - increaseLabel: string -}) { - const [draft, setDraft] = useState(String(props.value)) - - useEffect(() => { - setDraft(String(props.value)) - }, [props.value]) - - const commitDraft = () => { - const parsed = draft.trim() === '' ? props.value : Number(draft) - const next = normalizeSessionPreviewLimit(parsed) - props.onChange(next) - setDraft(String(next)) - } - - const step = (delta: number) => { - const next = normalizeSessionPreviewLimit(props.value + delta) - props.onChange(next) - setDraft(String(next)) - } - - return ( -
- -
- - setDraft(event.target.value)} - onBlur={commitDraft} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - commitDraft() - event.currentTarget.blur() - } - if (event.key === 'Escape') { - event.preventDefault() - setDraft(String(props.value)) - event.currentTarget.blur() - } - }} - className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm font-medium tabular-nums text-[var(--app-fg)] outline-none focus:bg-[var(--app-subtle-bg)]" - /> - -
-
- ) -} - -function ChatSurfaceColorControl(props: { - label: string - preference: ChatSurfaceColorPreference - onPresetChange: (preset: ChatSurfaceColorPreset) => void - onCustomChange: (value: string) => void - t: (key: string) => string -}) { - const presetOptions = getChatSurfaceColorPresetOptions() - const pickerValue = getChatSurfaceColorPickerValue(props.preference) - const isCustomSelected = props.preference.startsWith('custom:') - - return ( -
-
{props.label}
-
- {presetOptions.map((option) => { - const selected = props.preference === toPresetChatSurfaceColorPreference(option.value) - const swatchColor = getChatSurfaceColorPickerValue(toPresetChatSurfaceColorPreference(option.value)) - return ( - - ) - })} -
-
- {props.t('settings.chat.surfaceColor.custom')} - -
-
- ) -} - -function ThemeColorControl(props: { t: (key: string) => string }) { - const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors() - - return ( -
-
- {props.t('settings.display.themeColors.title')} - {hasAnyCustom && ( - - )} -
-
{props.t('settings.display.themeColors.description')}
-
- {keys.map((key) => { - const value = getPickerValue(key.id) - const customized = isCustomized(key.id) - return ( -
- {props.t(key.labelKey)} -
- {customized && ( - - )} - -
-
- ) - })} -
-
- ) -} - -export default function SettingsPage() { - const { t, locale, setLocale } = useTranslation() - const { api } = useAppContext() - const goBack = useAppGoBack() - const [isOpen, setIsOpen] = useState(false) - const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) - const [isFontOpen, setIsFontOpen] = useState(false) - const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false) - const [isChatOpen, setIsChatOpen] = useState(false) - const [isTerminalToolDisplayOpen, setIsTerminalToolDisplayOpen] = useState(false) - const [isSessionListStatusOpen, setIsSessionListStatusOpen] = useState(false) - const [isVoiceOpen, setIsVoiceOpen] = useState(false) - const [isVoiceBackendOpen, setIsVoiceBackendOpen] = useState(false) - const [isVoicePickerOpen, setIsVoicePickerOpen] = useState(false) - const containerRef = useRef(null) - const appearanceContainerRef = useRef(null) - const fontContainerRef = useRef(null) - const terminalFontContainerRef = useRef(null) - const chatContainerRef = useRef(null) - const terminalToolDisplayContainerRef = useRef(null) - const sessionListStatusContainerRef = useRef(null) - const voiceContainerRef = useRef(null) - const voiceBackendPickerRef = useRef(null) - const voicePickerContainerRef = useRef(null) - const { fontScale, setFontScale } = useFontScale() - const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() - const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit() - const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() - const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() - const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() - const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() - const { - toolGroupBackground, - userMessageBackground, - setToolGroupBackground, - setUserMessageBackground, - } = useChatSurfaceColors() - const { appearance, setAppearance } = useAppearance() - - // Voice language state - read from localStorage - const [voiceLanguage, setVoiceLanguage] = useState(() => { - return localStorage.getItem('hapi-voice-lang') - }) - - const [configuredVoiceBackends, setConfiguredVoiceBackends] = useState([]) - const [voiceBackend, setVoiceBackend] = useState(null) - - // Per-backend voice selection (localStorage keys differ by backend) - const [voiceId, setVoiceId] = useState(null) - - // Dynamic voice list fetched from hub (ElevenLabs only) - const [dynamicVoices, setDynamicVoices] = useState(null) - const [playingVoiceId, setPlayingVoiceId] = useState(null) - const currentAudioRef = useRef(null) - - // Voice opening mode — "brief" = proactive summary on connect, "greet" = greeting + wait - const [voiceOpening, setVoiceOpening] = useState<'greet' | 'brief'>(() => { - return localStorage.getItem('hapi-voice-proactive') === 'true' ? 'brief' : 'greet' - }) - - const handleVoiceOpeningChange = (value: 'greet' | 'brief') => { - setVoiceOpening(value) - if (value === 'brief') { - localStorage.setItem('hapi-voice-proactive', 'true') - } else { - localStorage.removeItem('hapi-voice-proactive') - } - } - - const fontScaleOptions = getFontScaleOptions() - const terminalFontSizeOptions = getTerminalFontSizeOptions() - const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions() - const terminalToolDisplayModeOptions = getTerminalToolDisplayModeOptions() - const sessionListStatusModeOptions = getSessionListStatusModeOptions() - const appearanceOptions = getAppearanceOptions() - const currentLocale = locales.find((loc) => loc.value === locale) - const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' - const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' - const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px' - const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send' - const currentTerminalToolDisplayModeLabel = terminalToolDisplayModeOptions.find((opt) => opt.value === terminalToolDisplayMode)?.labelKey ?? 'settings.chat.terminalToolDisplay.compact' - const currentSessionListStatusModeLabel = sessionListStatusModeOptions.find((opt) => opt.value === sessionListStatusMode)?.labelKey ?? 'settings.display.sessionListStatus.standard' - const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) - - const staticVoiceOptions = voiceBackend ? getStaticVoiceOptions(voiceBackend) : [] - - // Voice list: ElevenLabs dynamic + fallback, or static catalog for Gemini/Qwen - const fallbackVoices = getFallbackVoices(locale) - const voiceOptions: VoiceInfo[] = voiceBackend === 'elevenlabs' - ? (dynamicVoices && dynamicVoices.length > 0 - ? dynamicVoices - : fallbackVoices.map(v => ({ id: v.id, name: v.name, previewUrl: '', category: 'premade' }))) - : voiceBackend === 'gemini-live' || voiceBackend === 'qwen-realtime' - ? staticVoiceOptions.map(v => ({ - id: v.id, - name: v.label, - description: v.description, - previewUrl: '', - category: 'premade' - })) - : fallbackVoices.map(v => ({ id: v.id, name: v.name, previewUrl: '', category: 'premade' })) - - const currentVoiceName = voiceId - ? (voiceOptions.find(v => v.id === voiceId)?.name - ?? staticVoiceOptions.find(v => v.id === voiceId)?.label - ?? fallbackVoices.find(v => v.id === voiceId)?.name - ?? voiceId) - : null - const currentVoiceDescription = voiceId - ? (voiceOptions.find(v => v.id === voiceId)?.description - ?? staticVoiceOptions.find(v => v.id === voiceId)?.description) - : undefined - const staticVoiceCatalog = voiceBackend === 'gemini-live' || voiceBackend === 'qwen-realtime' - - const voicePreviewEnabled = voiceBackend === 'elevenlabs' - const showVoiceBackendChooser = configuredVoiceBackends.length > 1 - const currentVoiceBackendLabel = voiceBackend ? VOICE_BACKEND_LABELS[voiceBackend] : null - - const handleLocaleChange = (newLocale: Locale) => { - setLocale(newLocale) - setIsOpen(false) - } - - const handleAppearanceChange = (pref: AppearancePreference) => { - setAppearance(pref) - setIsAppearanceOpen(false) - } - - const handleFontScaleChange = (newScale: FontScale) => { - setFontScale(newScale) - setIsFontOpen(false) - } - - const handleTerminalFontSizeChange = (newSize: TerminalFontSize) => { - setTerminalFontSize(newSize) - setIsTerminalFontOpen(false) - } - - const handleComposerEnterBehaviorChange = (newBehavior: ComposerEnterBehavior) => { - setComposerEnterBehavior(newBehavior) - setIsChatOpen(false) - } - - const handleTerminalToolDisplayModeChange = (newMode: TerminalToolDisplayMode) => { - setTerminalToolDisplayMode(newMode) - setIsTerminalToolDisplayOpen(false) - } - - const handleSessionListStatusModeChange = (newMode: SessionListStatusMode) => { - setSessionListStatusMode(newMode) - setIsSessionListStatusOpen(false) - } - - const handleVoiceLanguageChange = (language: Language) => { - setVoiceLanguage(language.code) - if (language.code === null) { - localStorage.removeItem('hapi-voice-lang') - } else { - localStorage.setItem('hapi-voice-lang', language.code) - } - setIsVoiceOpen(false) - } - - const handleVoiceChange = (id: string | null) => { - setVoiceId(id) - writeStoredVoiceSelection(voiceBackend ?? 'elevenlabs', id) - setIsVoicePickerOpen(false) - } - - const handleVoiceBackendChange = (backend: VoiceBackendType) => { - writeStoredVoiceBackendPreference(backend) - setVoiceBackend(backend) - setVoiceId(readStoredVoiceSelection(backend)) - setIsVoiceBackendOpen(false) - } - - // Resolve configured backends, user preference, and voice selection for that backend - useEffect(() => { - let cancelled = false - fetchVoiceBackend(api).then((resp) => { - if (cancelled) return - setConfiguredVoiceBackends(resp.backends) - const selected = resolveSelectedVoiceBackend(resp.backends, resp.backend) - setVoiceBackend(selected) - setVoiceId(readStoredVoiceSelection(selected)) - }).catch(() => { - if (cancelled) return - setConfiguredVoiceBackends(['elevenlabs']) - setVoiceBackend('elevenlabs') - setVoiceId(readStoredVoiceSelection('elevenlabs')) - }) - return () => { cancelled = true } - }, [api]) - - // Fetch ElevenLabs voices only after hub reports elevenlabs backend - useEffect(() => { - if (voiceBackend !== 'elevenlabs') { - setDynamicVoices(null) - return - } - let cancelled = false - fetchVoices(api).then(voices => { - if (!cancelled && voices.length > 0) setDynamicVoices(voices) - }) - return () => { cancelled = true } - }, [api, voiceBackend]) - - const handleVoicePreview = (previewUrl: string, voiceId: string, event: React.MouseEvent) => { - event.stopPropagation() - if (!previewUrl) return - - if (playingVoiceId === voiceId) { - currentAudioRef.current?.pause() - currentAudioRef.current = null - setPlayingVoiceId(null) - return - } - - currentAudioRef.current?.pause() - const audio = new Audio(previewUrl) - currentAudioRef.current = audio - setPlayingVoiceId(voiceId) - audio.play().catch(() => setPlayingVoiceId(null)) - audio.addEventListener('ended', () => { - setPlayingVoiceId(null) - currentAudioRef.current = null - }) - } - - useEffect(() => { - return () => { - currentAudioRef.current?.pause() - currentAudioRef.current = null - setPlayingVoiceId(null) - } - }, []) - - // Close dropdown when clicking outside - useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - } - if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) { - setIsAppearanceOpen(false) - } - if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) { - setIsFontOpen(false) - } - if (isTerminalFontOpen && terminalFontContainerRef.current && !terminalFontContainerRef.current.contains(event.target as Node)) { - setIsTerminalFontOpen(false) - } - if (isChatOpen && chatContainerRef.current && !chatContainerRef.current.contains(event.target as Node)) { - setIsChatOpen(false) - } - if (isTerminalToolDisplayOpen && terminalToolDisplayContainerRef.current && !terminalToolDisplayContainerRef.current.contains(event.target as Node)) { - setIsTerminalToolDisplayOpen(false) - } - if (isSessionListStatusOpen && sessionListStatusContainerRef.current && !sessionListStatusContainerRef.current.contains(event.target as Node)) { - setIsSessionListStatusOpen(false) - } - if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) { - setIsVoiceOpen(false) - } - if (isVoiceBackendOpen && voiceBackendPickerRef.current && !voiceBackendPickerRef.current.contains(event.target as Node)) { - setIsVoiceBackendOpen(false) - } - if (isVoicePickerOpen && voicePickerContainerRef.current && !voicePickerContainerRef.current.contains(event.target as Node)) { - setIsVoicePickerOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen]) - - // Close on escape key - useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsOpen(false) - setIsAppearanceOpen(false) - setIsFontOpen(false) - setIsTerminalFontOpen(false) - setIsChatOpen(false) - setIsTerminalToolDisplayOpen(false) - setIsSessionListStatusOpen(false) - setIsVoiceOpen(false) - setIsVoiceBackendOpen(false) - setIsVoicePickerOpen(false) - } - } - - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen]) +import { useTranslation } from '@/lib/use-translation' +import { SettingsNav } from '@/components/settings/SettingsNav' +import SettingsDisplayPage from './display' +export default function SettingsHubPage() { + const { t } = useTranslation() return ( -
-
-
- -
{t('settings.title')}
-
+ <> +
+
{t('settings.hub.description')}
+
- -
-
- {/* Language section */} -
-
- {t('settings.language.title')} -
-
- - - {isOpen && ( -
- {locales.map((loc) => { - const isSelected = locale === loc.value - return ( - - ) - })} -
- )} -
-
- - {/* Display section */} -
-
- {t('settings.display.title')} -
-
- - - {isAppearanceOpen && ( -
- {appearanceOptions.map((opt) => { - const isSelected = appearance === opt.value - return ( - - ) - })} -
- )} -
- -
- - - {isFontOpen && ( -
- {fontScaleOptions.map((opt) => { - const isSelected = fontScale === opt.value - return ( - - ) - })} -
- )} -
-
- - - {isTerminalFontOpen && ( -
- {terminalFontSizeOptions.map((opt) => { - const isSelected = terminalFontSize === opt.value - return ( - - ) - })} -
- )} -
- -
-
- {t('settings.display.activeSessionsOnly')} - {t('settings.display.activeSessionsOnly.desc')} -
- -
-
- - - {isSessionListStatusOpen && ( -
- {sessionListStatusModeOptions.map((opt) => { - const isSelected = sessionListStatusMode === opt.value - return ( - - ) - })} -
- )} -
- {sessionListStatusMode === 'detailed' ? ( -
- {t('settings.display.sessionListStatus.detailedDescription')} -
- ) : null} -
- - {/* Chat section */} -
-
- {t('settings.chat.title')} -
-
- - - {isChatOpen && ( -
- {composerEnterBehaviorOptions.map((opt) => { - const isSelected = composerEnterBehavior === opt.value - return ( - - ) - })} -
- )} -
-
- - - {isTerminalToolDisplayOpen && ( -
- {terminalToolDisplayModeOptions.map((opt) => { - const isSelected = terminalToolDisplayMode === opt.value - return ( - - ) - })} -
- )} -
- setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} - onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} - t={t} - /> - setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} - onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} - t={t} - /> -
- - {/* Voice Assistant section */} -
-
- {t('settings.voice.title')} -
- - {/* ── Connection & provider ── */} -
-
- {t('settings.voice.connection.title')} -
- {showVoiceBackendChooser && ( -
- - {isVoiceBackendOpen && ( -
- {configuredVoiceBackends.map((backend) => { - const isSelected = voiceBackend === backend - return ( - - ) - })} -
- )} -
- )} -
- - {isVoiceOpen && ( -
- {voiceLanguages.map((lang) => { - const isSelected = voiceLanguage === lang.code - const displayName = lang.code === null ? t('settings.voice.autoDetect') : getLanguageDisplayName(lang) - return ( - - ) - })} -
- )} -
-
- - {staticVoiceCatalog &&

{t('settings.voice.staticCatalogHint')}

} - {isVoicePickerOpen && ( -
-
- -
- {voiceOptions.map((voice) => { - const isSelected = voiceId === voice.id - const isPlaying = playingVoiceId === voice.id - return ( -
- - -
- ) - })} -
- )} -
-
- - {/* ── How It Sounds ── */} -
-
- {t('settings.voice.sounds.title')} -
- -
- - {/* ── How it behaves ── */} -
-
- {t('settings.voice.behaves.title')} -
- {/* Opening */} -
-

{t('settings.voice.opening.label')}

-
- {(['greet', 'brief'] as const).map((opt) => ( - - ))} -
-
- -
- - {/* ── Persona & instructions ── */} -
-
- {t('settings.voice.persona.title')} -
- -
- - {/* ── Advanced ── */} -
-
- {t('settings.voice.advanced.section.title')} -
- -
-
- - {/* About section */} -
-
- {t('settings.about.title')} -
-
- {t('settings.about.website')} - - hapi.run - -
-
- {t('settings.about.appVersion')} - {__APP_VERSION__} -
-
- {t('settings.about.protocolVersion')} - {PROTOCOL_VERSION} -
-
-
+
+
-
+ ) } diff --git a/web/src/routes/settings/layout.tsx b/web/src/routes/settings/layout.tsx new file mode 100644 index 0000000000..e0fc8e986b --- /dev/null +++ b/web/src/routes/settings/layout.tsx @@ -0,0 +1,57 @@ +import { Outlet, useLocation, useNavigate } from '@tanstack/react-router' +import { useTranslation } from '@/lib/use-translation' +import { useAppGoBack } from '@/hooks/useAppGoBack' +import { SettingsNav } from '@/components/settings/SettingsNav' +import { getSettingsCategory } from './categories' + +function BackIcon() { + return ( + + ) +} + +export default function SettingsLayout() { + const { t } = useTranslation() + const goBack = useAppGoBack() + const navigate = useNavigate() + const pathname = useLocation({ select: (location) => location.pathname }) + const category = getSettingsCategory(pathname) + const mobileTitleKey = pathname === '/settings/voice/voices' + ? 'settings.voice.voice' + : pathname === '/settings/voice/advanced' + ? 'settings.voice.advanced.title' + : category?.titleKey ?? 'settings.title' + const mobileTitle = t(mobileTitleKey) + + return ( +
+
+
+ + +
+

{mobileTitle}

+ {t('settings.title')} +
+
+
+ +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/web/src/routes/settings/useVoiceSettings.test.tsx b/web/src/routes/settings/useVoiceSettings.test.tsx new file mode 100644 index 0000000000..a7ab41b635 --- /dev/null +++ b/web/src/routes/settings/useVoiceSettings.test.tsx @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { useVoiceSettings } from './useVoiceSettings' + +const { fetchVoiceBackend, fetchVoices, pause, play } = vi.hoisted(() => ({ + fetchVoiceBackend: vi.fn(), + fetchVoices: vi.fn(), + pause: vi.fn(), + play: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: {} }), +})) + +vi.mock('@/api/voice', () => ({ + fetchVoiceBackend, + fetchVoices, +})) + +function Wrapper(props: { children: React.ReactNode }) { + return {props.children} +} + +describe('useVoiceSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + fetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) + fetchVoices.mockResolvedValue([]) + class MockAudio { + constructor(_source: string) {} + play = play + pause = pause + addEventListener = vi.fn() + } + vi.stubGlobal('Audio', MockAudio) + }) + + it('loads dynamic voices and keeps the existing per-backend storage key', async () => { + fetchVoices.mockResolvedValue([ + { id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ]) + const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1')) + act(() => result.current.setVoice('dynamic-1')) + + expect(localStorage.getItem('hapi-voice-elevenlabs')).toBe('dynamic-1') + }) + + it('switches configured backends and restores the backend-specific selection', async () => { + localStorage.setItem('hapi-voice-elevenlabs', 'eleven-1') + fetchVoiceBackend.mockResolvedValue({ + backend: 'gemini-live', + backends: ['gemini-live', 'elevenlabs'], + }) + const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.backend).toBe('gemini-live')) + act(() => result.current.setBackend('elevenlabs')) + + expect(result.current.backend).toBe('elevenlabs') + expect(result.current.voiceId).toBe('eleven-1') + expect(localStorage.getItem('hapi-voice-backend')).toBe('elevenlabs') + }) + + it('stops a playing preview when the picker unmounts', async () => { + fetchVoices.mockResolvedValue([ + { id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ]) + const { result, unmount } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1')) + + act(() => result.current.previewVoice(result.current.voices[0])) + expect(play).toHaveBeenCalledOnce() + unmount() + expect(pause).toHaveBeenCalled() + }) +}) diff --git a/web/src/routes/settings/useVoiceSettings.ts b/web/src/routes/settings/useVoiceSettings.ts new file mode 100644 index 0000000000..1ebcd261f0 --- /dev/null +++ b/web/src/routes/settings/useVoiceSettings.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import { useTranslation } from '@/lib/use-translation' +import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice' +import { getFallbackVoices } from '@/lib/voices' +import { getElevenLabsSupportedLanguages, type Language } from '@/lib/languages' +import { + getStaticVoiceOptions, + readStoredVoiceSelection, + resolveSelectedVoiceBackend, + writeStoredVoiceBackendPreference, + writeStoredVoiceSelection, +} from '@/lib/voicePickerPreferences' +import type { VoiceBackendType } from '@hapi/protocol/voice' + +export function useVoiceSettings() { + const { api } = useAppContext() + const { locale } = useTranslation() + const [configuredBackends, setConfiguredBackends] = useState([]) + const [backend, setBackendState] = useState(null) + const [voiceId, setVoiceIdState] = useState(null) + const [dynamicVoices, setDynamicVoices] = useState(null) + const [voiceLanguage, setVoiceLanguageState] = useState(() => localStorage.getItem('hapi-voice-lang')) + const [playingVoiceId, setPlayingVoiceId] = useState(null) + const audioRef = useRef(null) + + useEffect(() => { + let cancelled = false + fetchVoiceBackend(api).then((response) => { + if (cancelled) return + setConfiguredBackends(response.backends) + const selected = resolveSelectedVoiceBackend(response.backends, response.backend) + setBackendState(selected) + setVoiceIdState(readStoredVoiceSelection(selected)) + }).catch(() => { + if (cancelled) return + setConfiguredBackends(['elevenlabs']) + setBackendState('elevenlabs') + setVoiceIdState(readStoredVoiceSelection('elevenlabs')) + }) + return () => { cancelled = true } + }, [api]) + + useEffect(() => { + if (backend !== 'elevenlabs') { + setDynamicVoices(null) + return + } + let cancelled = false + fetchVoices(api).then((voices) => { + if (!cancelled) setDynamicVoices(voices.length > 0 ? voices : null) + }).catch(() => { + if (!cancelled) setDynamicVoices(null) + }) + return () => { cancelled = true } + }, [api, backend]) + + const voices = useMemo(() => { + const fallback = getFallbackVoices(locale) + if (backend === 'elevenlabs') { + return dynamicVoices ?? fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' })) + } + if (backend === 'gemini-live' || backend === 'qwen-realtime') { + return getStaticVoiceOptions(backend).map((voice) => ({ id: voice.id, name: voice.label, description: voice.description, previewUrl: '', category: 'premade' })) + } + return fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' })) + }, [backend, dynamicVoices, locale]) + + const setBackend = useCallback((value: VoiceBackendType) => { + writeStoredVoiceBackendPreference(value) + setBackendState(value) + setVoiceIdState(readStoredVoiceSelection(value)) + }, []) + + const setVoice = useCallback((value: string | null) => { + setVoiceIdState(value) + writeStoredVoiceSelection(backend ?? 'elevenlabs', value) + }, [backend]) + + const setVoiceLanguage = useCallback((language: Language) => { + setVoiceLanguageState(language.code) + if (language.code === null) localStorage.removeItem('hapi-voice-lang') + else localStorage.setItem('hapi-voice-lang', language.code) + }, []) + + const stopPreview = useCallback(() => { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + }, []) + + const previewVoice = useCallback((voice: VoiceInfo) => { + if (!voice.previewUrl || backend !== 'elevenlabs') return + if (playingVoiceId === voice.id) { + stopPreview() + return + } + stopPreview() + const audio = new Audio(voice.previewUrl) + audioRef.current = audio + setPlayingVoiceId(voice.id) + audio.play().catch(stopPreview) + audio.addEventListener('ended', stopPreview, { once: true }) + }, [backend, playingVoiceId, stopPreview]) + + useEffect(() => stopPreview, [stopPreview]) + + return { + configuredBackends, + backend, + setBackend, + voiceId, + setVoice, + voices, + voiceLanguage, + setVoiceLanguage, + voiceLanguages: getElevenLabsSupportedLanguages(), + playingVoiceId, + previewVoice, + } +} diff --git a/web/src/routes/settings/voice-advanced.tsx b/web/src/routes/settings/voice-advanced.tsx new file mode 100644 index 0000000000..078eae50e2 --- /dev/null +++ b/web/src/routes/settings/voice-advanced.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from '@/lib/use-translation' +import { VoiceDiagnosticsControls, VoicePersonaControls, VoiceSoundsControls } from '@/components/settings/VoiceAdvancedControls' +import { SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +export default function SettingsVoiceAdvancedPage() { + const { t } = useTranslation() + const { backend } = useVoiceSettings() + return ( + + + + + + + + + + + + ) +} diff --git a/web/src/routes/settings/voice-voices.tsx b/web/src/routes/settings/voice-voices.tsx new file mode 100644 index 0000000000..90b5784cf6 --- /dev/null +++ b/web/src/routes/settings/voice-voices.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from '@/lib/use-translation' +import { CheckIcon, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +function PlayIcon() { + return +} + +function StopIcon() { + return +} + +export default function SettingsVoiceVoicesPage() { + const { t } = useTranslation() + const voice = useVoiceSettings() + return ( + + +
+ + {voice.voices.map((option) => { + const selected = voice.voiceId === option.id + const playing = voice.playingVoiceId === option.id + const canPreview = voice.backend === 'elevenlabs' && Boolean(option.previewUrl) + return ( +
+ + +
+ ) + })} +
+
+
+ ) +} diff --git a/web/src/routes/settings/voice.tsx b/web/src/routes/settings/voice.tsx new file mode 100644 index 0000000000..70240bca28 --- /dev/null +++ b/web/src/routes/settings/voice.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { VOICE_BACKEND_LABELS } from '@hapi/protocol/voicePickerCatalog' +import { getLanguageDisplayName } from '@/lib/languages' +import { useTranslation } from '@/lib/use-translation' +import { VoiceRespondsControls } from '@/components/settings/VoiceAdvancedControls' +import { SettingsChoiceGroup, SettingsLinkRow, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +export default function SettingsVoicePage() { + const { t } = useTranslation() + const navigate = useNavigate() + const voice = useVoiceSettings() + const [opening, setOpening] = useState<'greet' | 'brief'>(() => localStorage.getItem('hapi-voice-proactive') === 'true' ? 'brief' : 'greet') + const selectedLanguage = voice.voiceLanguages.find((language) => language.code === voice.voiceLanguage) + const selectedVoice = voice.voices.find((option) => option.id === voice.voiceId) + + const setVoiceOpening = (value: 'greet' | 'brief') => { + setOpening(value) + if (value === 'brief') localStorage.setItem('hapi-voice-proactive', 'true') + else localStorage.removeItem('hapi-voice-proactive') + } + + return ( + + + {voice.configuredBackends.length > 1 && voice.backend ? ( + ({ value: backend, label: VOICE_BACKEND_LABELS[backend] }))} + onChange={voice.setBackend} + /> + ) : null} + + navigate({ to: '/settings/voice/voices' })} + /> + + + + ({ value, label: t(`settings.voice.opening.${value}`), description: t(`settings.voice.opening.${value}.hint`) }))} + onChange={setVoiceOpening} + /> + + + + navigate({ to: '/settings/voice/advanced' })} /> + + + ) +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 5df7b144d7..d5ed3d8905 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1,4 +1,10 @@ import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) function installMemoryLocalStorage(): void { const store = new Map() diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 89846a75b2..9a5b3d3ff5 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -18,6 +18,10 @@ export type { DirectoryEntry, FileReadResponse, GitCommandResponse, + GrokModelsResponse, + GrokModelSummary, + GrokReasoningEffortResponse, + GrokReasoningEffortOption, ListDirectoryResponse, MachineDirectoryEntry, MachineListDirectoryResponse, @@ -46,6 +50,7 @@ export type { Metadata, PermissionMode, Machine, + MachineHealth, PendingRequest, PendingRequestKind, RunnerState,