diff --git a/AGENTS.md b/AGENTS.md index eff9a9641..081c3481c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,43 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr - **Permission modes**: `default`, `acceptEdits`, `bypassPermissions`, `plan` - **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:` suffix +## Adding new web features — consider an FUE + +When you ship a non-essential feature (the 20% of sessions, not the 80%), consider wrapping its affordance in the generic First-User-Experience primitive so existing users discover it without a giant always-visible UI block. + +- **Hook**: `web/src/lib/use-fue.ts` — `useFue(featureId)` returns `{ status, engage, dismiss }`. Storage namespace `hapi.fue.v1.` (one localStorage key per feature, isolated from any upstream onboarding flow). +- **Components**: `web/src/components/Fue.tsx` — `` (small pulsing badge for the affordance) and `` (portal-rendered popover with title/body + "Got it" affirmative-action dismiss). + +Pattern (~10 lines around the affordance): + +```tsx +const fue = useFue('my-feature') +const buttonRef = useRef(null) +return ( + <> + + {fue.status === 'engaging' ? ( + + ) : null} + +) +``` + +Rules: +- Affirmative action only: there is no auto-timeout — user dismisses by clicking "Got it" (reading speed varies). +- The FUE dot and any feature-specific badge (e.g. an entry counter) should be **mutually exclusive**: onboarding signal beats inventory signal until acknowledged. +- Storage is opt-in per-feature; if upstream ships its own onboarding for a feature, just don't wrap that affordance. + +Canonical example: scratchlist toggle in `web/src/components/AssistantChat/ComposerButtons.tsx` (`ScratchlistToggleButton`). + ## Critical Thinking 1. Fix root cause (not band-aid). diff --git a/bun.lock b/bun.lock index 92fe93d1c..0bcd2d047 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "cli": { "name": "@twsxtd/hapi", - "version": "0.20.0", + "version": "0.20.1", "bin": { "hapi": "bin/hapi.cjs", }, @@ -46,11 +46,11 @@ "vitest": "^4.0.16", }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.20.0", - "@twsxtd/hapi-darwin-x64": "0.20.0", - "@twsxtd/hapi-linux-arm64": "0.20.0", - "@twsxtd/hapi-linux-x64": "0.20.0", - "@twsxtd/hapi-win32-x64": "0.20.0", + "@twsxtd/hapi-darwin-arm64": "0.20.1", + "@twsxtd/hapi-darwin-x64": "0.20.1", + "@twsxtd/hapi-linux-arm64": "0.20.1", + "@twsxtd/hapi-linux-x64": "0.20.1", + "@twsxtd/hapi-win32-x64": "0.20.1", }, }, "docs": { @@ -139,10 +139,14 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", + "hast-util-to-html": "^9.0.5", "jsdom": "^26.1.0", "postcss": "^8.5.6", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", + "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16", }, @@ -1062,13 +1066,15 @@ "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], - "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.20.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-B/S//3qBcFJCJD3CxLZRXoy1UAUvtNtn1MyA1N4Erho9/YZdH8aX6NyA1tfTGMT+Uu/lm+w+NzNdiZJGe9q2dQ=="], + "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.20.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-pqeelPhT2jQQc0QkqBKuKpgmPuOckg+NapIr9p41TZWfSUyY4TkJk69ymk/bJw0e5n58Kwsm4aULFm5RRAtpdA=="], - "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.20.0", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-/2bTEtPCq/cqiMEcbIr2HCOIGktbn30ehJb3RynVM8vEFcayxX5QNAZSBJsPHdAPyfW8OV5isGUaM5pObg+wvA=="], + "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.20.1", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-cdVybNC/XzVqONtsH+gTMyAaYshf6vGM0bKakR3ItHg5Sq+0pPd64WB+nqdFrdyRWtRcq/XXmT9TlRAYkjsrbQ=="], - "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.20.0", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-p/m27u19GlLGVbYHXmNWEX6ohaRgi7fioSbIVzM34QJAHZevnK1NYmC8xodWKQTEpRizqEbZBGUHycugquvpbg=="], + "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.20.1", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-3EMbXqztDgToXles5fg3tgT0lBOmq6bdfWXb+tQD0H2pHdt/akL0EsVUKBdc8kkm45b1nClTQsptdhPIbRQCSw=="], - "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-hmdAxSgsAxeLfRFn57u13xLc7jHGxKJR/HZUwKkFKe6vcTkGRWsSJToVhGZrUSCT+giYo8g0YQpyOWI/i8cBLA=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-VWPCKdAgwfUNBRI9Xy14CKjx1d7JS1irOja5l6zufpaTi139jc51gyDcWFfygMwttQlNimmh2qHTfaFqqvcdNg=="], + + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kHsA3aV9LlIbI0kpqeF8oFeSCLCubaGVHnC3l5AH51KbvlDGeYzXyr1S8KEdgVgd1Gg3cS6LmwYL/xnyr6WO5Q=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/cli/package.json b/cli/package.json index 02b7fe464..1a53868ce 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@twsxtd/hapi", - "version": "0.20.0", + "version": "0.20.1", "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.0", - "@twsxtd/hapi-darwin-x64": "0.20.0", - "@twsxtd/hapi-linux-arm64": "0.20.0", - "@twsxtd/hapi-linux-x64": "0.20.0", - "@twsxtd/hapi-win32-x64": "0.20.0" + "@twsxtd/hapi-darwin-arm64": "0.20.1", + "@twsxtd/hapi-darwin-x64": "0.20.1", + "@twsxtd/hapi-linux-arm64": "0.20.1", + "@twsxtd/hapi-linux-x64": "0.20.1", + "@twsxtd/hapi-win32-x64": "0.20.1" }, "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"", diff --git a/cli/src/agent/backends/acp/agentCliGuard.test.ts b/cli/src/agent/backends/acp/agentCliGuard.test.ts index 812f45e9a..e9c783321 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.test.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.test.ts @@ -15,6 +15,21 @@ function lockDir(): string { return join(testHome, 'locks', 'agent-acp-active'); } +function writeTestAcpLock(args: { count: number; pids: number[] }): void { + const dir = lockDir(); + mkdirSync(join(dir, 'pids'), { recursive: true }); + writeFileSync(join(dir, 'count'), String(args.count), 'utf8'); + for (const pid of args.pids) { + writeFileSync(join(dir, 'pids', String(pid)), String(pid), 'utf8'); + } +} + +function writeLegacyAcpLock(pid: number): void { + const dir = lockDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'pid'), String(pid), 'utf8'); +} + describe('agentCliGuard', () => { const previousHome = process.env.HAPI_HOME; @@ -35,32 +50,72 @@ describe('agentCliGuard', () => { expect(isAgentAcpTransportActive()).toBe(false); }); - test('clears stale cross-process lock when pid is not running', () => { + test('keeps cross-process lock until the last transport unregisters', () => { process.env.HAPI_HOME = testHome; - const dir = lockDir(); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'pid'), '99999999'); + registerActiveAcpTransport(); + registerActiveAcpTransport(); + + unregisterActiveAcpTransport(); + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); + unregisterActiveAcpTransport(); expect(isAgentAcpTransportActive()).toBe(false); - expect(existsSync(dir)).toBe(false); + expect(existsSync(lockDir())).toBe(false); }); - test('keeps lock when pid file points at a live process', () => { + test('leaves refcount at one after the first of two in-process unregisters', () => { process.env.HAPI_HOME = testHome; + registerActiveAcpTransport(); + registerActiveAcpTransport(); + const dir = lockDir(); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'pid'), String(process.pid)); + unregisterActiveAcpTransport(); expect(isAgentAcpTransportActive()).toBe(true); expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, 'pids', String(process.pid)))).toBe(true); + }); + + test('clears stale cross-process lock when pid is not running', () => { + process.env.HAPI_HOME = testHome; + writeLegacyAcpLock(99999999); + + expect(isAgentAcpTransportActive()).toBe(false); + expect(existsSync(lockDir())).toBe(false); + }); + + test('keeps legacy lock when pid file points at a live process', () => { + process.env.HAPI_HOME = testHome; + writeLegacyAcpLock(process.pid); + + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); }); - test('clears lock when pid file is missing or invalid', () => { + test('clears refcount lock when pid entries are missing or invalid', () => { process.env.HAPI_HOME = testHome; const dir = lockDir(); mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'count'), '1', 'utf8'); expect(isAgentAcpTransportActive()).toBe(false); expect(existsSync(dir)).toBe(false); }); + + test('clears refcount lock when all pid entries are stale', () => { + process.env.HAPI_HOME = testHome; + writeTestAcpLock({ count: 2, pids: [99999998, 99999999] }); + + expect(isAgentAcpTransportActive()).toBe(false); + expect(existsSync(lockDir())).toBe(false); + }); + + test('reconciles refcount lock down to live pid entries', () => { + process.env.HAPI_HOME = testHome; + writeTestAcpLock({ count: 3, pids: [process.pid, 99999999] }); + + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(lockDir())).toBe(true); + }); }); diff --git a/cli/src/agent/backends/acp/agentCliGuard.ts b/cli/src/agent/backends/acp/agentCliGuard.ts index 7c68d8e6b..b4937ef00 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -17,6 +24,10 @@ function getAcpLockDir(): string { return join(home, 'locks', 'agent-acp-active'); } +function getPidsDir(lockDir: string): string { + return join(lockDir, 'pids'); +} + function readLockPid(lockDir: string): number | null { const pidPath = join(lockDir, 'pid'); if (!existsSync(pidPath)) { @@ -35,6 +46,46 @@ function readLockPid(lockDir: string): number | null { } } +function readLockCount(lockDir: string): number { + const countPath = join(lockDir, 'count'); + if (!existsSync(countPath)) { + return 0; + } + + try { + const raw = readFileSync(countPath, 'utf8').trim(); + const count = Number(raw); + if (!Number.isInteger(count) || count < 0) { + return 0; + } + return count; + } catch { + return 0; + } +} + +function writeLockCount(lockDir: string, count: number): void { + writeFileSync(join(lockDir, 'count'), String(Math.max(0, count)), 'utf8'); +} + +function addLockPid(lockDir: string, pid: number): void { + const pidsDir = getPidsDir(lockDir); + mkdirSync(pidsDir, { recursive: true }); + writeFileSync(join(pidsDir, String(pid)), String(pid), 'utf8'); +} + +function removeLockPid(lockDir: string, pid: number): void { + try { + rmSync(join(getPidsDir(lockDir), String(pid)), { force: true }); + } catch { + // Best effort. + } +} + +function isLegacyLock(lockDir: string): boolean { + return existsSync(join(lockDir, 'pid')) && !existsSync(join(lockDir, 'count')); +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -58,6 +109,46 @@ function removeAcpLockDir(): void { } } +function reconcileRefcountLock(lockDir: string): boolean { + const pidsDir = getPidsDir(lockDir); + if (!existsSync(pidsDir)) { + removeAcpLockDir(); + return false; + } + + let liveCount = 0; + for (const entry of readdirSync(pidsDir)) { + const pid = Number(entry); + if (!Number.isInteger(pid) || pid <= 0) { + try { + rmSync(join(pidsDir, entry), { force: true }); + } catch { + // Best effort. + } + continue; + } + + if (isProcessAlive(pid)) { + liveCount += 1; + continue; + } + + try { + rmSync(join(pidsDir, entry), { force: true }); + } catch { + // Best effort. + } + } + + if (liveCount <= 0) { + removeAcpLockDir(); + return false; + } + + writeLockCount(lockDir, liveCount); + return true; +} + /** Remove lock directories left behind by SIGKILL / crash / reboot. */ function clearStaleAcpLockIfNeeded(): void { const lockDir = getAcpLockDir(); @@ -65,10 +156,15 @@ function clearStaleAcpLockIfNeeded(): void { return; } - const pid = readLockPid(lockDir); - if (pid === null || !isProcessAlive(pid)) { - removeAcpLockDir(); + if (isLegacyLock(lockDir)) { + const pid = readLockPid(lockDir); + if (pid === null || !isProcessAlive(pid)) { + removeAcpLockDir(); + } + return; } + + reconcileRefcountLock(lockDir); } export function registerActiveAcpTransport(): void { @@ -76,7 +172,8 @@ export function registerActiveAcpTransport(): void { const lockDir = getAcpLockDir(); try { mkdirSync(lockDir, { recursive: true }); - writeFileSync(join(lockDir, 'pid'), String(process.pid)); + writeLockCount(lockDir, readLockCount(lockDir) + 1); + addLockPid(lockDir, process.pid); } catch { // Another process may have created the lock; in-process guard still applies. } @@ -84,10 +181,27 @@ export function registerActiveAcpTransport(): void { export function unregisterActiveAcpTransport(): void { activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1); - if (activeAcpTransportCount > 0) { + + const lockDir = getAcpLockDir(); + if (!existsSync(lockDir)) { return; } - removeAcpLockDir(); + + if (isLegacyLock(lockDir)) { + if (activeAcpTransportCount <= 0) { + removeAcpLockDir(); + } + return; + } + + try { + if (activeAcpTransportCount <= 0) { + removeLockPid(lockDir, process.pid); + } + reconcileRefcountLock(lockDir); + } catch { + // Best effort. + } } export function isAgentAcpTransportActive(): boolean { @@ -95,7 +209,17 @@ export function isAgentAcpTransportActive(): boolean { return true; } clearStaleAcpLockIfNeeded(); - return existsSync(getAcpLockDir()); + const lockDir = getAcpLockDir(); + if (!existsSync(lockDir)) { + return false; + } + + if (isLegacyLock(lockDir)) { + const pid = readLockPid(lockDir); + return pid !== null && isProcessAlive(pid); + } + + return readLockCount(lockDir) > 0; } export function _resetAgentCliGuardForTests(): void { diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index dd0a11ccc..607232815 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -141,7 +141,7 @@ describe('claudeRemote async message handling', () => { queryMock.mockReset(); querySpy.mockRestore(); } - }); + }, 15_000); it('handles rejected next user message fetch without unhandled rejection', async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index 0bd649bd8..a51d01cc1 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -80,7 +80,12 @@ describe('codexLocal', () => { mcpServers: { hapi: { command: hapiCommandPath, - args: ['mcp', '--url', 'http://127.0.0.1:63995/'] + args: ['mcp', '--url', 'http://127.0.0.1:63995/'], + tools: { + change_title: { + approval_mode: 'approve' + } + } } }, sessionHook: { @@ -108,6 +113,7 @@ describe('codexLocal', () => { expect(hookArg).toBeDefined(); expect(hookArg).toContain('{ hooks = [{ type = "command", command = "'); expect(args).toContain("mcp_servers.hapi.args=['mcp','--url','http://127.0.0.1:63995/']"); + expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); }); it('passes reasoning effort through Codex config instead of an unsupported CLI flag', async () => { diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index a72ee38ab..e60e3a87a 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -9,6 +9,7 @@ import { import { codexSystemPrompt } from './utils/systemPrompt'; import type { ReasoningEffort } from './appServerTypes'; import { resolveCodexCommand } from './utils/codexExecutable'; +import type { McpServersConfig } from './utils/buildHapiMcpBridge'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -38,7 +39,7 @@ export async function codexLocal(opts: { sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; onSessionFound: (id: string) => void; codexArgs?: string[]; - mcpServers?: Record; + mcpServers?: McpServersConfig; sessionHook?: { port: number; token: string; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 6c53fb17a..c8892419d 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -102,9 +102,16 @@ export class CodexSession extends AgentSessionBase { this.sessionId = null; this.resetTranscriptPath(); this.client.updateMetadata((metadata: Metadata) => { - const updated = { ...metadata }; - delete updated.codexSessionId; - return updated; + // Explicit-clear sentinel: `null` instructs the hub merge to + // drop `codexSessionId` from the persisted blob. Plain + // `delete` arrives at the hub as an omitted field, which the + // carry-forward path then restores from the prior row — + // defeating the reset. See hub/src/store/sessions.ts + // mergeSessionMetadata. The value is `null` on the wire only; + // MetadataSchema parses `string().optional()`, so the + // post-merge persisted blob carries no key. + const updated: Record = { ...metadata, codexSessionId: null }; + return updated as unknown as Metadata; }); } diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 26e1cc5c7..3a08c554a 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -47,6 +47,37 @@ describe('appServerConfig', () => { expect(params.approvalPolicy).toBe('on-request'); }); + it('passes MCP per-tool approval config through thread config', () => { + const params = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', collaborationMode: 'default' }, + mcpServers: { + hapi: { + command: 'node', + args: ['mcp'], + tools: { + change_title: { + approval_mode: 'approve' + } + } + } + } + }); + + expect(params.config).toEqual({ + 'mcp_servers.hapi': { + command: 'node', + args: ['mcp'], + tools: { + change_title: { + approval_mode: 'approve' + } + } + }, + developer_instructions: codexSystemPrompt + }); + }); + it('ignores CLI overrides when permission mode is not default', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 4a2e4f7ec..84f8d8134 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -61,7 +61,8 @@ function buildMcpServerConfig(mcpServers: McpServersConfig): Record; } /** @@ -63,7 +70,12 @@ export async function buildHapiMcpBridge( mcpServers: { hapi: { command: bridgeCommand.command, - args: bridgeCommand.args + args: bridgeCommand.args, + tools: { + change_title: { + approval_mode: 'approve' + } + } } } }; diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index 6f99ac377..298f2dbd0 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -23,6 +23,24 @@ describe('codexMcpConfig', () => { ]); }); + it('builds per-tool approval mode config', () => { + const mcpServers = { + hapi: { + command: 'hapi', + args: ['mcp'], + tools: { + change_title: { + approval_mode: 'approve' as const + } + } + } + }; + + const args = buildMcpServerConfigArgs(mcpServers); + + expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); + }); + it('builds config args for multiple MCP servers', () => { const mcpServers = { hapi: { command: 'hapi', args: ['mcp'] }, diff --git a/cli/src/codex/utils/codexMcpConfig.ts b/cli/src/codex/utils/codexMcpConfig.ts index 1cb045319..367bee1ae 100644 --- a/cli/src/codex/utils/codexMcpConfig.ts +++ b/cli/src/codex/utils/codexMcpConfig.ts @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; +import type { McpServersConfig } from './buildHapiMcpBridge'; /** * Escape a string value for use in a TOML string literal. @@ -121,7 +122,7 @@ export function buildSessionStartHookConfigArgs(port: number, token: string): st * @returns Array of CLI arguments to pass to codex */ export function buildMcpServerConfigArgs( - mcpServers: Record + mcpServers: McpServersConfig ): string[] { const configArgs: string[] = []; @@ -133,6 +134,15 @@ export function buildMcpServerConfigArgs( // Use TOML literal strings to avoid shell-quote mangling on Windows. const argsToml = buildTomlLiteralArray(server.args); configArgs.push('-c', `mcp_servers.${name}.args=${argsToml}`); + + for (const [toolName, tool] of Object.entries(server.tools ?? {})) { + if (tool.approval_mode) { + configArgs.push( + '-c', + `mcp_servers.${name}.tools.${toolName}.approval_mode="${escapeTomlString(tool.approval_mode)}"` + ); + } + } } return configArgs; diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index c039ce041..3aeb74738 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -12,7 +12,9 @@ const harness = vi.hoisted(() => ({ backendArgs: null as { command: string; args?: string[] } | null, setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>, deferSetConfigOption: null as Promise | null, - releaseSetConfigOption: null as (() => void) | null + releaseSetConfigOption: null as (() => void) | null, + deferLoadSession: null as Promise | null, + releaseLoadSession: null as (() => void) | null })); const legacyLauncher = vi.hoisted(() => vi.fn()); @@ -38,6 +40,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({ supportsLoadSession: vi.fn(() => harness.supportsLoadSession), loadSession: vi.fn(async () => { harness.loadSessionCalled = true; + if (harness.deferLoadSession) { + await harness.deferLoadSession; + } if (harness.loadSessionError) throw harness.loadSessionError; return 'loaded-acp-session'; }), @@ -174,6 +179,8 @@ describe('cursorAcpRemoteLauncher', () => { harness.setConfigOptionCalls = []; harness.deferSetConfigOption = null; harness.releaseSetConfigOption = null; + harness.deferLoadSession = null; + harness.releaseLoadSession = null; legacyLauncher.mockClear(); process.stdin.isTTY = false; process.stdout.isTTY = false; @@ -204,6 +211,27 @@ describe('cursorAcpRemoteLauncher', () => { expect(harness.newSessionCalled).toBe(false); }); + it('registers cursorSessionId before session/load completes', async () => { + let releaseLoadSession!: () => void; + harness.deferLoadSession = new Promise((resolve) => { + harness.releaseLoadSession = resolve; + releaseLoadSession = resolve; + }); + + const session = makeSession('resume-thread-1'); + const launchPromise = cursorAcpRemoteLauncher(session); + + await vi.waitFor(() => { + expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('resume-thread-1', 'acp'); + }); + expect(harness.loadSessionCalled).toBe(true); + + releaseLoadSession(); + await launchPromise; + + expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('loaded-acp-session', 'acp'); + }); + it('throws when session/load fails instead of falling back to stream-json', async () => { harness.loadSessionError = new Error('session not found'); const session = makeSession('old-stream-json-id'); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 3c1028769..58b611bee 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -17,7 +17,8 @@ import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge'; import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot'; import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter'; import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig'; -import { seedCursorModelsCache } from '@/modules/common/cursorModels'; +import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; +import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; class CursorAcpRemoteLauncher extends RemoteLauncherBase { @@ -95,6 +96,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { let acpSessionId: string; if (resumeSessionId && backend.supportsLoadSession()) { + // Register pending cursorSessionId before awaiting session/load (Zed PR #54431). + session.onSessionFoundWithProtocol(resumeSessionId, 'acp'); try { acpSessionId = await backend.loadSession({ sessionId: resumeSessionId, @@ -118,7 +121,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { }); } - session.onSessionFoundWithProtocol(acpSessionId, 'acp'); + if (acpSessionId !== resumeSessionId) { + session.onSessionFoundWithProtocol(acpSessionId, 'acp'); + } syncCursorModelsFromAcp(backend, acpSessionId); @@ -442,8 +447,9 @@ function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string): return; } + const payload = buildCursorModelsSeedPayload(snapshot, readSharedCursorModelsCache()); setCursorAcpModelsSnapshot(snapshot); - seedCursorModelsCache({ success: true, ...snapshot }); + seedCursorModelsCache(payload); } function toAcpMcpServers(config: Record): McpServerStdio[] { diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 46d305485..32d811626 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; const spawnMock = vi.hoisted(() => vi.fn()); @@ -26,24 +27,62 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { CursorSession } from './session'; import type { EnhancedMode } from './loop'; -function makeChild() { - const stdoutHandlers: Array<(chunk: string) => void> = []; - return { - stdout: { on: vi.fn((event: string, handler: (chunk: string) => void) => { - if (event === 'data') stdoutHandlers.push(handler); - }) }, - stderr: { on: vi.fn() }, +type ChildOptions = { + exitCode?: number | null; + stderr?: string; +}; + +function makeChild(opts: ChildOptions = {}) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = { + stdout, + stderr, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'exit') { - setImmediate(() => handler(0, null)); + if (event === 'close') { + setImmediate(() => { + if (opts.stderr) { + // emit synchronously so runAgentProcess captures it before + // the close handler resolves. + stderr.emit('data', Buffer.from(opts.stderr)); + } + handler(opts.exitCode ?? 0, null); + }); } }), emitStdout(line: string) { - for (const handler of stdoutHandlers) { - handler(`${line}\n`); - } + stdout.write(`${line}\n`); } }; + return child; +} + +function makeClient() { + return { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn((handler: (m: Record) => Record) => { + handler({ path: '/tmp', host: 'h', flavor: 'cursor' }); + }), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitMessagesConsumed: vi.fn() + }; +} + +function makeSession(queue: MessageQueue2, client: ReturnType): CursorSession { + return new CursorSession({ + api: {} as never, + client: client as never, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: 'legacy-id', + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote' + }); } describe('cursorLegacyRemoteLauncher', () => { @@ -51,6 +90,11 @@ describe('cursorLegacyRemoteLauncher', () => { spawnMock.mockReset(); process.stdin.isTTY = false; process.stdout.isTTY = false; + process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS = '0'; + }); + + afterEach(() => { + delete process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS; }); it('spawns agent with stream-json and trust, not acp', async () => { @@ -61,30 +105,8 @@ describe('cursorLegacyRemoteLauncher', () => { queue.push('hello', { permissionMode: 'default' }); queue.close(); - const metadataUpdates: unknown[] = []; - const client = { - rpcHandlerManager: { registerHandler: vi.fn() }, - updateMetadata: vi.fn((handler: (m: Record) => Record) => { - metadataUpdates.push(handler({ path: '/tmp', host: 'h', flavor: 'cursor' })); - }), - sendSessionEvent: vi.fn(), - sendAgentMessage: vi.fn(), - keepAlive: vi.fn(), - emitMessagesConsumed: vi.fn() - }; - - const session = new CursorSession({ - api: {} as never, - client: client as never, - path: '/tmp/project', - logPath: '/tmp/log', - sessionId: 'legacy-id', - messageQueue: queue, - onModeChange: vi.fn(), - mode: 'remote', - startedBy: 'runner', - startingMode: 'remote' - }); + const client = makeClient(); + const session = makeSession(queue, client); const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); await cursorLegacyRemoteLauncher(session); @@ -97,10 +119,203 @@ describe('cursorLegacyRemoteLauncher', () => { expect(args).toContain('--resume'); expect(args).toContain('legacy-id'); expect(args).not.toContain('acp'); + }); + + it('requeues the user message and surfaces an auth banner when agent exits with auth-required stderr', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); - expect(metadataUpdates[0]).toEqual(expect.objectContaining({ - cursorSessionId: 'legacy-id', - cursorSessionProtocol: 'stream-json' + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(2); + const messages = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messages).toHaveLength(1); + expect(messages[0].message).toContain('Cursor authentication expired'); + expect(messages[0].message).toContain("'agent login'"); + expect(messages[0].message).toContain('queued and will retry'); + + const firstPrompt = spawnMock.mock.calls[0]?.[1] as string[]; + const secondPrompt = spawnMock.mock.calls[1]?.[1] as string[]; + const pIndex1 = firstPrompt.indexOf('-p'); + const pIndex2 = secondPrompt.indexOf('-p'); + expect(firstPrompt[pIndex1 + 1]).toBe('do thing'); + expect(secondPrompt[pIndex2 + 1]).toBe('do thing'); + }); + + it('uses a rate-limit-specific banner for rate limit stderr', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: 'Error: rate limit exceeded, please retry later\n' + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + const messages = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messages).toHaveLength(1); + expect(messages[0].message).toContain('rate limit'); + expect(messages[0].message).toContain('queued and will retry'); + }); + + it('does not requeue when stderr is non-transient (real crash); surfaces error and emits ready', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + queue.close(); + + spawnMock.mockReturnValue(makeChild({ + exitCode: 134, + stderr: 'fatal: Segmentation fault\n' })); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messageEvents).toHaveLength(1); + expect(messageEvents[0].message).toContain('Agent exited (134)'); + expect(messageEvents[0].message).toContain('Segmentation fault'); + expect(messageEvents[0].message).not.toContain('queued and will retry'); + + const readyEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'ready'); + expect(readyEvents).toHaveLength(1); + }); + + it('does not retry signal-killed processes even if stderr contains a transient keyword', async () => { + // SIGTERM → exit 143; stderr happens to mention rate limit. Should NOT be + // classified transient because the documented contract is exit-1-only. + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + queue.close(); + + spawnMock.mockReturnValue(makeChild({ + exitCode: 143, + stderr: 'rate limit hit; aborting due to SIGTERM\n' + })); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messageEvents).toHaveLength(1); + expect(messageEvents[0].message).toContain('Agent exited (143)'); + expect(messageEvents[0].message).not.toContain('queued and will retry'); + }); + + it('preserves isolation when requeueing a slash command after a transient failure', async () => { + const queue = new MessageQueue2(() => 'm'); + // /compress is a pass-through slash command; enqueueCursorUserMessage uses + // pushIsolated for these so they never batch with sibling prompts. + queue.pushIsolated('/compress', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(2); + // Confirm the queue still flagged the requeued item as isolated + // (the second collectBatch saw it alone with isolate=true). + const secondPrompt = spawnMock.mock.calls[1]?.[1] as string[]; + const pIdx = secondPrompt.indexOf('-p'); + expect(secondPrompt[pIdx + 1]).toBe('/compress'); + }); + + it('drops the message after MAX_CONSECUTIVE_TRANSIENT_FAILURES consecutive transient failures', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call >= 5) { + queue.close(); + } + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(5); + + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + // 4 transient retry banners + 1 drop banner = 5 + expect(messageEvents).toHaveLength(5); + const banners = messageEvents.map((e: any) => e.message); + expect(banners.filter((m: string) => m.includes('queued and will retry'))).toHaveLength(4); + const drop = banners.find((m: string) => m.includes('5 times in a row')); + expect(drop).toBeDefined(); + expect(drop).toContain('Dropping the queued message'); }); }); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 5d24ab9d1..b8f80e856 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -11,6 +11,7 @@ import { type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; import type { CursorSession } from './session'; +import type { EnhancedMode } from './loop'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -19,6 +20,54 @@ import type { CursorStreamEvent } from './utils/cursorLegacyEventConverter'; import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorLegacyEventConverter'; import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; +// Transient `agent` failures (auth expiry, rate limits, transient network) come back +// as exit code 1 with a recognisable stderr signature. We requeue and retry instead +// of silently swallowing the user message. +const TRANSIENT_STDERR_PATTERN = /authentication required|please run ['"]?agent login['"]?|rate limit|ETIMEDOUT|ECONNRESET|EAI_AGAIN/i; +const AUTH_STDERR_PATTERN = /authentication required|please run ['"]?agent login['"]?/i; +const RATE_LIMIT_STDERR_PATTERN = /rate limit/i; +const DEFAULT_TRANSIENT_BACKOFF_MS = 2_000; +const MAX_CONSECUTIVE_TRANSIENT_FAILURES = 5; +const STDERR_DISPLAY_LIMIT = 400; +// In-memory stderr cap. Display only uses STDERR_DISPLAY_LIMIT chars; this is a +// safety bound so a chatty `agent` failure cannot balloon CLI process memory. +const STDERR_CAPTURE_LIMIT = 8_192; + +function getTransientBackoffMs(): number { + const raw = process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS; + if (raw === undefined) return DEFAULT_TRANSIENT_BACKOFF_MS; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 0) return DEFAULT_TRANSIENT_BACKOFF_MS; + return parsed; +} + +function isTransientAgentError(exitCode: number, stderr: string): boolean { + // Known transient failures (auth expiry, rate limit, transient network) + // all come back as exit code 1. Keep the retry path narrow to that contract; + // signal-kills (137 SIGKILL, 143 SIGTERM) and crashes (134 SIGABRT, etc.) + // should never auto-retry even if their stderr happens to contain a matching + // keyword. + return exitCode === 1 && TRANSIENT_STDERR_PATTERN.test(stderr); +} + +function truncateStderrForDisplay(stderr: string): string { + const trimmed = stderr.trim(); + if (!trimmed) return '(no stderr)'; + return trimmed.length > STDERR_DISPLAY_LIMIT + ? `${trimmed.slice(0, STDERR_DISPLAY_LIMIT)}...` + : trimmed; +} + +function friendlyTransientMessage(exitCode: number, stderr: string): string { + if (AUTH_STDERR_PATTERN.test(stderr)) { + return "Cursor authentication expired. Re-run 'agent login' or set CURSOR_API_KEY. Your message is queued and will retry automatically."; + } + if (RATE_LIMIT_STDERR_PATTERN.test(stderr)) { + return 'Cursor rate limit hit. Your message is queued and will retry automatically.'; + } + return `Cursor agent failed transiently (exit ${exitCode}). Your message is queued and will retry automatically.`; +} + function buildAgentArgs(opts: { message: string; cwd: string; @@ -57,6 +106,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; private abortController = new AbortController(); private displayPermissionMode: string | null = null; + private consecutiveTransientFailures = 0; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -102,7 +152,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { break; } - const { message, mode } = batch; + const { message, mode, isolate: batchIsolated } = batch; const specialCommand = parseCursorSpecialCommand(message); const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); @@ -128,7 +178,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { session.onThinkingChange(true); try { - const exitCode = await this.runAgentProcess(args, session.path, (event) => { + const { exitCode, stderr } = await this.runAgentProcess(args, session.path, (event) => { if (event.type === 'system' && event.subtype === 'init' && event.session_id) { cursorSessionId = event.session_id; session.onSessionFoundWithProtocol(event.session_id, 'stream-json'); @@ -162,11 +212,19 @@ class CursorRemoteLauncher extends RemoteLauncherBase { } }); - if (exitCode !== 0 && exitCode !== null) { - logger.debug(`[cursor-remote] Agent exited with code ${exitCode}`); - messageBuffer.addMessage(`Agent exited with code ${exitCode}`, 'status'); + if (exitCode === 0 || exitCode === null) { + this.consecutiveTransientFailures = 0; + } else if (isTransientAgentError(exitCode, stderr)) { + await this.handleTransientAgentFailure(exitCode, stderr, message, mode, batchIsolated); + } else { + this.consecutiveTransientFailures = 0; + const errMsg = `Agent exited (${exitCode}): ${truncateStderrForDisplay(stderr)}`; + logger.warn(`[cursor-remote] ${errMsg}`); + session.sendSessionEvent({ type: 'message', message: errMsg }); + messageBuffer.addMessage(errMsg, 'status'); } } catch (error) { + this.consecutiveTransientFailures = 0; logger.warn('[cursor-remote] Agent run failed', error); const errMsg = error instanceof Error ? error.message : String(error); session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); @@ -184,7 +242,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { args: string[], cwd: string, onEvent: (event: ReturnType & object) => void - ): Promise { + ): Promise<{ exitCode: number | null; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn('agent', args, { cwd, @@ -194,9 +252,11 @@ class CursorRemoteLauncher extends RemoteLauncherBase { windowsHide: process.platform === 'win32' }); + let stderrCapture = ''; + const abortHandler = () => { killProcessByChildProcess(child, false).catch(() => {}); - resolve(null); + resolve({ exitCode: null, stderr: stderrCapture }); }; this.abortController.signal.addEventListener('abort', abortHandler); @@ -209,9 +269,14 @@ class CursorRemoteLauncher extends RemoteLauncherBase { reject(err); }); - child.on('exit', (code, signal) => { + // `close` (not `exit`) waits for the stdio streams to flush before + // firing. Otherwise stderr from an agent that prints + exits quickly + // (e.g. "Authentication required" → exit 1) can arrive after we + // already classified the failure, turning a transient error into a + // dropped message. + child.on('close', (code, signal) => { cleanup(); - resolve(code); + resolve({ exitCode: code, stderr: stderrCapture }); }); const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); @@ -224,6 +289,9 @@ class CursorRemoteLauncher extends RemoteLauncherBase { child.stderr?.on('data', (chunk) => { const text = chunk.toString(); + if (stderrCapture.length < STDERR_CAPTURE_LIMIT) { + stderrCapture += text.slice(0, STDERR_CAPTURE_LIMIT - stderrCapture.length); + } if (text.trim()) { logger.debug('[cursor-remote] agent stderr:', text.trim()); } @@ -231,6 +299,78 @@ class CursorRemoteLauncher extends RemoteLauncherBase { }); } + private async handleTransientAgentFailure( + exitCode: number, + stderr: string, + message: string, + mode: EnhancedMode, + batchIsolated: boolean + ): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + this.consecutiveTransientFailures += 1; + + if (this.consecutiveTransientFailures >= MAX_CONSECUTIVE_TRANSIENT_FAILURES) { + const summary = truncateStderrForDisplay(stderr); + const dropMsg = `Cursor agent failed ${MAX_CONSECUTIVE_TRANSIENT_FAILURES} times in a row (${summary}). Dropping the queued message; resolve the issue ('agent login', wait out rate limit, etc.) and resend.`; + logger.warn( + `[cursor-remote] transient agent failures hit cap (${MAX_CONSECUTIVE_TRANSIENT_FAILURES}); dropping message`, + { exitCode, stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) } + ); + session.sendSessionEvent({ type: 'message', message: dropMsg }); + messageBuffer.addMessage(dropMsg, 'status'); + this.consecutiveTransientFailures = 0; + return; + } + + logger.warn( + '[cursor-remote] transient agent failure, requeueing user message', + { + exitCode, + attempt: this.consecutiveTransientFailures, + stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) + } + ); + // Preserve isolation when the original batch was isolated (e.g. a + // pass-through slash command queued via pushIsolated). Without this the + // requeued command could be batched with a sibling prompt on retry and + // change semantics. parseCursorSpecialCommand is the same gate + // enqueueCursorUserMessage uses to decide isolation in the first place. + const requeueIsolated = batchIsolated || parseCursorSpecialCommand(message).type !== null; + if (requeueIsolated) { + session.queue.unshiftIsolated(message, mode); + } else { + session.queue.unshift(message, mode); + } + const friendly = friendlyTransientMessage(exitCode, stderr); + session.sendSessionEvent({ type: 'message', message: friendly }); + messageBuffer.addMessage(friendly, 'status'); + await this.transientBackoff(getTransientBackoffMs()); + } + + private async transientBackoff(ms: number): Promise { + if (ms <= 0) return; + const signal = this.abortController.signal; + if (signal.aborted) return; + await new Promise((resolve) => { + let timer: ReturnType | null = null; + // Single completion path so the abort listener is always removed, + // whether the timer or the abort wins. Without this, repeated + // transient retries on the same AbortController accumulate stale + // listeners until the next abort fires them in bulk. + const finish = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener('abort', finish); + resolve(); + }; + timer = setTimeout(finish, ms); + signal.addEventListener('abort', finish, { once: true }); + }); + } + private applyDisplayMode(permissionMode: string | undefined): void { if (permissionMode && permissionMode !== this.displayPermissionMode) { this.displayPermissionMode = permissionMode; diff --git a/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts b/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts index a4d7840c5..1f89a9224 100644 --- a/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts +++ b/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts @@ -1,16 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { parseCursorEvent, convertCursorEventToAgentMessage, - __resetCursorEventConverterStateForTests, type CursorStreamEvent } from './cursorLegacyEventConverter'; describe('cursorLegacyEventConverter', () => { - beforeEach(() => { - __resetCursorEventConverterStateForTests(); - }); - describe('parseCursorEvent', () => { it('parses system init event', () => { const line = @@ -64,7 +59,7 @@ describe('cursorLegacyEventConverter', () => { expect(msg).toEqual({ type: 'turn_complete', stopReason: 'success' }); }); - it('passes through a normal tool result unchanged (read_file)', () => { + it('passes through a normal read_file result unchanged', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', @@ -88,16 +83,7 @@ describe('cursorLegacyEventConverter', () => { }); describe('#784 transitional safety: AskQuestion synthetic-skip intercept', () => { - it('rewrites a tool_call result containing the synthetic skip string to a no_input_surface failure', () => { - const startedEvent = { - type: 'tool_call', - subtype: 'started', - call_id: 'q1', - session_id: 's1', - tool_call: { function: { name: 'AskQuestion', arguments: '{"q":"..."}' } } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - + it('rewrites a function-shaped AskQuestion whose result contains the synthetic marker', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', @@ -124,7 +110,7 @@ describe('cursorLegacyEventConverter', () => { expect(output.message).toMatch(/Re-prompt in plain text/); }); - it('matches the synthetic-skip string even when nested deep inside the tool_call payload', () => { + it('matches the synthetic-skip string even when nested deep inside the function payload', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', @@ -145,59 +131,30 @@ describe('cursorLegacyEventConverter', () => { expect(msg).toMatchObject({ type: 'tool_result', id: 'q2', status: 'failed' }); }); - it('rewrites a sub-500ms AskQuestion completion with a trivial result even without the synthetic string', () => { - const startedEvent = { - type: 'tool_call', - subtype: 'started', - call_id: 'q3', - session_id: 's1', - tool_call: { function: { name: 'AskQuestion', arguments: '{}' } } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - + // The marker is detected on the raw `tool_call` payload, not on + // `extractToolResult`'s output. That matters for stream-json shapes + // the converter labels `name=unknown` (notably the `toolu_vrtx_*` + // Anthropic Vertex tool calls) where extractToolResult returns `{}` + // and would otherwise hide the marker. + it('catches the marker in a name=unknown tool whose extractToolResult would return {}', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', call_id: 'q3', session_id: 's1', - tool_call: { function: { name: 'AskQuestion', arguments: '{}' } } + tool_call: { + id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', + fabricated_response: { + text: 'Questions skipped by the user, continue with the information you already have' + } + } } as CursorStreamEvent; const msg = convertCursorEventToAgentMessage(completedEvent); expect(msg).toMatchObject({ type: 'tool_result', id: 'q3', status: 'failed' }); expect((msg as { output: { kind: string } }).output.kind).toBe('no_input_surface'); }); - it('rewrites a sub-500ms completion when the converter falls back to name=unknown', () => { - const startedEvent = { - type: 'tool_call', - subtype: 'started', - call_id: 'q4', - session_id: 's1', - tool_call: { function: {} } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - - const completedEvent = { - type: 'tool_call', - subtype: 'completed', - call_id: 'q4', - session_id: 's1', - tool_call: { function: {} } - } as CursorStreamEvent; - const msg = convertCursorEventToAgentMessage(completedEvent); - expect(msg).toMatchObject({ type: 'tool_result', id: 'q4', status: 'failed' }); - }); - it('does NOT rewrite a normal function-tool completion with a real result', () => { - const startedEvent = { - type: 'tool_call', - subtype: 'started', - call_id: 'r2', - session_id: 's1', - tool_call: { function: { name: 'MyCustomTool', arguments: '{}' } } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - const completedEvent = { type: 'tool_call', subtype: 'completed', @@ -224,11 +181,11 @@ describe('cursorLegacyEventConverter', () => { expect(msg).toMatchObject({ type: 'tool_result', id: 'rw1', status: 'completed' }); }); - // Regression test for the false positive flagged on PR #801 by the - // HAPI auto-review bot: this PR adds the literal synthetic-skip - // marker to docs/guide/cursor.md, so a Cursor read_file of that - // file would surface the marker inside readToolCall.result.content. - // The intercept must NOT rewrite that as a no_input_surface failure. + // Regression test: docs/guide/cursor.md contains the literal + // synthetic-skip marker, so a Cursor read_file of that file would + // surface the marker inside readToolCall.result.content. The + // intercept must NOT rewrite that as a no_input_surface failure + // because the gate excludes read_file tool calls. it('does NOT rewrite a read_file result whose content contains the synthetic marker', () => { const completedEvent = { type: 'tool_call', @@ -284,33 +241,14 @@ describe('cursorLegacyEventConverter', () => { }); }); - // Regression test for the second Major finding from the HAPI bot on - // PR #801: a legitimate AskQuestion whose prompt text (carried in - // `function.arguments`) quotes the synthetic-skip marker - e.g. an - // agent debugging this exact bug - must NOT be rewritten when the - // user has actually answered. The marker check must look only at the - // extracted result, never the agent's input arguments. - it('does NOT rewrite an AskQuestion whose arguments quote the marker but whose result is a real answer', async () => { - const startedEvent = { - type: 'tool_call', - subtype: 'started', - call_id: 'meta1', - session_id: 's1', - tool_call: { - function: { - name: 'AskQuestion', - arguments: - '{"prompt":"Do you want to handle the case where cursor-agent returns: Questions skipped by the user, continue with the information you already have"}' - } - } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - - // Wait past the synthetic latency threshold so the timing - // heuristic does not apply - this is a real user answer, not a - // zero-latency fabrication. - await new Promise((resolve) => setTimeout(resolve, 550)); - + // Regression for the second Major finding from the HAPI bot on + // PR #801: a legitimate AskQuestion whose prompt text (carried + // in `function.arguments`) quotes the synthetic-skip marker - + // e.g. an agent debugging this exact bug - must NOT be rewritten + // when the user has actually answered. The marker scan must look + // only at the response portion of the tool_call, never at the + // agent's input arguments. + it('does NOT rewrite an AskQuestion whose arguments quote the marker but whose result is a real answer', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', @@ -332,9 +270,6 @@ describe('cursorLegacyEventConverter', () => { status: 'completed' }); expect((msg as { output: unknown }).output).toBe('yes, please add the intercept'); - expect((msg as { output: unknown }).output).not.toMatchObject({ - kind: 'no_input_surface' - }); }); it('does NOT rewrite a non-AskQuestion function tool whose result happens to contain the marker text', () => { @@ -360,27 +295,115 @@ describe('cursorLegacyEventConverter', () => { }); }); - it('does NOT rewrite an AskQuestion completion that took longer than the synthetic threshold', async () => { - const startedEvent = { + // Regression for the real-traffic false positives observed on PR #801 + // (see https://github.com/tiann/hapi/issues/784 follow-up data): a + // legacy stream-json session carrying Anthropic Vertex Claude tool + // calls surfaces every one of them as `name=unknown` with an empty + // extracted result. The earlier timing-only defense-in-depth + // rewrote those as `no_input_surface` failures. The marker-only + // path must let them pass through normally. + it('does NOT rewrite a fast name=unknown tool call that lacks the synthetic marker', () => { + const completedEvent = { type: 'tool_call', - subtype: 'started', - call_id: 'q5', + subtype: 'completed', + call_id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', session_id: 's1', - tool_call: { function: { name: 'AskQuestion', arguments: '{}' } } + tool_call: { + id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', + name: 'TodoWrite', + input: { todos: [] } + } } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); + const msg = convertCursorEventToAgentMessage(completedEvent); + expect(msg).toMatchObject({ + type: 'tool_result', + id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', + status: 'completed' + }); + expect((msg as { output: unknown }).output).not.toMatchObject({ + kind: 'no_input_surface' + }); + }); - await new Promise((resolve) => setTimeout(resolve, 550)); + // Regression for the Codex P2 finding on the fork-stage review + // of this PR (heavygee/hapi#35): an Anthropic tool_use shape + // `{id, name, input, ...}` with a recognizable top-level `name` + // must be gate-rejected by the AskQuestion-name set, AND its + // agent-controlled `input` field must be excluded from the + // marker scan even if the gate were to pass. Concrete case: + // an agent debugging or documenting this very bug whose + // TodoWrite payload quotes the synthetic-skip marker verbatim. + it('does NOT rewrite an Anthropic tool_use shape whose input quotes the marker (Codex P2 regression)', () => { + const completedEvent = { + type: 'tool_call', + subtype: 'completed', + call_id: 'toolu_vrtx_meta', + session_id: 's1', + tool_call: { + id: 'toolu_vrtx_meta', + name: 'TodoWrite', + input: { + todos: [ + { + content: + 'Document Questions skipped by the user, continue with the information you already have' + } + ] + } + } + } as CursorStreamEvent; + const msg = convertCursorEventToAgentMessage(completedEvent); + expect(msg).toMatchObject({ + type: 'tool_result', + id: 'toolu_vrtx_meta', + status: 'completed' + }); + expect((msg as { output: unknown }).output).not.toMatchObject({ + kind: 'no_input_surface' + }); + }); + // Defense-in-depth companion to the above: even if a tool shape + // somehow reached this code path with `name=unknown` (no top- + // level name field) and the marker buried inside its `input`, + // the AGENT_INPUT_KEYS exclusion must still suppress the + // rewrite - the marker only counts as fabricated when it lives + // outside agent-controlled input fields. + it('does NOT rewrite a name=unknown shape whose marker lives only inside agent input', () => { const completedEvent = { type: 'tool_call', subtype: 'completed', - call_id: 'q5', + call_id: 'input1', + session_id: 's1', + tool_call: { + id: 'toolu_vrtx_input', + input: { + prompt: + 'Quoting bug: Questions skipped by the user, continue with the information you already have' + } + } + } as CursorStreamEvent; + const msg = convertCursorEventToAgentMessage(completedEvent); + expect(msg).toMatchObject({ + type: 'tool_result', + id: 'input1', + status: 'completed' + }); + }); + + it('does NOT rewrite an empty function-shaped AskQuestion that lacks the marker', () => { + const completedEvent = { + type: 'tool_call', + subtype: 'completed', + call_id: 'q4', session_id: 's1', tool_call: { function: { name: 'AskQuestion', arguments: '{}' } } } as CursorStreamEvent; const msg = convertCursorEventToAgentMessage(completedEvent); - expect(msg).toMatchObject({ type: 'tool_result', id: 'q5', status: 'completed' }); + expect(msg).toMatchObject({ type: 'tool_result', id: 'q4', status: 'completed' }); + expect((msg as { output: unknown }).output).not.toMatchObject({ + kind: 'no_input_surface' + }); }); }); }); diff --git a/cli/src/cursor/utils/cursorLegacyEventConverter.ts b/cli/src/cursor/utils/cursorLegacyEventConverter.ts index 3876ebeb6..125165878 100644 --- a/cli/src/cursor/utils/cursorLegacyEventConverter.ts +++ b/cli/src/cursor/utils/cursorLegacyEventConverter.ts @@ -1,6 +1,13 @@ /** * Converts Cursor Agent stream-json events to HAPI AgentMessage format. * Cursor emits NDJSON: system/init, thinking, assistant, tool_call, result. + * + * This legacy converter only runs for cursor sessions created before the + * ACP migration (#799). New cursor remote sessions go through + * cursorAcpBackend, which handles AskQuestion via the bidirectional + * `cursor/ask_question` ACP extension method and is immune to the #784 + * fabrication. The intercept below exists for legacy resumed sessions + * only and removes itself when those sessions drain. */ import type { AgentMessage } from '@/agent/types'; @@ -56,6 +63,11 @@ function extractToolName(toolCall: Record): string { const fn = toolCall.function as Record; return typeof fn.name === 'string' ? fn.name : 'unknown'; } + // Anthropic tool_use shape (Vertex Claude routes through cursor-agent + // in legacy stream-json mode): {id, name, input, ...}. Surface the + // top-level name so the #784 intercept's gate can distinguish a real + // TodoWrite/Bash/etc. from a truly opaque shape. + if (typeof toolCall.name === 'string') return toolCall.name; return 'unknown'; } @@ -86,13 +98,11 @@ function extractToolResult(toolCall: Record): unknown { } if (toolCall.function && typeof toolCall.function === 'object') { const fn = toolCall.function as Record; - // Cursor's stream-json function-shaped tool calls put the agent's - // input in `arguments` and the cursor-side response in other fields. - // Surface the response (preferring `result` when present, otherwise - // everything except `name` / `arguments`) so downstream callers don't - // lose the cursor-side payload to a `{}` fallback. Excluding - // `arguments` matters for the #784 intercept: the agent's own prompt - // text must not be searched for the synthetic-skip marker. + // Function-shaped tool calls put the agent's input in `arguments` + // and the cursor-side response in other fields. Surface the + // response (preferring `result` when present, otherwise everything + // except `name` / `arguments`) so downstream callers see the + // cursor-side payload instead of an opaque `{}` placeholder. if (fn.result !== undefined) return fn.result; const rest: Record = {}; for (const [k, v] of Object.entries(fn)) { @@ -105,20 +115,41 @@ function extractToolResult(toolCall: Record): unknown { } /** - * Transitional safety patch for tiann/hapi#784. + * Transitional safety intercept for tiann/hapi#784. + * + * cursor-agent in headless `--print --output-format stream-json` mode + * fabricates the literal SYNTHETIC_SKIP_MARKER string below as the + * AskQuestion tool result, with no error flag and in ~zero seconds, + * because there is no IDE surface to render the question. The agent's + * model then treats this as legitimate user consent and acts on it. + * + * HAPI's legacy converter (this file) rewrites the result to a + * structured `no_input_surface` failure so downstream consumers (web + * UI, Telegram, log readers) surface the fabrication as an error + * instead of silently passing through fabricated consent. * - * cursor-agent in headless `--print --output-format stream-json` mode fabricates - * the following literal string as the AskQuestion tool result, with no error - * flag and in ~zero seconds, because there is no IDE surface to render the - * question. The agent's model then treats this as legitimate user consent. + * Scope: legacy stream-json sessions only. New cursor remote sessions + * go through cursorAcpBackend with the proper `cursor/ask_question` + * ACP method and never hit this code. The intercept drains with the + * legacy session population. * - * HAPI intercepts the synthetic string at the converter layer and rewrites the - * tool result to a structured `no_input_surface` failure, so downstream agents - * see an explicit error instead of fabricated consent. + * Detection rules: + * 1. Tool name resolves to an AskQuestion-shaped call (explicit + * `AskQuestion` / `ask_question` / `askQuestion`) or the + * converter's `unknown` fallback (cursor's stream-json drops the + * AskQuestion name in some configurations - see #784 issue body). + * 2. The literal SYNTHETIC_SKIP_MARKER appears in the *response* + * portion of the raw tool_call payload. For function-shaped + * tools, the response excludes `function.arguments` (agent + * input), so a legitimate AskQuestion whose prompt quotes the + * marker (e.g. debugging this exact bug) is not rewritten. * - * This patch is intentionally scoped to the stream-json launcher and - * auto-deletes when tiann/hapi#781 (ACP migration) replaces stream-json with - * the proper bidirectional `cursor/ask_question` ACP method. + * The earlier timing-signature defense-in-depth (rewrite any sub-500ms + * AskQuestion-shaped completion with a trivial result) was removed in + * a follow-up: in real legacy traffic it fires on Anthropic Vertex + * Claude tool calls (whose `toolu_vrtx_*` shape the converter labels + * `name=unknown` and whose extracted result is the `{}` fallback) and + * caught no actual fabrications. The marker-only path is sufficient. */ const SYNTHETIC_SKIP_MARKER = 'Questions skipped by the user, continue with the information you already have'; @@ -129,56 +160,16 @@ const NO_INPUT_SURFACE_OUTPUT = { 'cursor-agent fabricated a skip response in headless mode. The operator did not respond. Re-prompt in plain text and wait for a real user message before proceeding.' } as const; -/** - * Names cursor-agent has been observed to use (or fall back to) for the - * AskQuestion tool when its result reaches the stream-json converter. - */ -const ASK_QUESTION_TOOL_NAMES = new Set(['AskQuestion', 'askQuestion', 'ask_question', 'unknown']); - -/** - * Defense-in-depth latency threshold. A real interactive answer cannot arrive - * faster than this; cursor-agent's fabricated skip arrives in ~0 ms. - */ -const SYNTHETIC_LATENCY_THRESHOLD_MS = 500; - -/** - * Tracks when each tool_call 'started' event arrived, keyed by call_id. - * Used to detect zero-latency fabricated AskQuestion completions even if the - * synthetic-string text changes in a future cursor-agent release. - * - * Bounded to prevent unbounded growth if 'completed' events are ever missed. - */ -const TOOL_CALL_STARTED_MAX = 1024; -const toolCallStartedAt = new Map(); - -function rememberToolCallStart(callId: string, now: number = Date.now()): void { - if (toolCallStartedAt.size >= TOOL_CALL_STARTED_MAX) { - const oldest = toolCallStartedAt.keys().next().value; - if (typeof oldest === 'string') { - toolCallStartedAt.delete(oldest); - } - } - toolCallStartedAt.set(callId, now); -} - -function takeToolCallElapsedMs(callId: string, now: number = Date.now()): number | null { - const started = toolCallStartedAt.get(callId); - if (started === undefined) return null; - toolCallStartedAt.delete(callId); - return now - started; -} +const ASK_QUESTION_TOOL_NAMES = new Set([ + 'AskQuestion', + 'askQuestion', + 'ask_question', + 'unknown' +]); /** - * Recursively checks every string-typed value reachable from `value` for the - * synthetic-skip marker. Used instead of `JSON.stringify(...).includes(...)` so - * the intercept does not false-positive on legitimate tool results that happen - * to quote the marker text (notably a `read_file` of `docs/guide/cursor.md`, - * which documents this exact intercept). - * - * Guards against cycles via a visited-set; the cycle case in practice is - * vanishingly rare on stream-json payloads (parsed from JSON.parse) but the - * guard is cheap and removes any tail risk if a future caller hands us a - * non-tree object graph. + * Recursively checks every string reachable from `value` for the + * synthetic-skip marker. Guards against cycles via a visited-set. */ function containsSyntheticSkipMarker(value: unknown, seen: WeakSet = new WeakSet()): boolean { if (typeof value === 'string') { @@ -197,53 +188,56 @@ function containsSyntheticSkipMarker(value: unknown, seen: WeakSet = new return false; } -function isTrivialResult(result: unknown): boolean { - if (result === null || result === undefined) return true; - if (typeof result === 'string') return result.trim().length === 0; - if (typeof result === 'object') { - return Object.keys(result as Record).length === 0; - } - return false; -} +/** + * Field names that carry agent-controlled tool input across the shapes + * the legacy converter encounters. These must never be marker-scanned - + * the agent's own prompt / TodoWrite payload / etc. can legitimately + * quote the synthetic-skip marker (notably when an agent is debugging + * or documenting this very bug). The marker is a fabrication signal + * only when it appears in the *response* portion of a tool_call. + */ +const AGENT_INPUT_KEYS = new Set(['input', 'args', 'arguments']); -function shouldRewriteAsNoInputSurface(opts: { - name: string; - result: unknown; - elapsedMs: number | null; -}): boolean { - // Gate on the tool name resolving to an AskQuestion-shaped call (or the - // converter's `unknown` fallback for function-shaped tools without a - // name). Prevents legitimate `read_file` / `write_file` results that - // contain the literal marker string (e.g. reading this repo's - // `docs/guide/cursor.md`, which documents the intercept) from being - // rewritten as `no_input_surface` failures. - if (!ASK_QUESTION_TOOL_NAMES.has(opts.name)) { +/** + * Scans the raw `tool_call` payload for the synthetic-skip marker in + * the response portion only. Agent-controlled input fields are excluded + * for every shape: + * - function-shaped: skip `function.arguments` + * - everything else (including Anthropic tool_use `{id, name, input, ...}` + * and legacy read/write shapes that still carry `args`): skip + * `input` / `args` / `arguments` at the top level. + * + * Operates on the raw `tool_call` rather than `extractToolResult`'s + * output because the latter returns `{}` for tool shapes the converter + * does not recognize (notably the `toolu_vrtx_*` Anthropic Vertex tool + * calls cursor-agent surfaces in legacy stream-json mode), discarding + * the marker before it can be checked. + */ +function findMarkerInToolCallResponse(toolCall: Record): boolean { + if (toolCall.function && typeof toolCall.function === 'object') { + const fn = toolCall.function as Record; + for (const [k, v] of Object.entries(fn)) { + if (k === 'arguments') continue; + if (containsSyntheticSkipMarker(v)) return true; + } + for (const [k, v] of Object.entries(toolCall)) { + if (k === 'function') continue; + if (containsSyntheticSkipMarker(v)) return true; + } return false; } - // Search only the extracted result, not the whole tool_call payload. The - // `arguments` field carries the agent's own prompt text, which can quote - // the marker without that being a fabricated skip; matching there would - // false-positive on legitimate AskQuestion calls that ask about this - // exact bug or paste the marker verbatim into their prompt. - if (containsSyntheticSkipMarker(opts.result)) { - return true; - } - if ( - opts.elapsedMs !== null && - opts.elapsedMs < SYNTHETIC_LATENCY_THRESHOLD_MS && - isTrivialResult(opts.result) - ) { - return true; + for (const [k, v] of Object.entries(toolCall)) { + if (AGENT_INPUT_KEYS.has(k)) continue; + if (containsSyntheticSkipMarker(v)) return true; } return false; } -/** - * Test-only hook to reset the timing tracker between test cases. Not exported - * from the package surface; consumed by the colocated test file. - */ -export function __resetCursorEventConverterStateForTests(): void { - toolCallStartedAt.clear(); +function shouldRewriteAsNoInputSurface(name: string, toolCall: Record): boolean { + if (!ASK_QUESTION_TOOL_NAMES.has(name)) { + return false; + } + return findMarkerInToolCallResponse(toolCall); } export function convertCursorEventToAgentMessage(event: CursorStreamEvent): AgentMessage | null { @@ -261,7 +255,6 @@ export function convertCursorEventToAgentMessage(event: CursorStreamEvent): Agen const name = extractToolName(toolCall); const input = extractToolInput(toolCall); if (event.subtype === 'started') { - rememberToolCallStart(event.call_id); return { type: 'tool_call', id: event.call_id, @@ -270,9 +263,7 @@ export function convertCursorEventToAgentMessage(event: CursorStreamEvent): Agen status: 'in_progress' }; } - const result = extractToolResult(toolCall); - const elapsedMs = takeToolCallElapsedMs(event.call_id); - if (shouldRewriteAsNoInputSurface({ name, result, elapsedMs })) { + if (shouldRewriteAsNoInputSurface(name, toolCall)) { return { type: 'tool_result', id: event.call_id, @@ -280,6 +271,7 @@ export function convertCursorEventToAgentMessage(event: CursorStreamEvent): Agen status: 'failed' }; } + const result = extractToolResult(toolCall); return { type: 'tool_result', id: event.call_id, diff --git a/cli/src/modules/common/cursorModels.test.ts b/cli/src/modules/common/cursorModels.test.ts index 4fc2576d5..5fd6ecbc6 100644 --- a/cli/src/modules/common/cursorModels.test.ts +++ b/cli/src/modules/common/cursorModels.test.ts @@ -34,6 +34,7 @@ import { } from './cursorModelsSharedCache'; import { _resetCursorModelsCacheForTests, + buildCursorModelsSeedPayload, listCursorModels, parseCursorModelsOutput, seedCursorModelsCache @@ -48,6 +49,33 @@ afterEach(() => { acpProbeMock.runCursorAcpModelProbe.mockReset() }) +describe('buildCursorModelsSeedPayload', () => { + test('inherits cliModelSkus from shared cache when ACP snapshot has none', () => { + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' } + ] + }) + + const seeded = buildCursorModelsSeedPayload( + { + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }, + readSharedCursorModelsCache() + ) + + expect(seeded.cliModelSkus?.map((row) => row.modelId)).toEqual([ + 'gpt-5.5-medium', + 'gpt-5.5-high' + ]) + }) +}) + describe('parseCursorModelsOutput', () => { test('parses Cursor agent model list output', () => { const result = parseCursorModelsOutput(` @@ -153,6 +181,107 @@ describe('listCursorModels', () => { expect(readSharedCursorModelsCache()?.currentModelId).toBe('composer-2.5[fast=true]') }) + test('unions partial response cliModelSkus with fuller shared cache while lock is active', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' } + ] + } as never) + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' } + ] + }) + + const result = await listCursorModels() + + expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([ + 'gpt-5.5-high-fast', + 'gpt-5.5-low', + 'gpt-5.5-medium', + 'gpt-5.5-high' + ]) + expect(spawnMock).not.toHaveBeenCalled() + }) + + test('enriches live ACP snapshot with fuller shared cliModelSkus while lock is active', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' } + ] + }) + + const result = await listCursorModels() + + expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([ + 'gpt-5.5-high-fast', + 'gpt-5.5-low', + 'gpt-5.5-medium', + 'gpt-5.5-high' + ]) + expect(spawnMock).not.toHaveBeenCalled() + }) + + test('unions shared partial cliModelSkus with probe results when lock is inactive', async () => { + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' } + ] + }) + spawnMock.mockImplementation(() => ({ + stdout: { + on: vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + handler(Buffer.from( + 'gpt-5.5-high-fast - GPT-5.5 High Fast\n' + + 'gpt-5.5-high - GPT-5.5 High\n' + )); + } + }) + }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, handler: (code: number) => void) => { + if (event === 'exit') { + setTimeout(() => handler(0), 0); + } + }), + kill: vi.fn() + })); + + const result = await listCursorModels(); + + expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([ + 'gpt-5.5-medium', + 'gpt-5.5-high-fast', + 'gpt-5.5-high' + ]); + expect(spawnMock).toHaveBeenCalled(); + }); + test('prefers ACP wire probe over CLI slug probe when cache is empty', async () => { acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ success: true, @@ -216,6 +345,21 @@ describe('listCursorModels', () => { }); }); + test('skips CLI slug probe when ACP lock is active after ACP probe', async () => { + vi.mocked(isAgentAcpTransportActive) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ + success: false, + error: 'no wires' + }); + + const result = await listCursorModels(); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(result).toEqual({ success: false, error: 'no wires' }); + }); + test('prefers live ACP snapshot over cache while ACP transport is active', async () => { vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) seedCursorModelsCache({ diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index d61d46416..14e72851e 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -18,6 +18,44 @@ import { runCursorAcpModelProbe } from './cursorAcpModelProbe'; +export function buildCursorModelsSeedPayload( + snapshot: { + availableModels: CursorModelSummary[]; + currentModelId: string | null; + cliModelSkus?: readonly CursorModelSummary[]; + }, + shared?: CursorModelsResponse | null +): ListCursorModelsResponse { + const cliModelSkus = mergeCliModelSkus( + snapshot.cliModelSkus ?? [], + shared?.cliModelSkus ?? [] + ); + return { + success: true, + availableModels: snapshot.availableModels, + currentModelId: snapshot.currentModelId, + ...(cliModelSkus.length > 0 ? { cliModelSkus } : {}) + }; +} + +export function mergeCliModelSkus( + ...lists: readonly (readonly CursorModelSummary[])[] +): CursorModelSummary[] { + const merged = new Map(); + for (const list of lists) { + for (const entry of list) { + const modelId = entry.modelId.trim(); + if (!modelId) { + continue; + } + if (!merged.has(modelId)) { + merged.set(modelId, entry); + } + } + } + return [...merged.values()]; +} + function filterCliSkusForWireBases( cliSkus: CursorModelSummary[], wires: CursorModelSummary[] @@ -39,44 +77,47 @@ function attachCliSkusToResponse( response: ListCursorModelsResponse, cliSkus: readonly CursorModelSummary[] ): ListCursorModelsResponse { - if ((response.cliModelSkus?.length ?? 0) > 0) { - return response; - } - const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId)); const filtered = filterCliSkusForWireBases([...cliSkus], wires); - return filtered.length > 0 ? { ...response, cliModelSkus: filtered } : response; + const merged = mergeCliModelSkus(response.cliModelSkus ?? [], filtered); + if (merged.length === 0) { + return response; + } + if (merged.length === (response.cliModelSkus?.length ?? 0)) { + return response; + } + return { ...response, cliModelSkus: merged }; } async function enrichCursorModelsWithCliSkus( response: ListCursorModelsResponse ): Promise { - if ((response.cliModelSkus?.length ?? 0) > 0) { - return response; - } - const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId)); if (wires.length === 0) { return response; } + const candidates: CursorModelSummary[] = []; const shared = readSharedCursorModelsCache(); if (shared?.cliModelSkus?.length) { - return attachCliSkusToResponse(response, shared.cliModelSkus); + candidates.push(...shared.cliModelSkus); } // Never spawn `agent --list-models` while an ACP session holds the CLI lock. - if (isAgentAcpTransportActive()) { - return response; + if (!isAgentAcpTransportActive()) { + try { + const probe = await runCursorModelProbe(); + candidates.push(...(probe.availableModels ?? [])); + } catch { + // Keep partial candidates from shared cache. + } } - try { - const probe = await runCursorModelProbe(); - const cliSkus = filterCliSkusForWireBases(probe.availableModels ?? [], wires); - return cliSkus.length > 0 ? { ...response, cliModelSkus: cliSkus } : response; - } catch { + if (candidates.length === 0) { return response; } + + return attachCliSkusToResponse(response, candidates); } export type ListCursorModelsResponse = CursorModelsResponse; @@ -134,6 +175,10 @@ export function parseCursorModelsOutput(output: string): { } async function runCursorModelProbe(): Promise { + if (isAgentAcpTransportActive()) { + throw new Error('Cursor ACP transport is active'); + } + return await new Promise((resolve, reject) => { const child = spawn('agent', ['--list-models'], { env: process.env, @@ -204,7 +249,10 @@ async function listCursorModelsWhileAcpActive(): Promise Date.now() && (cache.response.availableModels?.length ?? 0) > 0) { const shared = readSharedCursorModelsCache(); - const cachedSkus = cache.response.cliModelSkus ?? shared?.cliModelSkus ?? []; + const cachedSkus = mergeCliModelSkus( + cache.response.cliModelSkus ?? [], + shared?.cliModelSkus ?? [] + ); return attachCliSkusToResponse(cache.response, cachedSkus); } return { success: true, availableModels: [], currentModelId: null }; @@ -240,9 +288,12 @@ export async function listCursorModels(): Promise { return applyInMemoryCache(acpResponse); } - const probeResponse = await runCursorModelProbe(); - if (cursorProbeResponseHasWireCatalog(probeResponse)) { - return applyInMemoryCache(probeResponse); + let probeResponse: ListCursorModelsResponse | null = null; + if (!isAgentAcpTransportActive()) { + probeResponse = await runCursorModelProbe(); + if (cursorProbeResponseHasWireCatalog(probeResponse)) { + return applyInMemoryCache(probeResponse); + } } // CLI `--list-models` returns slug ids without bracket params; never cache @@ -250,9 +301,10 @@ export async function listCursorModels(): Promise { if (acpResponse.success) { return acpResponse; } - return probeResponse.success - ? { success: true, availableModels: [], currentModelId: null } - : probeResponse; + if (probeResponse?.success) { + return { success: true, availableModels: [], currentModelId: null }; + } + return probeResponse ?? acpResponse; } catch (error) { return { success: false, @@ -267,6 +319,9 @@ export async function listCursorModels(): Promise { } export function seedCursorModelsCache(response: ListCursorModelsResponse): void { + if ((response.availableModels?.length ?? 0) > 0) { + writeSharedCursorModelsCache(response); + } void applyInMemoryCache(response); } diff --git a/cli/src/modules/common/cursorModelsPrewarm.test.ts b/cli/src/modules/common/cursorModelsPrewarm.test.ts new file mode 100644 index 000000000..a985fe139 --- /dev/null +++ b/cli/src/modules/common/cursorModelsPrewarm.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const listCursorModelsMock = vi.hoisted(() => vi.fn()); + +vi.mock('./cursorModels', () => ({ + listCursorModels: listCursorModelsMock +})); + +import { scheduleCursorModelsPrewarm } from './cursorModelsPrewarm'; + +afterEach(() => { + listCursorModelsMock.mockReset(); +}); + +describe('scheduleCursorModelsPrewarm', () => { + test('starts a background listCursorModels call', async () => { + listCursorModelsMock.mockResolvedValue({ + success: true, + availableModels: [], + currentModelId: null + }); + + scheduleCursorModelsPrewarm(); + + await Promise.resolve(); + + expect(listCursorModelsMock).toHaveBeenCalledTimes(1); + }); + + test('swallows listCursorModels failures', async () => { + listCursorModelsMock.mockRejectedValue(new Error('agent missing')); + + scheduleCursorModelsPrewarm(); + + await Promise.resolve(); + + expect(listCursorModelsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cli/src/modules/common/cursorModelsPrewarm.ts b/cli/src/modules/common/cursorModelsPrewarm.ts new file mode 100644 index 000000000..7ebd6ed3a --- /dev/null +++ b/cli/src/modules/common/cursorModelsPrewarm.ts @@ -0,0 +1,9 @@ +import { logger } from '@/ui/logger'; +import { listCursorModels } from './cursorModels'; + +/** Background fill of shared cursor-models cache; does not block runner startup. */ +export function scheduleCursorModelsPrewarm(): void { + void listCursorModels().catch((error) => { + logger.debug('[RUNNER RUN] Cursor model pre-warm failed', error); + }); +} diff --git a/cli/src/modules/common/cursorModelsSharedCache.test.ts b/cli/src/modules/common/cursorModelsSharedCache.test.ts index 90006a5e3..db8e1ae83 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.test.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.test.ts @@ -26,4 +26,19 @@ describe('cursorModelsSharedCache', () => { writeSharedCursorModelsCache({ success: true, availableModels: [], currentModelId: null }); expect(readSharedCursorModelsCache()).toBeNull(); }); + + test('round-trips cliModelSkus with wire catalog', () => { + const payload = { + success: true as const, + availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cliModelSkus: [ + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' } + ] + }; + + writeSharedCursorModelsCache(payload); + + expect(readSharedCursorModelsCache()?.cliModelSkus).toEqual(payload.cliModelSkus); + }); }); diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 3f276cf3d..51b6a974f 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -24,6 +24,7 @@ import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken } from './runnerIdentity'; +import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise { // We don't have cleanup function at the time of server construction @@ -720,6 +721,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Connect to server apiMachine.connect(); + scheduleCursorModelsPrewarm(); // Visible startup banner. Use console.log so it always appears on stdout, // regardless of the verbose/quiet logger setting. diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index fea790303..54d0e5a3d 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -219,6 +219,42 @@ export class MessageQueue2 { logger.debug(`[MessageQueue2] unshift() completed. Queue size: ${this.queue.length}`); } + /** + * Push a message to the beginning of the queue with isolation preserved. + * Mirrors `pushIsolated` but inserts at the head. Use this when requeueing a + * batch that was originally collected under isolation (e.g. a slash command + * that failed transiently and must retry without batching against sibling + * prompts). + */ + unshiftIsolated(message: string, mode: T, localId?: string): void { + if (this.closed) { + throw new Error('Cannot unshift to closed queue'); + } + + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] unshiftIsolated() called with mode hash: ${modeHash}`); + + this.queue.unshift({ + message, + mode, + modeHash, + localId, + isolate: true + }); + + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + + if (this.waiter) { + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + + logger.debug(`[MessageQueue2] unshiftIsolated() completed. Queue size: ${this.queue.length}`); + } + /** * Remove the first queued message that matches the given localId. * Returns true if a message was removed, false if not found. diff --git a/docs/guide/cursor.md b/docs/guide/cursor.md index ca919d511..c7ab12383 100644 --- a/docs/guide/cursor.md +++ b/docs/guide/cursor.md @@ -48,13 +48,15 @@ Set mode via `--mode` flag or change from the web UI during a session. - **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. -### Headless safety: AskQuestion behavior +### Legacy stream-json safety: AskQuestion behavior -When running cursor-agent under `--print --output-format stream-json` (HAPI's current remote mode), the cursor-agent CLI returns a synthetic `Questions skipped by the user, continue with the information you already have` response for the `AskQuestion` tool because there is no IDE surface to render the question. The agent's underlying model can interpret this as legitimate user consent and act on it. +New cursor remote sessions go through ACP, which handles `AskQuestion` via the bidirectional `cursor/ask_question` extension method and is immune to the issue below. The intercept described here exists only for legacy sessions that resume via the older `agent -p` stream-json launcher. -HAPI intercepts this synthetic response in the stream-json event converter and rewrites it to an explicit `no_input_surface` error (`is_error: true`), so agents do not act on fabricated user consent. Defense-in-depth: any `AskQuestion` (or `name=unknown`) tool completion that arrives within ~500 ms of its start event with a trivial payload is treated the same way, in case cursor-agent changes the synthetic-string text in a future release. +When running cursor-agent under `--print --output-format stream-json`, the cursor-agent CLI returns a synthetic `Questions skipped by the user, continue with the information you already have` response for the `AskQuestion` tool because there is no IDE surface to render the question. The agent's underlying model can interpret this as legitimate user consent and act on it. -Agents running under HAPI's Cursor remote mode should fall back to plain-text prompting (markdown options + waiting for a regular user message) until the [ACP migration (tiann/hapi#781)](https://github.com/tiann/hapi/issues/781) lands and `cursor/ask_question` becomes available as a proper bidirectional ACP method. At that point this intercept becomes unnecessary and is removed. +HAPI's legacy event converter intercepts this synthetic response and rewrites it to an explicit `no_input_surface` error (`status: failed`), so downstream consumers (web UI, Telegram, log readers) surface the fabrication as an error instead of silently passing through fabricated consent. The intercept scans the raw `tool_call` payload for the literal marker text and is scoped to `AskQuestion`-shaped (and converter-fallback `name=unknown`) calls; legitimate read/write/function tools are not affected. + +The intercept drains naturally with the legacy session population - resumed pre-ACP sessions are the only path that still hits this code. Tracking issue: [tiann/hapi#784](https://github.com/tiann/hapi/issues/784). diff --git a/hub/src/cursor/acpVerifyProbe.test.ts b/hub/src/cursor/acpVerifyProbe.test.ts new file mode 100644 index 000000000..4deff3452 --- /dev/null +++ b/hub/src/cursor/acpVerifyProbe.test.ts @@ -0,0 +1,358 @@ +/** + * Unit tests for the AcpVerifyProbe lock-acquisition primitives. + * + * The probe spawns a real `agent acp` in production. These tests only + * cover the lock dance (start/stop side effects on the agent-acp-active + * lock dir), NOT the RPC behaviour — that's covered by the integration + * tests with a real agent binary. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe' + +describe('AcpVerifyProbe — agent-acp-active lock acquisition (Codex #34 P2 v2)', () => { + let hapiHome: string + beforeEach(() => { + hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-test-')) + }) + afterEach(() => { + try { rmSync(hapiHome, { recursive: true, force: true }) } catch {} + }) + + function lockDir(home: string): string { + return join(home, 'locks', 'agent-acp-active') + } + + it('acquires the lock atomically when no holder exists (and releases on stop)', async () => { + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', // any executable; we'll stop() before sending RPC + hapiHome + }) + // Spawn would normally fail RPC, but the start path itself should + // succeed up through agent spawn — what we care about here is the + // lock side-effect. + probe.start() + expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true) + await probe.stop() + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) + + it('throws when the lock is held by another live process (atomic refusal)', () => { + // Pre-create the lock dir with a pid that IS alive (our own pid). + mkdirSync(lockDir(hapiHome), { recursive: true }) + writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid)) + + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', + hapiHome + }) + expect(() => probe.start()).toThrow(/agent-acp-active lock is held/) + // Pre-existing lock dir must NOT be removed by the failed acquire. + expect(existsSync(lockDir(hapiHome))).toBe(true) + }) + + it('clears a stale lock (dead pid file present) and acquires on retry', async () => { + // Pre-create with a pid that is virtually certain to be dead. + mkdirSync(lockDir(hapiHome), { recursive: true }) + writeFileSync(join(lockDir(hapiHome), 'pid'), '999999') // typical max_pid; if collides, test is slightly flaky but unlikely on CI + + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', + hapiHome + }) + probe.start() + // We acquired by clearing the stale dir and re-creating it. + expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true) + await probe.stop() + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) + + it('refuses on a pidless lock dir (mid-startup race, Codex #34 P2 v3)', () => { + // Pre-create an EMPTY lock dir without a pid file — this is what + // the CLI guard's registerActiveAcpTransport looks like in the + // tiny window between mkdir and writeFileSync. Treating it as + // "stale because no pid" would clobber the freshly-starting CLI + // ACP transport. + mkdirSync(lockDir(hapiHome), { recursive: true }) + + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', + hapiHome + }) + expect(() => probe.start()).toThrow(/agent-acp-active lock is held/) + // The pidless lock dir must still be intact. + expect(existsSync(lockDir(hapiHome))).toBe(true) + }) + + it('stop() does not remove a lock dir owned by another holder', async () => { + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', + hapiHome + }) + probe.start() + // Simulate another process clobbering the pid file before our stop(). + writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid + 1)) + await probe.stop() + // Lock dir is preserved because we no longer own the pid file. + expect(existsSync(lockDir(hapiHome))).toBe(true) + }) + + it('skipLockAcquire makes start() bypass internal acquire and stop() bypass release (Codex #34 P2 v7)', async () => { + // Caller (migrator) holds the lock externally. + const externalHandle = tryAcquireAcpActiveLock(hapiHome) + expect(externalHandle).not.toBeNull() + if (!externalHandle) return + + const probe = new AcpVerifyProbe({ + agentBinary: '/usr/bin/env', + hapiHome, + skipLockAcquire: true + }) + // start() must NOT throw 'lock is held' — it skipped its internal acquire. + probe.start() + // Lock still held by the external handle. + expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true) + await probe.stop() + // Lock is still held — probe.stop() did NOT release it. + expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true) + externalHandle.release() + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) + + it('falls back to $HOME/.local/bin and $HOME/.npm-global/bin in PATH when spawning agent (live dogfood 2026-06-07 regression: hub systemd unit ships minimal PATH AND the migrator overrides HOME to a tmpdir for isolation)', async () => { + // The dogfood failure mode: hapi-hub.service ships minimal PATH and + // never sees ~/.local/bin/agent. The migrator additionally overrides + // HOME for the verify probe (HAPI_HOME isolation) — so any naive + // augmentation that derives bin paths from baseEnv.HOME points at a + // tmpdir that doesn't contain agent. + // + // After codex #34 P2 (round 13): the probe accepts an explicit + // `agentLookupHome` option (caller threads its recorded session- + // owner home). Falls back to process.env.HOME when not provided. + // We pin BOTH: explicit option AND fallback. We also pin that the + // existing PATH wins over the fallback (precedence preservation — + // codex #34 P2 round-13 finding F3). + const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-stub-home-')) + const stubBin = join(stubHome, '.local', 'bin') + mkdirSync(stubBin, { recursive: true }) + writeFileSync(join(stubBin, 'agent'), '#!/bin/sh\nexit 99\n', { mode: 0o755 }) + + const fakeOverrideHome = mkdtempSync(join(tmpdir(), 'hapi-probe-override-home-')) + // Deliberately NO .local/bin under fakeOverrideHome. + + // Case A: explicit agentLookupHome wins, env.HOME irrelevant. + try { + const probe = new AcpVerifyProbe({ + hapiHome, + agentLookupHome: stubHome, + env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' } + }) + probe.start() + const exited = await new Promise<{ code: number | null }>((resolve) => { + const interval = setInterval(() => { + if (probe['proc'] && probe['proc'].exitCode !== null) { + clearInterval(interval) + resolve({ code: probe['proc'].exitCode }) + } + }, 10) + setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000) + }) + expect(exited.code).toBe(99) + await probe.stop() + } finally { + // intentionally leave stubHome in place for case B + } + + // Case B: no agentLookupHome → falls back to process.env.HOME. + const originalHome = process.env.HOME + process.env.HOME = stubHome + try { + const probe = new AcpVerifyProbe({ + hapiHome, + env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' } + }) + probe.start() + const exited = await new Promise<{ code: number | null }>((resolve) => { + const interval = setInterval(() => { + if (probe['proc'] && probe['proc'].exitCode !== null) { + clearInterval(interval) + resolve({ code: probe['proc'].exitCode }) + } + }, 10) + setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000) + }) + expect(exited.code).toBe(99) + await probe.stop() + } finally { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + try { rmSync(stubHome, { recursive: true, force: true }) } catch {} + try { rmSync(fakeOverrideHome, { recursive: true, force: true }) } catch {} + } + }) + + it('preserves explicit options.env.PATH precedence over the cursor-bin fallback (Codex #34 P2 round-13 F3)', async () => { + // When the caller deliberately supplies options.env.PATH with a + // pinned `agent` (e.g. a staging Cursor install or a wrapper), + // the cursor-bin fallback must NOT override it. We test by giving + // BOTH a winning PATH entry (priorityBin) AND a fallback entry + // (fallbackBin) and asserting the priority wins. + const priorityHome = mkdtempSync(join(tmpdir(), 'hapi-probe-priority-')) + const priorityBin = join(priorityHome, 'bin') + mkdirSync(priorityBin, { recursive: true }) + writeFileSync(join(priorityBin, 'agent'), '#!/bin/sh\nexit 11\n', { mode: 0o755 }) + + const fallbackHome = mkdtempSync(join(tmpdir(), 'hapi-probe-fallback-')) + const fallbackBin = join(fallbackHome, '.local', 'bin') + mkdirSync(fallbackBin, { recursive: true }) + writeFileSync(join(fallbackBin, 'agent'), '#!/bin/sh\nexit 22\n', { mode: 0o755 }) + + try { + const probe = new AcpVerifyProbe({ + hapiHome, + agentLookupHome: fallbackHome, + env: { PATH: priorityBin } // explicit PATH wins; fallback bins appended + }) + probe.start() + const exited = await new Promise<{ code: number | null }>((resolve) => { + const interval = setInterval(() => { + if (probe['proc'] && probe['proc'].exitCode !== null) { + clearInterval(interval) + resolve({ code: probe['proc'].exitCode }) + } + }, 10) + setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000) + }) + expect(exited.code).toBe(11) // priority wins, not 22 (fallback) + await probe.stop() + } finally { + try { rmSync(priorityHome, { recursive: true, force: true }) } catch {} + try { rmSync(fallbackHome, { recursive: true, force: true }) } catch {} + } + }) + + it('joins augmented PATH with path.delimiter (Codex #34 P2 round-13 F1: Windows uses ; not :)', async () => { + // Indirect assertion via spawn behaviour: on linux the delimiter is + // `:`. We can't actually drive a win32 spawn from this test runner, + // but we can confirm the join uses path.delimiter by checking that + // the augmented PATH contains a path.delimiter between segments, + // not a hardcoded ':'. Reach into the spawn env via a stubbed + // agent that prints its PATH. + const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-delim-')) + const stubBin = join(stubHome, '.local', 'bin') + mkdirSync(stubBin, { recursive: true }) + writeFileSync( + join(stubBin, 'agent'), + '#!/bin/sh\nprintenv PATH > "$0.path"\nexit 33\n', + { mode: 0o755 } + ) + + try { + const probe = new AcpVerifyProbe({ + hapiHome, + agentLookupHome: stubHome, + env: { PATH: '/usr/bin' } + }) + probe.start() + await new Promise((resolve) => { + const interval = setInterval(() => { + if (probe['proc'] && probe['proc'].exitCode !== null) { + clearInterval(interval) + resolve() + } + }, 10) + setTimeout(() => { clearInterval(interval); resolve() }, 2000) + }) + await probe.stop() + const pathFile = join(stubBin, 'agent.path') + const seenPath = existsSync(pathFile) + ? require('node:fs').readFileSync(pathFile, 'utf8').trim() + : '' + // Should be `/usr/bin/.local/bin/.npm-global/bin` + // on linux this means `/usr/bin:/tmp/.../.local/bin:/tmp/.../.npm-global/bin` + expect(seenPath).toContain('/usr/bin') + expect(seenPath).toContain(`${stubHome}/.local/bin`) + // Existing PATH first, fallback appended. + const usrBinIdx = seenPath.indexOf('/usr/bin') + const fallbackIdx = seenPath.indexOf(`${stubHome}/.local/bin`) + expect(usrBinIdx).toBeLessThan(fallbackIdx) + } finally { + try { rmSync(stubHome, { recursive: true, force: true }) } catch {} + } + }) +}) + +describe('tryAcquireAcpActiveLock (Codex #34 P2 v7)', () => { + let hapiHome: string + beforeEach(() => { + hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-helper-test-')) + }) + afterEach(() => { + try { rmSync(hapiHome, { recursive: true, force: true }) } catch {} + }) + + function lockDir(home: string): string { + return join(home, 'locks', 'agent-acp-active') + } + + it('returns a handle on a clean home; release() removes the lock dir', () => { + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).not.toBeNull() + if (!h) return + expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true) + h.release() + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) + + it('returns null when another live process holds the lock', () => { + // Pre-place an active lock (our pid is alive). + mkdirSync(lockDir(hapiHome), { recursive: true }) + writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid)) + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).toBeNull() + // Existing lock dir not clobbered. + expect(existsSync(lockDir(hapiHome))).toBe(true) + }) + + it('returns null on a pidless lock dir (mid-startup race)', () => { + mkdirSync(lockDir(hapiHome), { recursive: true }) + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).toBeNull() + expect(existsSync(lockDir(hapiHome))).toBe(true) + }) + + it('clears a stale lock (dead pid) and acquires on retry', () => { + mkdirSync(lockDir(hapiHome), { recursive: true }) + writeFileSync(join(lockDir(hapiHome), 'pid'), '999999') + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).not.toBeNull() + if (!h) return + h.release() + }) + + it('release() is idempotent', () => { + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).not.toBeNull() + if (!h) return + h.release() + h.release() // second call should not throw + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) + + it('release() removes the lock dir even when the pid file is missing (Codex #34 P2 v7)', () => { + // Simulate the rare case where mkdir succeeded but writeFileSync(pid) + // failed. The handle still owns the dir; release must clean it up. + const h = tryAcquireAcpActiveLock(hapiHome) + expect(h).not.toBeNull() + if (!h) return + // Remove the pid file underneath us. + rmSync(join(lockDir(hapiHome), 'pid')) + h.release() + // Lock dir gone — we own it, we remove it even when pidless. + expect(existsSync(lockDir(hapiHome))).toBe(false) + }) +}) diff --git a/hub/src/cursor/acpVerifyProbe.ts b/hub/src/cursor/acpVerifyProbe.ts new file mode 100644 index 000000000..f70665894 --- /dev/null +++ b/hub/src/cursor/acpVerifyProbe.ts @@ -0,0 +1,671 @@ +/** + * Minimal JSON-RPC stdio client for `agent acp`. + * + * Hub-side, internal-only: used by the legacy → ACP migrator's verify step to + * confirm that a transplanted store.db can actually be opened by `agent acp` + * before we flip metadata and remove the legacy source. + * + * This is intentionally NOT a full ACP client (those live in cli/src/agent/...). + * It speaks only the three calls verify needs: initialize, session/load, and + * (optionally) session/prompt. It is decoupled from the launcher loop so it + * can spawn against a temp $HOME without engaging any of HAPI's per-session + * machinery. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, delimiter as pathDelimiter } from 'node:path' +import { tmpdir } from 'node:os' + +export interface AcpProbeOptions { + /** Path to the `agent` binary. Default 'agent'. */ + agentBinary?: string + /** Override env (used to set HOME for isolation). */ + env?: NodeJS.ProcessEnv + /** Default per-request timeout, ms. */ + timeoutMs?: number + /** + * Override the $HAPI_HOME directory used for the agent-acp-active lock. + * Defaults to `process.env.HAPI_HOME` (with tmpdir/hapi fallback) — same + * scheme as cli/src/agent/backends/acp/agentCliGuard.ts. Tests can pass a + * temp dir here to avoid clobbering the operator's real lock. + */ + hapiHome?: string + /** + * When true, the probe will NOT acquire the agent-acp-active lock in + * start() and will NOT release it in stop(). Caller is responsible + * for owning the lock for the probe's lifetime. Codex review #34 + * P2 v7: the migrator pre-acquires the lock BEFORE archiving so a + * concurrent ACP spawn cannot land in the window between preflight + * and verifyInTempHome. + */ + skipLockAcquire?: boolean + /** + * Recorded operator home dir to use for resolving the `agent` binary + * on PATH. Used when constructing the fallback PATH augmentation + * (~/.local/bin and ~/.npm-global/bin). Defaults to `process.env.HOME`. + * + * Codex review #34 P2: in deployment shapes where the hub runs as a + * service account whose `process.env.HOME` differs from the human + * user who installed Cursor (`metadata.homeDir`), the caller (the + * migrator) needs to thread its recorded session-owner home through + * to the verify probe so the binary lookup happens against the right + * filesystem location. Independent of any HOME override passed via + * `options.env` (which is for the spawned agent's cache/state + * isolation, not its binary lookup). + */ + agentLookupHome?: string +} + +/** + * Handle to a held agent-acp-active lock. Created by + * tryAcquireAcpActiveLock(); the caller MUST call release() in a + * finally block. + */ +export interface AcpActiveLockHandle { + /** Absolute path to the lock directory we own. */ + lockDir: string + /** Release the lock. Idempotent. */ + release(): void +} + +/** + * Acquire the global agent-acp-active lock at the well-known path + * `/locks/agent-acp-active/`. Returns a handle whose + * release() removes the lock dir; returns null if the lock is held + * by another live (or mid-startup) process. Throws on + * non-EEXIST mkdir failures (root-owned HAPI_HOME, read-only fs). + * + * Codex review #34 P2 v7: extracted so the legacy migrator can + * reserve the lock BEFORE archive — closing the gap where another + * agent acp could start between preflight and verifyInTempHome. + */ +export function tryAcquireAcpActiveLock(hapiHome: string): AcpActiveLockHandle | null { + const lockDir = join(hapiHome, 'locks', 'agent-acp-active') + const pidFile = join(lockDir, 'pid') + const parentDir = join(lockDir, '..') + try { + mkdirSync(parentDir, { recursive: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!/EEXIST/.test(msg)) { + throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`) + } + } + + const tryAcquire = (): boolean => { + try { + mkdirSync(lockDir, { recursive: false }) + try { + writeFileSync(pidFile, String(process.pid)) + } catch { + // pid write best-effort; release will still rmdir. + } + return true + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!/EEXIST/.test(msg)) { + throw new Error(`agent-acp-active lock could not be claimed (path=${lockDir}): ${msg}`) + } + return false + } + } + + if (tryAcquire()) { + return makeLockHandle(lockDir, pidFile) + } + // Lock held — decide if stale and retry once. + const probe = inspectLockHolder(pidFile) + if (probe.kind === 'dead') { + try { rmSync(lockDir, { recursive: true, force: true }) } catch {} + if (tryAcquire()) { + return makeLockHandle(lockDir, pidFile) + } + } + return null +} + +type LockHolderProbe = + | { kind: 'live'; pid: number } + | { kind: 'dead' } + | { kind: 'starting' } + +function inspectLockHolder(pidFile: string): LockHolderProbe { + if (!existsSync(pidFile)) return { kind: 'starting' } + let raw: string + try { + raw = readFileSync(pidFile, 'utf8').trim() + } catch { + return { kind: 'starting' } + } + if (raw.length === 0) return { kind: 'starting' } + const pid = Number(raw) + if (!Number.isInteger(pid) || pid <= 0) return { kind: 'starting' } + try { + process.kill(pid, 0) + return { kind: 'live', pid } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EPERM') return { kind: 'live', pid } + return { kind: 'dead' } + } +} + +function makeLockHandle(lockDir: string, pidFile: string): AcpActiveLockHandle { + let released = false + return { + lockDir, + release() { + if (released) return + released = true + try { + let shouldRemove = true + if (existsSync(pidFile)) { + try { + const raw = readFileSync(pidFile, 'utf8').trim() + if (raw.length > 0 && raw !== String(process.pid)) { + const otherPid = Number(raw) + if (Number.isInteger(otherPid) && otherPid > 0) { + shouldRemove = false + } + } + } catch { + // read error — we own the dir, remove it. + } + } + if (shouldRemove) { + rmSync(lockDir, { recursive: true, force: true }) + } + } catch { + // best-effort + } + } + } +} + +export type AcpRpcResponse = + | { ok: true; result: Record } + | { ok: false; error: { code: number; message: string; data?: unknown } } + +export type AcpNotification = { + method: string + params: Record +} + +/** Subset of session/load response useful to the migrator. */ +export interface AcpLoadOutcome { + response: AcpRpcResponse + notificationCount: number + notificationKinds: Record + durationMs: number +} + +export interface AcpPromptOutcome { + response: AcpRpcResponse + durationMs: number +} + +export class AcpVerifyProbe { + private proc: ChildProcessWithoutNullStreams | null = null + private nextId = 0 + private buf = '' + private readonly pending = new Map void; timer: NodeJS.Timeout }>() + private readonly notifications: AcpNotification[] = [] + private stderr = '' + private readonly defaultTimeoutMs: number + private readonly stderrLimit = 4096 + private lockHeld = false + private readonly lockDir: string + private readonly lockPidFile: string + + constructor(private readonly options: AcpProbeOptions = {}) { + this.defaultTimeoutMs = options.timeoutMs ?? 20_000 + const home = options.hapiHome ?? process.env.HAPI_HOME?.trim() ?? join(tmpdir(), 'hapi') + this.lockDir = join(home, 'locks', 'agent-acp-active') + this.lockPidFile = join(this.lockDir, 'pid') + } + + start(): void { + if (this.proc) return + // Codex review #34 P2: register the agent-acp-active lock BEFORE + // spawn so concurrent migrations / model-list requests see the + // probe as a live ACP transport and back off. Without this, two + // parallel migrations could each pass the pre-spawn check, both + // spawn agent acp, and the second one's spawn would SIGTERM the + // first per Cursor's single-instance enforcement (see + // cli/src/agent/backends/acp/agentCliGuard.ts top comment). + // + // Codex review #34 P2 v7: when the caller (typically the legacy + // migrator) has already acquired the lock at the start of its + // critical section, skip our internal acquire so we don't fail + // EEXIST against the caller's own lock. The caller is then + // responsible for releasing. + if (!this.options.skipLockAcquire) { + this.acquireLock() + } + + // Codex review #34 P2: match cursorAcpRemoteLauncher's spawn shape + // so the `agent.cmd` shim on Windows is reachable. Without + // shell:true + windowsHide:true the spawn fails with ENOENT even + // though normal Cursor ACP sessions work. + const isWin = process.platform === 'win32' + // Live dogfood (2026-06-07) on hapi-hub.service surfaced + // `Executable not found in $PATH: "agent"` — the hub's systemd unit + // ships a minimal PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin: + // /usr/bin:/sbin:/bin) and never sees Cursor's standard install + // location at ~/.local/bin/agent. hapi-runner.service hand-fixes + // this via Environment=PATH=$HOME/.local/bin:... in its unit file; + // we replicate that in code so the hub doesn't depend on the + // operator hand-tuning their systemd dropin. + // + // Resolution order for the lookup home: + // 1. options.agentLookupHome — caller-supplied (migrator threads + // its recorded session-owner homeDir here; covers the service- + // account-hub deployment where process.env.HOME and the human + // user who installed Cursor differ) + // 2. process.env.HOME — hub process's own home (covers the + // common single-user deployment) + // + // Independently of where the LOOKUP home comes from, options.env + // may override HOME for the spawned agent's cache/state isolation + // (HAPI_HOME-style sandboxing in the migrator's verifyInTempHome). + // The two HOMEs are deliberately separate concerns. + // + // PATH precedence: we APPEND the fallback bin dirs after the + // existing PATH, so any explicit options.env.PATH (e.g. a staging + // Cursor install or a pinned wrapper) wins. The fallback only + // kicks in when the existing PATH doesn't already contain `agent`. + // + // Platform: use path.delimiter (`;` on win32, `:` elsewhere) so the + // augmented PATH is valid for cmd.exe when this spawn path + // delegates to the shell for `agent.cmd`. + const baseEnv = this.options.env ?? process.env + const lookupHome = this.options.agentLookupHome ?? process.env.HOME ?? '' + const cursorBins = lookupHome + ? [join(lookupHome, '.local', 'bin'), join(lookupHome, '.npm-global', 'bin')] + : [] + const existingPath = baseEnv.PATH ?? '' + const augmentedPath = [existingPath, ...cursorBins].filter(Boolean).join(pathDelimiter) + const spawnEnv = { ...baseEnv, PATH: augmentedPath } + const proc = spawn(this.options.agentBinary ?? 'agent', ['acp'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: spawnEnv, + shell: isWin, + windowsHide: isWin + }) + this.proc = proc + + proc.stdout.on('data', (chunk: Buffer) => this.handleStdout(chunk.toString('utf8'))) + proc.stderr.on('data', (chunk: Buffer) => { + this.stderr += chunk.toString('utf8') + if (this.stderr.length > this.stderrLimit) { + this.stderr = this.stderr.slice(-this.stderrLimit) + } + }) + // If the child dies, fail all pending requests so callers see a + // structured rejection instead of a hang. + proc.on('error', (err) => this.failPending(err)) + proc.on('exit', (code, signal) => { + if (this.pending.size > 0) { + this.failPending(new Error(`agent acp exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`)) + } + }) + } + + async stop(): Promise { + const proc = this.proc + this.proc = null + if (proc) { + // Codex review #34 P2 v7: on Windows we spawn through a shell + // (shell: true in start()) so proc.kill only signals the shell + // wrapper — the `agent` child can survive. Use taskkill /F /T + // to kill the process tree. POSIX kill propagates to the + // process group via SIGTERM as long as the child didn't fork. + if (process.platform === 'win32' && proc.pid !== undefined) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('node:child_process').execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: 'ignore', windowsHide: true }) + } catch { + // best-effort — fall through to SIGTERM as a backup + try { proc.kill('SIGTERM') } catch {} + } + } else { + try { + proc.kill('SIGTERM') + } catch { + // best-effort + } + } + // Codex review #34 P2 v6: wait for the child to actually exit + // before releasing the agent-acp-active lock. Sending SIGTERM + // does not mean the process is dead — agent acp may take a + // few hundred ms to tear down JSON-RPC and detach stdio. If + // we release the lock before then, the next session's + // verifier (or any other CLI guard caller) can acquire the + // free lock and spawn a second `agent acp` while ours is + // still live, hitting Cursor's single-instance enforcement + // and SIGTERMing one or both. + const alreadyDone = proc.exitCode !== null || proc.signalCode !== null + if (!alreadyDone) { + await new Promise((resolve) => { + let resolved = false + const done = () => { + if (resolved) return + resolved = true + resolve() + } + proc.once('exit', done) + proc.once('close', done) + // Hard ceiling so a wedged child cannot hang the + // migrator's finally{} block forever. After this + // ceiling we fall through to releaseLock and accept + // the (now extremely rare) overlap window. + setTimeout(done, 5000) + }) + } + // Drain any remaining pending requests with a kill error so the caller + // does not deadlock waiting on a JSON-RPC response that will never arrive. + this.failPending(new Error('agent acp killed by probe.stop()')) + } + // Release the lock LAST (after kill + exit-wait) so concurrent + // requests still see us as active during the entire teardown + // window. Codex review #34 P2 / P2 v6. + // + // Codex review #34 P2 v7: when skipLockAcquire was set, the + // caller owns the lock for a longer scope than this probe + // instance — don't release theirs. + if (!this.options.skipLockAcquire) { + this.releaseLock() + } + } + + private acquireLock(): void { + // Ensure the parent dir exists (idempotent), then atomically claim + // the lock dir itself via mkdirSync(recursive:false). The atomic + // mkdir fails EEXIST if another lock-holder is already in place + // — that is the only race-safe primitive here. mkdirSync + write + // is NOT atomic and would let two concurrent migrations both + // think they own the lock. Codex review #34 P2 v2. + const parentDir = join(this.lockDir, '..') + try { + mkdirSync(parentDir, { recursive: true }) + } catch (err) { + // Codex review #34 P2 v7: previously silently swallowed. If + // we can't even create the parent dir (root-owned HAPI_HOME, + // read-only fs), we cannot claim a lock. Fail loud so start() + // refuses rather than running unguarded. + const msg = err instanceof Error ? err.message : String(err) + if (!/EEXIST/.test(msg)) { + throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`) + } + } + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + mkdirSync(this.lockDir, { recursive: false }) + // Won the race — write our pid IMMEDIATELY (no async work + // between the mkdir and the write) so other callers see a + // pidful lock as soon as possible. The CLI guard's + // clearStaleAcpLockIfNeeded ALSO no longer removes + // pid-less dirs (Codex review #34 P3 v6), but tightening + // the window here belt-and-suspenders the protection. + try { + writeFileSync(this.lockPidFile, String(process.pid)) + } catch { + // best-effort: pid file is diagnostic, not the + // primary lock primitive (the dir is). releaseLock + // will still rmdir on our pid-less lock because + // lockHeld=true means we own the mkdir. Codex + // review #34 P2 v7. + } + this.lockHeld = true + return + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!/EEXIST/.test(msg)) { + // Codex review #34 P2 v7: a non-EEXIST mkdir failure + // (permission denied, read-only fs, disk full) means + // we cannot acquire the lock. Previously we silently + // returned with lockHeld=false and start() would + // spawn agent acp UNGUARDED. Throw instead so + // verifyInTempHome surfaces the refusal cleanly. + throw new Error(`agent-acp-active lock could not be claimed (path=${this.lockDir}): ${msg}`) + } + // Lock dir exists. Decide whether the holder is stale. + // Codex review #34 P2 v3: a CLI agent guard that just + // mkdir'd the lock but has NOT yet written the pid file + // would otherwise look "stale" to us and we would delete + // their freshly-created live lock. Treat a missing-pid + // lock as ACTIVE (probably mid-startup) on the first + // attempt; only consider it stale if a pid file IS + // present AND the recorded pid is dead. + const probe = this.probeLockHolder() + if (attempt === 0 && probe.kind === 'dead') { + try { rmSync(this.lockDir, { recursive: true, force: true }) } catch {} + continue + } + // Either live, or mid-startup (no pid yet), or we already + // retried once. Refuse. + throw new Error(`agent-acp-active lock is held (path=${this.lockDir}, holder=${probe.kind === 'live' ? `pid=${probe.pid}` : probe.kind}); refusing to spawn a second agent acp`) + } + } + } + + /** Inspect the current holder of an existing lock dir. */ + private probeLockHolder(): { kind: 'live'; pid: number } | { kind: 'dead' } | { kind: 'starting' } { + try { + if (!existsSync(this.lockPidFile)) { + // Lock dir exists but pid file not yet written — caller + // is in the middle of registerActiveAcpTransport(). Treat + // as live to avoid racing them. + return { kind: 'starting' } + } + const raw = readFileSync(this.lockPidFile, 'utf8').trim() + const pid = Number(raw) + if (!Number.isInteger(pid) || pid <= 0) { + // Malformed pid file — treat as starting (caller may be + // mid-write) rather than dead. + return { kind: 'starting' } + } + try { + process.kill(pid, 0) + return { kind: 'live', pid } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EPERM') return { kind: 'live', pid } + return { kind: 'dead' } + } + } catch { + return { kind: 'starting' } + } + } + + private releaseLock(): void { + if (!this.lockHeld) return + this.lockHeld = false + // We own the mkdir (lockHeld was set true by the EEXIST-free + // mkdirSync above). Remove the lock dir in two cases: + // (a) pid file is OUR pid (normal happy path), OR + // (b) pid file is missing/unparseable — Codex review #34 P2 v7: + // we own the dir, our pid-write failed (disk full etc.). + // The CLI guard now treats pid-less dirs as "starting" + // (active), so leaving this here would wedge the lock + // forever. We OWN it; we must clean it up. + // Skip removal only when the pid file has a DIFFERENT, valid pid + // — that would mean another holder somehow took over (shouldn't + // happen but defensive). + try { + let shouldRemove = true + if (existsSync(this.lockPidFile)) { + try { + const raw = readFileSync(this.lockPidFile, 'utf8').trim() + if (raw.length > 0 && raw !== String(process.pid)) { + const otherPid = Number(raw) + if (Number.isInteger(otherPid) && otherPid > 0) { + shouldRemove = false + } + } + } catch { + // read error — we own the dir, remove it. + } + } + if (shouldRemove) { + rmSync(this.lockDir, { recursive: true, force: true }) + } + } catch { + // best-effort + } + } + + getStderr(): string { + return this.stderr + } + + getNotifications(): AcpNotification[] { + return [...this.notifications] + } + + clearNotifications(): void { + this.notifications.length = 0 + } + + /** Send `initialize` and return the response. */ + initialize(timeoutMs?: number): Promise { + return this.send('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false + }, + clientInfo: { name: 'hapi-cursor-legacy-migrator-verify', version: '1' } + }, timeoutMs) + } + + /** Send `session/load` and capture replay notifications drained over `replayDrainMs`. */ + async loadSession(params: { sessionId: string; cwd: string; mcpServers?: unknown[] }, replayDrainMs: number = 3_000, timeoutMs?: number): Promise { + const start = Date.now() + const before = this.notifications.length + const response = await this.send('session/load', { + sessionId: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [] + }, timeoutMs) + if (!response.ok) { + return { + response, + notificationCount: 0, + notificationKinds: {}, + durationMs: Date.now() - start + } + } + if (replayDrainMs > 0) { + await sleep(replayDrainMs) + } + const drained = this.notifications.slice(before) + const notificationKinds: Record = {} + for (const n of drained) { + const u = (n.params as Record)?.update as Record | undefined + const kind = typeof u?.sessionUpdate === 'string' ? u.sessionUpdate : '_other' + notificationKinds[kind] = (notificationKinds[kind] ?? 0) + 1 + } + return { + response, + notificationCount: drained.length, + notificationKinds, + durationMs: Date.now() - start + } + } + + async prompt(params: { sessionId: string; text: string }, timeoutMs: number = 60_000): Promise { + const start = Date.now() + const response = await this.send('session/prompt', { + sessionId: params.sessionId, + prompt: [{ type: 'text', text: params.text }] + }, timeoutMs) + return { response, durationMs: Date.now() - start } + } + + async setModel(params: { sessionId: string; modelId: string }, timeoutMs?: number): Promise { + return this.send('session/set_model', { sessionId: params.sessionId, modelId: params.modelId }, timeoutMs) + } + + // --------------------------------------------------------------------- + + private send(method: string, params: unknown, timeoutMs?: number): Promise { + if (!this.proc) { + return Promise.resolve({ ok: false as const, error: { code: -32603, message: 'agent acp not started' } }) + } + const id = ++this.nextId + const t = timeoutMs ?? this.defaultTimeoutMs + const req = { jsonrpc: '2.0', id, method, params } + const stdin = this.proc.stdin + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id) + resolve({ ok: false, error: { code: -32603, message: `timeout ${method} after ${t}ms`, data: { stderr_tail: this.stderr.slice(-512) } } }) + }, t) + this.pending.set(id, { resolve, timer }) + try { + stdin.write(`${JSON.stringify(req)}\n`) + } catch (err) { + clearTimeout(timer) + this.pending.delete(id) + resolve({ ok: false, error: { code: -32603, message: `stdin write failed: ${err instanceof Error ? err.message : String(err)}` } }) + } + }) + } + + private handleStdout(chunk: string): void { + this.buf += chunk + let idx: number + while ((idx = this.buf.indexOf('\n')) !== -1) { + const line = this.buf.slice(0, idx).trim() + this.buf = this.buf.slice(idx + 1) + if (!line) continue + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + continue + } + const id = msg.id + if (typeof id === 'number' && this.pending.has(id)) { + const entry = this.pending.get(id)! + this.pending.delete(id) + clearTimeout(entry.timer) + if (msg.error && typeof msg.error === 'object') { + const err = msg.error as Record + entry.resolve({ + ok: false, + error: { + code: typeof err.code === 'number' ? err.code : -32603, + message: typeof err.message === 'string' ? err.message : 'agent acp error', + data: err.data + } + }) + } else if (msg.result && typeof msg.result === 'object') { + entry.resolve({ ok: true, result: msg.result as Record }) + } else { + entry.resolve({ ok: false, error: { code: -32603, message: 'malformed agent acp response' } }) + } + } else if (typeof msg.method === 'string' && msg.params && typeof msg.params === 'object') { + this.notifications.push({ + method: msg.method as string, + params: msg.params as Record + }) + } + } + } + + private failPending(err: Error): void { + for (const [id, entry] of this.pending.entries()) { + clearTimeout(entry.timer) + entry.resolve({ ok: false, error: { code: -32603, message: err.message } }) + this.pending.delete(id) + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/hub/src/cursor/cursorLegacyMigrator.test.ts b/hub/src/cursor/cursorLegacyMigrator.test.ts new file mode 100644 index 000000000..70f262af2 --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.test.ts @@ -0,0 +1,1139 @@ +/** + * Unit tests for the legacy stream-json → ACP migrator (tiann/hapi#824). + * + * Strategy: + * - Real filesystem in a per-test tmpdir (cheaper than mocking node:fs) + * - Real bun:sqlite for the synthetic store fixture (the migrator reads + * meta.lastUsedModel directly) + * - MOCK agent acp via the createProbe dependency injection point. The + * mock probe records calls and returns scripted responses so we can + * exercise every branch without spawning a child process. + * - MOCK the hapi.db write via the updateSessionAfterMigrate dep. + * + * Covers: + * - happy path: cp + verify + flip + rm + * - --keep-source preserves the source after success + * - lastUsedModel round-trip + * - refusals: not a cursor session, already on ACP, no cursor session id, + * missing on-disk store, ACP target already exists, running without + * force-archive + * - rollback on session/load failure + * - rollback on session/prompt failure + * - rollback on metadata write failure + * - --force-archive-then-migrate archives a running session before proceeding + * - skipVerify path (load + prompt both skipped, no probe spawned) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import type { Metadata } from '@hapi/protocol/schemas' +import type { Session } from '@hapi/protocol/types' +import type { AcpRpcResponse } from './acpVerifyProbe' +import { CursorLegacyMigrator, findLegacyChatStore, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator' +import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore' +/* ---------- mock probe ---------- */ + +interface ScriptedProbe { + initializeResponse: AcpRpcResponse + loadResponse: AcpRpcResponse + loadNotificationCount: number + promptResponse: AcpRpcResponse + started: boolean + stopped: boolean + initializeCalls: number + loadCalls: number + promptCalls: number +} + +function ok(result: Record = {}): AcpRpcResponse { + return { ok: true, result } +} + +function err(message: string, code: number = -32602): AcpRpcResponse { + return { ok: false, error: { code, message } } +} + +function makeMockProbe(overrides: Partial = {}): ScriptedProbe & { + start(): void + stop(): Promise + initialize(): Promise + loadSession(): Promise<{ response: AcpRpcResponse; notificationCount: number; notificationKinds: Record; durationMs: number }> + prompt(): Promise<{ response: AcpRpcResponse; durationMs: number }> + setModel?(): Promise +} { + const state: ScriptedProbe = { + initializeResponse: ok({ protocolVersion: 1 }), + loadResponse: ok({ models: { availableModels: [], currentModelId: 'default[]' }, modes: { availableModes: [], currentModeId: 'agent' } }), + loadNotificationCount: 17, + promptResponse: ok({ stopReason: 'end_turn' }), + started: false, + stopped: false, + initializeCalls: 0, + loadCalls: 0, + promptCalls: 0, + ...overrides + } + return { + ...state, + start() { state.started = true }, + async stop() { state.stopped = true }, + async initialize() { + state.initializeCalls += 1 + return state.initializeResponse + }, + async loadSession() { + state.loadCalls += 1 + return { + response: state.loadResponse, + notificationCount: state.loadResponse.ok ? state.loadNotificationCount : 0, + notificationKinds: {}, + durationMs: 50 + } + }, + async prompt() { + state.promptCalls += 1 + return { response: state.promptResponse, durationMs: 30 } + }, + get started() { return state.started }, + get stopped() { return state.stopped }, + get initializeCalls() { return state.initializeCalls }, + get loadCalls() { return state.loadCalls }, + get promptCalls() { return state.promptCalls } + } as unknown as ScriptedProbe & ReturnType +} + +/* ---------- test harness ---------- */ + +interface Harness { + home: string + tmp: string + chatsDir: string + acpSessionsDir: string + /** Build a fake legacy session on disk; returns the on-disk path of store.db */ + placeLegacyStore: (cursorSessionId: string, opts?: { workspaceHash?: string; lastUsedModel?: string; name?: string }) => string + /** Make an in-memory Session row in the cursor flavor */ + makeSession: (overrides?: Partial) => Session + updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> + archiveCalls: string[] + probes: ReturnType[] + nextProbe: ReturnType | null +} + +function makeHarness(): Harness { + const home = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-home-')) + const tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-tmp-')) + const chatsDir = join(home, '.cursor', 'chats') + const acpSessionsDir = join(home, '.cursor', 'acp-sessions') + mkdirSync(chatsDir, { recursive: true }) + mkdirSync(acpSessionsDir, { recursive: true }) + + const updateCalls: Harness['updateCalls'] = [] + const archiveCalls: Harness['archiveCalls'] = [] + const probes: Harness['probes'] = [] + return { + home, + tmp, + chatsDir, + acpSessionsDir, + updateCalls, + archiveCalls, + probes, + nextProbe: null, + placeLegacyStore(cursorSessionId, opts = {}) { + const wsh = opts.workspaceHash ?? `wsh-${Math.random().toString(36).slice(2, 10)}` + const dir = join(chatsDir, wsh, cursorSessionId) + mkdirSync(dir, { recursive: true }) + const storePath = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path: storePath, + name: opts.name, + lastUsedModel: opts.lastUsedModel + }) + return storePath + }, + makeSession(overrides = {}) { + const sessionId = overrides.id ?? `sess-${Math.random().toString(36).slice(2, 8)}` + const cursorSessionId = (overrides.metadata as Metadata | undefined)?.cursorSessionId + ?? `cursor-${Math.random().toString(36).slice(2, 8)}` + const metadata: Metadata = { + path: '/workspace/example', + host: 'test-host', + flavor: 'cursor', + cursorSessionId, + ...(overrides.metadata ?? {}) + } + const base: Session = { + id: sessionId, + tag: sessionId, + namespace: 'default', + createdAt: 0, + updatedAt: 0, + seq: 0, + metadataVersion: 1, + agentStateVersion: 1, + metadata, + active: false, + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: null, + agentState: null, + todos: null, + todosUpdatedAt: null, + teamState: null, + teamStateUpdatedAt: null + } as unknown as Session + return { ...base, ...overrides, metadata } + } + } +} + +function cleanupHarness(h: Harness): void { + try { rmSync(h.home, { recursive: true, force: true }) } catch {} + try { rmSync(h.tmp, { recursive: true, force: true }) } catch {} +} + +function makeMigrator(h: Harness, probe: ReturnType | null, opts: { archiveSession?: (id: string) => Promise; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void } = {}): CursorLegacyMigrator { + return new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', // matches the test sessions' metadata.host + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: () => (probe ?? makeMockProbe()) as any, + awaitLockRelease: async () => true, + // Default to "no ACP transport active" so the migrator does not + // accidentally refuse on a real-world HAPI_HOME that happens to have + // a live lock during the test run (e.g. running on the operator's + // own machine while the dogfood agent is active). + isAgentAcpTransportActive: opts.isAgentAcpTransportActive ?? (() => ({ active: false, holderPid: null })), + // Default: acquire returns a no-op handle (we control the live + // lock check via isAgentAcpTransportActive instead). Tests + // simulating "lock unavailable" can return null here directly. + // Codex review #34 P2 v7. + acquireAcpActiveLock: opts.acquireAcpActiveLock ?? (() => ({ release() {} })), + // Default: no-op checkpoint. The real bun:sqlite checkpoint is + // exercised in integration tests; unit tests inject custom + // implementations to simulate post-checkpoint WAL growth. + // Codex review #34 P2 v8. + checkpointLegacyStore: opts.checkpointLegacyStore ?? (() => {}), + getCurrentSession: opts.getCurrentSession, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: opts.archiveSession ?? (async (id) => { h.archiveCalls.push(id) }), + updateSessionAfterMigrate: opts.updateOverride ?? ((sessionId, namespace, lastUsedModel) => { + h.updateCalls.push({ sessionId, namespace, lastUsedModel }) + return { ok: true } + }) + }) +} + +/* ---------- tests ---------- */ + +describe('findLegacyChatStore', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('finds the store.db under ~/.cursor/chats///', () => { + const storePath = h.placeLegacyStore('my-uuid', { workspaceHash: 'wsh-1' }) + const found = findLegacyChatStore('my-uuid', h.home) + expect(found).not.toBeNull() + expect(found?.storeDbPath).toBe(storePath) + expect(found?.workspaceHash).toBe('wsh-1') + }) + + it('returns null when the chat does not exist on disk', () => { + const found = findLegacyChatStore('non-existent-uuid', h.home) + expect(found).toBeNull() + }) + + it('returns null when ~/.cursor/chats itself does not exist', () => { + rmSync(join(h.home, '.cursor'), { recursive: true, force: true }) + const found = findLegacyChatStore('whatever', h.home) + expect(found).toBeNull() + }) + + it('scans multiple workspace-hash dirs to find the matching uuid', () => { + h.placeLegacyStore('uuid-a', { workspaceHash: 'wsh-a' }) + h.placeLegacyStore('uuid-b', { workspaceHash: 'wsh-b' }) + const found = findLegacyChatStore('uuid-b', h.home) + expect(found?.workspaceHash).toBe('wsh-b') + }) +}) + +describe('readLegacyMetaLastUsedModel', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('reads hex-encoded JSON meta record (legacy encoding)', () => { + const p = join(h.tmp, 'legacy.db') + buildSyntheticLegacyStore({ path: p, lastUsedModel: 'composer-2.5', name: 'chat 1', metaEncoding: 'hex' }) + const out = readLegacyMetaLastUsedModel(p) + expect(out).not.toBeNull() + expect(out?.lastUsedModel).toBe('composer-2.5') + expect(out?.name).toBe('chat 1') + }) + + it('reads raw JSON meta record (newer encoding)', () => { + const p = join(h.tmp, 'newer.db') + buildSyntheticLegacyStore({ path: p, lastUsedModel: 'gpt-5.3-codex', metaEncoding: 'json' }) + const out = readLegacyMetaLastUsedModel(p) + expect(out?.lastUsedModel).toBe('gpt-5.3-codex') + }) + + it('returns null on a missing store', () => { + const out = readLegacyMetaLastUsedModel(join(h.tmp, 'does-not-exist.db')) + expect(out).toBeNull() + }) +}) + +describe('CursorLegacyMigrator.migrateOne — refusals', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('refuses non-cursor sessions', async () => { + const session = h.makeSession({ metadata: { path: '/x', host: 'h', flavor: 'claude' } as Metadata }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('not_cursor_session') + }) + + it('refuses already-ACP sessions', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', cursorSessionProtocol: 'acp' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('already_acp') + }) + + it('refuses sessions with no cursorSessionId', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: undefined as unknown as string } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_cursor_session_id') + }) + + it('refuses sessions whose lifecycleState is "running" without forceArchiveRunning', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', lifecycleState: 'running' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('running_refused') + }) + + it('refuses sessions where session.active=true even without lifecycleState (Codex #34 P2)', async () => { + const session = h.makeSession({ + active: true, + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('running_refused') + }) + + it('refuses cursorSessionId values that fail basename validation (Codex #34 P2)', async () => { + for (const bad of ['../escape', 'has/slash', '.', '..', 'has\\backslash']) { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: bad } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_cursor_session_id') + } + }) + + it('refuses sessions recorded on a different host (Codex #34 P2)', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId: 'a' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('cross_host_session') + expect(out.message).toContain('other-machine') + }) + + it('refuses when the legacy on-disk store is missing', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'ghost-uuid' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_legacy_store_on_disk') + }) + + it('refuses when another agent acp transport is live (Codex #34 P1 / P2 v7)', async () => { + const cursorSessionId = 'acp-active-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Codex review #34 P2 v7: lock acquisition is the primary + // refuse-on signal now. isAgentAcpTransportActive is read + // only to format the holder pid in the refusal message. + acquireAcpActiveLock: () => null, + isAgentAcpTransportActive: () => ({ active: true, holderPid: 12345 }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('acp_transport_active') + expect(out.message).toContain('12345') + // Source untouched. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + }) + + it('refuses when ~/.cursor/acp-sessions// already exists (collision)', async () => { + const cursorSessionId = 'collision-uuid' + h.placeLegacyStore(cursorSessionId) + mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true }) + writeFileSync(join(h.acpSessionsDir, cursorSessionId, 'meta.json'), '{}') + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('target_already_exists') + }) +}) + +describe('CursorLegacyMigrator.migrateOne — happy path', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('cp + verify + flip + rm in order; populates outcome', async () => { + const cursorSessionId = 'happy-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId, { lastUsedModel: 'composer-2.5' }) + const probe = makeMockProbe() + const session = h.makeSession({ + id: 'happy-sess', + metadata: { path: '/workspace/example', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.acpSessionId).toBe(cursorSessionId) + expect(out.replayNotifications).toBe(17) + expect(out.lastUsedModelPreserved).toBe('composer-2.5') + expect(out.sourceRemoved).toBe(true) + + // ACP location populated. + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + expect(existsSync(acpStorePath)).toBe(true) + const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json') + expect(existsSync(sidecarPath)).toBe(true) + const sidecarText = require('node:fs').readFileSync(sidecarPath, 'utf8') as string + const sidecarObj = JSON.parse(sidecarText) as Record + expect(sidecarObj.schemaVersion).toBe(1) + expect(sidecarObj.cwd).toBe('/workspace/example') + + // Legacy source removed. + expect(existsSync(sourceStore)).toBe(false) + + // updateSessionAfterMigrate invoked. + expect(h.updateCalls).toHaveLength(1) + expect(h.updateCalls[0].sessionId).toBe('happy-sess') + expect(h.updateCalls[0].lastUsedModel).toBe('composer-2.5') + + // Probe used. + expect(probe.started).toBe(true) + expect(probe.stopped).toBe(true) + expect(probe.initializeCalls).toBe(1) + expect(probe.loadCalls).toBe(1) + expect(probe.promptCalls).toBe(1) + }) + + it('passes HOME and HAPI_HOME isolated to the fakeHome into the verify probe (tiann/hapi#824)', async () => { + // tiann/hapi#824: the verify probe must inherit a private HAPI_HOME + // so its child `agent acp` registers its lock in an isolated tmp dir + // — NOT in the host's $HAPI_HOME where peer agents may hold the + // global agent-acp-active lock. Without this isolation the auto- + // migration path on a busy machine would always refuse with + // acp_transport_active, defeating its own purpose. + const cursorSessionId = 'isolated-home-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/iso', host: 'h', flavor: 'cursor', cursorSessionId } + }) + let capturedEnv: NodeJS.ProcessEnv | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (env) => { + capturedEnv = env + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedEnv).not.toBeNull() + const env = capturedEnv as unknown as NodeJS.ProcessEnv + // The fakeHome path is generated inside verifyInTempHome under + // h.tmp; we don't need its exact value, only that HOME and + // HAPI_HOME point at the same path and that path is under our + // temp root (i.e. NOT the operator's real $HOME or $HAPI_HOME). + expect(typeof env.HOME).toBe('string') + expect(typeof env.HAPI_HOME).toBe('string') + expect(env.HOME).toBe(env.HAPI_HOME) + expect(env.HOME!.startsWith(h.tmp)).toBe(true) + // Defence in depth: the captured env must NOT leak the host's + // real HAPI_HOME (which could point at ~/.hapi or /tmp/hapi). + const realHapiHome = process.env.HAPI_HOME?.trim() || '' + if (realHapiHome.length > 0) { + expect(env.HAPI_HOME).not.toBe(realHapiHome) + } + }) + + it('passes metadata.homeDir (NOT deps.homeDir) as agentLookupHome to the verify probe (tiann/hapi#844)', async () => { + // tiann/hapi#844 upstream Codex Major: the default createProbe factory + // used `this.deps.homeDir()` for `agentLookupHome`, which on service- + // account hub deployments resolves to the hub user's $HOME — but the + // legacy store lives under the human user's home (metadata.homeDir). + // Earlier rounds wired `agentLookupHome` into the factory default + // but never threaded the resolved sourceHome through, so verification + // silently looked up `agent` under the wrong home and migrations + // fell back to legacy. The fix: createProbe takes a 2nd arg, and + // verifyInTempHome passes opts.sourceHome through. + const userHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-user-home-')) + try { + const hubHome = h.home // distinct from userHome + const cursorSessionId = 'service-account-uuid' + const userChatsDir = join(userHome, '.cursor', 'chats', 'wsh-svc', cursorSessionId) + mkdirSync(userChatsDir, { recursive: true }) + const sourceStore = join(userChatsDir, 'store.db') + buildSyntheticLegacyStore({ path: sourceStore }) + const userAcpDir = join(userHome, '.cursor', 'acp-sessions') + mkdirSync(userAcpDir, { recursive: true }) + const session = h.makeSession({ + metadata: { + path: '/workspace/svc', + host: 'h', + flavor: 'cursor', + cursorSessionId, + homeDir: userHome + } + }) + let capturedAgentLookupHome: string | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => hubHome, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (_env, agentLookupHome) => { + capturedAgentLookupHome = agentLookupHome + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedAgentLookupHome as unknown as string).toBe(userHome) + expect(capturedAgentLookupHome as unknown as string).not.toBe(hubHome) + } finally { + try { rmSync(userHome, { recursive: true, force: true }) } catch {} + } + }) + + it('falls back to deps.homeDir() for agentLookupHome when metadata.homeDir is absent (legacy session records)', async () => { + // Older session records may lack metadata.homeDir (the field was added + // in a later CLI rev). For those, the migrator falls back to + // this.deps.homeDir() for both the store lookup AND agentLookupHome. + const cursorSessionId = 'no-metadata-home-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/legacy', host: 'h', flavor: 'cursor', cursorSessionId } + }) + let capturedAgentLookupHome: string | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (_env, agentLookupHome) => { + capturedAgentLookupHome = agentLookupHome + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedAgentLookupHome as unknown as string).toBe(h.home) + }) + + it('--keep-source preserves the legacy source after success', async () => { + const cursorSessionId = 'keep-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { keepSource: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceRemoved).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('skipVerify skips ONLY the session/prompt step (load is still verified)', async () => { + const cursorSessionId = 'skipverify-uuid' + h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe() + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true }) + expect(out.ok).toBe(true) + // Codex review #34 P2: load must still run; only the prompt step is skipped. + expect(probe.started).toBe(true) + expect(probe.initializeCalls).toBe(1) + expect(probe.loadCalls).toBe(1) + expect(probe.promptCalls).toBe(0) + }) + + it('skipVerify still refuses on session/load failure', async () => { + const cursorSessionId = 'skipverify-loadfail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ loadResponse: err('corrupted store') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('lastUsedModel = null when the legacy meta record does not carry one', async () => { + const cursorSessionId = 'no-model-uuid' + h.placeLegacyStore(cursorSessionId) // no lastUsedModel + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.lastUsedModelPreserved).toBeNull() + expect(h.updateCalls[0].lastUsedModel).toBeNull() + }) +}) + +describe('CursorLegacyMigrator.migrateOne — rollback paths', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('rolls back the ACP placement when session/load fails', async () => { + const cursorSessionId = 'load-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ loadResponse: err('Session not found') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + + // Acp dir not created (verify is in temp HOME, so the real acp-sessions + // location was never touched). + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + expect(existsSync(acpStorePath)).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + // No hapi.db write. + expect(h.updateCalls).toHaveLength(0) + }) + + it('rolls back when session/prompt fails', async () => { + const cursorSessionId = 'prompt-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ promptResponse: err('agent acp died') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_prompt_failed') + expect(existsSync(sourceStore)).toBe(true) + expect(h.updateCalls).toHaveLength(0) + }) + + it('rolls back when the session is resumed mid-migration (Codex #34 P1)', async () => { + const cursorSessionId = 'resumed-mid-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: true, lifecycleState: 'running' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + // ACP placement rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('does NOT trip the resume-race recheck on stale lifecycleState=running after our own archive (Codex #34 P2 v5)', async () => { + // The force-archive flow archives synchronously (sets active=false) + // but the cleanup metadata write that flips lifecycleState to + // 'archived' may still be in-flight. The recheck must trust the + // active flag, not lifecycleState. + const cursorSessionId = 'stale-lifecycle-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Live runner at preflight (active=true), gets archived by us. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + archiveSession: async () => {}, + // After our archive: active=false (set by archive), but + // lifecycleState is still 'running' because the metadata + // cleanup write hasn't flushed yet. This should NOT refuse. + getCurrentSession: () => ({ active: false, lifecycleState: 'running' }) + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(h.updateCalls).toHaveLength(1) + }) + + it('still refuses the resume-race recheck when an EXTERNAL party set lifecycleState=running and wasActive=false (Codex #34 P2 v5)', async () => { + // If we did NOT archive and lifecycleState becomes running, + // someone else lifted the session back — that's a real race. + const cursorSessionId = 'external-resume-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Session NOT active/running at preflight — passes precheck. + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: false, lifecycleState: 'running' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + }) + + it('rolls back when the legacy store.db is touched during the migration window (Codex #34 P1 v3)', async () => { + const cursorSessionId = 'fingerprint-divergence-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + // Override createProbe to mutate the legacy source between + // checkpoint and the resume-race recheck. + const probe = { + ...makeMockProbe(), + start() { /* no-op */ }, + async loadSession() { + // Simulate a brief resume that wrote new turns to the + // legacy store after our checkpoint. + require('node:fs').appendFileSync(sourceStore, Buffer.from([0x00, 0x01, 0x02])) + return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 } + }, + async initialize() { return { ok: true as const, result: {} } }, + async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } }, + async stop() {}, + getStderr() { return '' }, + getNotifications() { return [] }, + clearNotifications() {} + } as unknown as ReturnType + const out = await makeMigrator(h, probe, {}).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/store\.db/) + // ACP placement rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('refuses early when WAL has content immediately after the checkpoint (Codex #34 P2 v8)', async () => { + // Codex review #34 P2 v8: a TRUNCATE-mode wal_checkpoint zeros + // the WAL. If a writer lands between checkpoint return and the + // fingerprint capture, the WAL grows above zero and our baseline + // would be poisoned (we copy main-file-only). The migrator must + // refuse rather than accept the post-resume state. + const cursorSessionId = 'wal-grew-post-checkpoint-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Simulate a writer landing in the gap: the checkpoint + // "returned" but a WAL with content has appeared right + // after — exactly the race the bot flagged. + checkpointLegacyStore: (path) => { + require('node:fs').writeFileSync(`${path}-wal`, Buffer.from([0xff, 0xff, 0xff, 0xff])) + } + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/between checkpoint and fingerprint/) + // No ACP placement, no source deletion. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back when a WAL sidecar appears during the migration window (Codex #34 P1 v4)', async () => { + const cursorSessionId = 'wal-divergence-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + // Override createProbe to CREATE a store.db-wal sidecar where + // none existed at fingerprint time. This is what a brief resume + // does: opens the store, writes a frame to WAL. + const probe = { + ...makeMockProbe(), + start() {}, + async loadSession() { + require('node:fs').writeFileSync(`${sourceStore}-wal`, Buffer.from([0xff, 0xff, 0xff])) + return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 } + }, + async initialize() { return { ok: true as const, result: {} } }, + async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } }, + async stop() {}, + getStderr() { return '' }, + getNotifications() { return [] }, + clearNotifications() {} + } as unknown as ReturnType + const out = await makeMigrator(h, probe, {}).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/store\.db-wal/) + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + }) + + it('rolls back when the atomic flip-time active check fires (Codex #34 P1 v2)', async () => { + // The migrator's earlier getCurrentSession recheck saw the session + // as inactive (default null), but the inner updateSessionAfterMigrate + // returns session_active — simulating the resume landing AFTER the + // recheck but inside the atomic flip. + const cursorSessionId = 'flip-time-active-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + updateOverride: () => ({ ok: false, reason: 'session_active' }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back when a concurrent migration already flipped protocol to acp (Codex #34 P1)', async () => { + const cursorSessionId = 'concurrent-flip-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: false, cursorSessionProtocol: 'acp' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('already_acp') + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back ACP placement when hapi.db metadata write fails', async () => { + const cursorSessionId = 'meta-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + updateOverride: () => ({ ok: false, reason: 'version_mismatch_or_missing' }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('metadata_write_failed') + // The ACP placement was rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) +}) + +describe('CursorLegacyMigrator.migrateOne — force-archive-then-migrate', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('archives a running session first, then proceeds', async () => { + const cursorSessionId = 'force-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // active=true triggers the archive RPC. Codex review #34 + // P2 v6: the lifecycleState alone is no longer enough. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + expect(h.archiveCalls).toEqual([session.id]) + }) + + it('does NOT call archiveSession on stale lifecycleState=running rows with no live runner (Codex #34 P2 v6)', async () => { + const cursorSessionId = 'stale-running-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // lifecycle says running but cache.active is false — the + // cleanup metadata write that flips 'running' → 'archived' + // was dropped (process crash before write). There is no + // live runner to archive. + active: false, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + // Critically: we did NOT call archive RPC. The metadata flip + // itself cleans up the stale lifecycle value. + expect(h.archiveCalls).toHaveLength(0) + }) + + it('does not archive when cross_host_session refusal would fire (Codex #34 P2 v2)', async () => { + const cursorSessionId = 'cross-host-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const archiveCalls: string[] = [] + const session = h.makeSession({ + // active=true so wasActive would normally trigger archive + // — proves the cross-host check correctly precedes it. + active: true, + metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + archiveSession: async (id) => { archiveCalls.push(id) } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('cross_host_session') + expect(archiveCalls).toHaveLength(0) + }) + + it('reserves and releases the ACP lock around the mutation window (Codex #34 P2 v7)', async () => { + const cursorSessionId = 'acp-lock-lifecycle-uuid' + h.placeLegacyStore(cursorSessionId) + let releaseCount = 0 + let acquireCount = 0 + const session = h.makeSession({ + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + acquireAcpActiveLock: () => { + acquireCount += 1 + return { release() { releaseCount += 1 } } + } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + // Lock was acquired exactly once and released exactly once even + // on the happy path. + expect(acquireCount).toBe(1) + expect(releaseCount).toBe(1) + }) + + it('releases the ACP lock even when migration refuses inside the locked window (Codex #34 P2 v7)', async () => { + const cursorSessionId = 'acp-lock-on-refusal-uuid' + h.placeLegacyStore(cursorSessionId) + // Pre-create the ACP target dir so target_already_exists fires + // INSIDE migrateOneWithLock (lock has been acquired by then). + const { mkdirSync } = require('node:fs') + mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true }) + let releaseCount = 0 + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + acquireAcpActiveLock: () => ({ release() { releaseCount += 1 } }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('target_already_exists') + expect(releaseCount).toBe(1) + }) + + it('does not archive when acp_transport_active refusal would fire (Codex #34 P2 v2 / P2 v7)', async () => { + const cursorSessionId = 'acp-active-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const archiveCalls: string[] = [] + const session = h.makeSession({ + // active=true so wasActive would normally trigger archive + // — proves the acp_transport_active check correctly precedes it. + active: true, + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Codex review #34 P2 v7: lock acquisition is reserved BEFORE + // archive. Returning null here is the new way to express + // "another agent acp transport is live". + acquireAcpActiveLock: () => null, + isAgentAcpTransportActive: () => ({ active: true, holderPid: 42 }), + archiveSession: async (id) => { archiveCalls.push(id) } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('acp_transport_active') + expect(archiveCalls).toHaveLength(0) + }) + + it('surfaces archive failures as archive_failed', async () => { + const cursorSessionId = 'archive-throws-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Live runner — wasActive=true triggers the archive RPC, + // which throws and we surface archive_failed. Codex #34 P2 v6. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + archiveSession: async () => { throw new Error('rpc gateway down') } + }) + const out = await migrator.migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('archive_failed') + expect(out.message).toContain('rpc gateway down') + }) +}) + +describe('CursorLegacyMigrator.migrateOne — telemetry', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('records a non-zero durationMs in every outcome', async () => { + const cursorSessionId = 'duration-uuid' + h.placeLegacyStore(cursorSessionId) + let t = 1_000_000 + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => (t += 25), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: () => makeMockProbe() as any, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + archiveSession: async () => {}, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.durationMs).toBeGreaterThan(0) + }) + + it('synthesized meta.json title comes from legacy meta name when present', async () => { + const cursorSessionId = 'title-uuid' + h.placeLegacyStore(cursorSessionId, { name: 'My Test Chat' }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json') + const sidecar = JSON.parse(require('node:fs').readFileSync(sidecarPath, 'utf8')) as Record + expect(sidecar.title).toBe('My Test Chat') + }) + + it('uses metadata.homeDir when present in preference to the hub HOME (Codex #34 P2)', async () => { + // Use a SEPARATE home dir than the harness's `h.home`, set on + // metadata. The legacy chat must live under the metadata-recorded + // home; the migrator should pick that path, not the hub HOME. + const ownerHome = mkdtempSync(join(tmpdir(), 'hapi-owner-home-')) + try { + const cursorSessionId = 'owner-home-uuid' + const ownerChatsDir = join(ownerHome, '.cursor', 'chats', 'wsh-owner', cursorSessionId) + mkdirSync(ownerChatsDir, { recursive: true }) + require('./fixtures/buildSyntheticLegacyStore').buildSyntheticLegacyStore({ + path: join(ownerChatsDir, 'store.db') + }) + // Hub HOME (h.home) has NO matching chat — only metadata.homeDir does. + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, homeDir: ownerHome } as Metadata + }) + // Re-route the migrator's resolution: even though h.home points to + // a temp dir without the chat, the migrator should resolve under + // ownerHome (metadata.homeDir). + // The acp-sessions placement targets the SAME ownerHome since + // home is now resolved from metadata. + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(existsSync(join(ownerHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true) + } finally { + try { rmSync(ownerHome, { recursive: true, force: true }) } catch {} + } + }) + + it('cp leaves the placed store.db non-empty (sanity)', async () => { + const cursorSessionId = 'sanity-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + const st = statSync(acpStorePath) + expect(st.size).toBeGreaterThan(0) + }) +}) diff --git a/hub/src/cursor/cursorLegacyMigrator.ts b/hub/src/cursor/cursorLegacyMigrator.ts new file mode 100644 index 000000000..829a52081 --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.ts @@ -0,0 +1,1043 @@ +/** + * Legacy stream-json → ACP migrator (transplant strategy). + * + * See tiann/hapi#824 and docs/plans/2026-06-06-cursor-legacy-to-acp-spike.md + * for the spike that established the design. Short version: + * + * 1. cursor-agent's `agent acp` resolves `session/load` against + * ~/.cursor/acp-sessions//{store.db, meta.json}. + * 2. The legacy stream-json flow stores chats at + * ~/.cursor/chats///store.db. + * 3. The on-disk SQLite schema is byte-identical between the two stores + * (blobs content-addressed Merkle tree + meta key-value). + * 4. Therefore: cp legacy store.db into the ACP location, synthesize the + * meta.json sidecar, verify session/load works, then flip HAPI's + * cursorSessionProtocol = 'acp' so the existing cursorAcpRemoteLauncher + * (already in #799) picks the session up on next resume. + * + * Per-session sequence (cp + verify + flip + rm): + * a) cp legacy store.db -> ~/.cursor/acp-sessions//store.db + * b) write meta.json sidecar + * c) verify by spawning `agent acp` in a temp $HOME pointing at a *copy* + * of the transplanted store, doing initialize + session/load + (optional) + * a trivial session/prompt + * d) ONLY if verify passes: flip cursorSessionProtocol in hapi.db, set + * session.model from legacy meta record's lastUsedModel, and rm the + * legacy source. + * e) If verify fails: rm the new ~/.cursor/acp-sessions// entry, + * leave the legacy store untouched. + * + * The verify is staged in a temp $HOME so the verify session/prompt never + * pollutes the operator's real acp-sessions store. After verify passes, the + * real placement is just a fresh cp of the original legacy store.db. + * + * Per orchestrator policy (Q1 in the spike report): + * - cp + verify + rm; the rm is gated on observable success + * - --keep-source preserves the legacy store after success + * - --force-archive-then-migrate archives a running session first + * + * The fork-side launcher in cli/src/cursor/cursorAcpRemoteLauncher.ts already + * routes on metadata.cursorSessionProtocol === 'acp' (set by this migrator). + * No launcher change is required. + */ + +import { join, dirname } from 'node:path' +import { homedir, hostname, tmpdir } from 'node:os' +import { mkdtempSync, copyFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, statSync, readdirSync, readFileSync, chmodSync } from 'node:fs' +import { Database } from 'bun:sqlite' + +import type { CursorMigrateOutcome, CursorMigrateRefusalReason } from '@hapi/protocol/apiTypes' +import type { Metadata } from '@hapi/protocol/schemas' +import type { Session } from '@hapi/protocol/types' +import { AcpVerifyProbe, tryAcquireAcpActiveLock, type AcpActiveLockHandle } from './acpVerifyProbe' + +/* ---------- types ---------- */ + +export interface CursorLegacyMigratorOptions { + keepSource?: boolean + forceArchiveRunning?: boolean + skipVerify?: boolean + /** Internal: max time to wait for a running session to release the store.db file lock after archive. */ + lockReleaseTimeoutMs?: number + /** Internal: max time the verify spawn is allowed in total. */ + verifyTimeoutMs?: number + /** Internal: the verify prompt body. Kept ultra-short to bound token cost. */ + verifyPromptText?: string +} + +export interface CursorLegacyMigratorDeps { + /** Resolve the operator's HOME dir. Override in tests. */ + homeDir?: () => string + /** + * Resolve the local hostname. Used to detect cross-host sessions + * (where the recorded `metadata.host` does not match the machine the + * hub is running on) — those sessions cannot be migrated because the + * legacy ~/.cursor/chats files exist on a different machine. + * Default: `process.env.HAPI_HOSTNAME || os.hostname()`. Codex + * review #34 P2. + */ + hostName?: () => string + /** + * Spawn factory for the verify probe. Override in tests to inject a mock probe. + * The second arg is the session-owner home (`metadata.homeDir`) resolved by + * `migrateOne`, which the default factory passes through to the probe as + * `agentLookupHome` so service-account hub deployments resolve `agent` under + * the human user's `~/.local/bin` rather than the hub user's. tiann/hapi#844 + * upstream Codex Major. + */ + createProbe?: (env: NodeJS.ProcessEnv, agentLookupHome: string) => AcpVerifyProbe + /** + * Optional escape hatch for the operator-driven CLI/REST flow that wants + * to reserve the global agent-acp-active lock for the entire migration + * window. Returns `null` if the lock is already held; the migrator then + * refuses with `acp_transport_active`. + * + * **Default in production: a no-op grant that never refuses.** The + * verify probe's child agent CLI now runs with an isolated HAPI_HOME + * (see verifyInTempHome), so the verify spawn does not race against any + * live ACP transport on the host. Refusing migration up-front when the + * host has other live transports — which is the common case on machines + * running peer agents — would make the auto-migration path unreachable + * for the operator who actually has 90+ legacy sessions to migrate. + * + * Tests can inject `() => null` to exercise the legacy refusal path. + * Operator CLI/REST callers can inject `tryAcquireAcpActiveLock(home)` + * if they want the conservative pre-isolation behavior back. + */ + acquireAcpActiveLock?: () => { release(): void } | null + /** + * Run `PRAGMA wal_checkpoint(TRUNCATE)` on the legacy store.db. + * Default: bun:sqlite checkpoint that refuses on busy=1 or partial + * apply. Tests can inject a no-op to simulate a writer landing + * between checkpoint return and the post-checkpoint fingerprint. + * Codex review #34 P2 v8. + */ + checkpointLegacyStore?: (storeDbPath: string) => void + /** Where to allocate the verify staging temp dir. Default: os.tmpdir(). */ + tmpDir?: () => string + /** Time source for telemetry. Default: Date.now. */ + now?: () => number + /** Optional hook to archive a running session. Required when forceArchiveRunning=true. */ + archiveSession?: (sessionId: string) => Promise + /** + * Wait for the archived session's store.db file lock to be released. + * Default: combined SQLite busy-probe + size-stability + minimum dwell + * time. The dwell is required because the hub cannot directly observe + * the runner subprocess exiting (it has no PID handle); without a + * minimum wait an idle runner with no SQLite write lock can pass the + * probe immediately while still in the middle of SIGTERM cleanup. + * Codex review #34 P1 v3. + */ + awaitLockRelease?: (storePath: string, timeoutMs: number) => Promise + /** + * Check whether ANY `agent acp` transport is registered as active in + * $HAPI_HOME/locks/agent-acp-active. Cursor's `agent` binary enforces + * single-instance semantics: spawning a second `agent acp` while one + * is live can SIGTERM the live one. Refuse the migration in that case + * because the verify-in-temp-HOME step would otherwise crash the + * operator's active Cursor ACP session. Codex review #34 P1. + * Default: read the lock dir under $HAPI_HOME (or tmpdir/hapi). + */ + isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null } + /** + * Re-read the latest session state from the hub cache, used to detect + * a resume that happened between preflight and the destructive steps. + * SyncEngine injects a real implementation; tests can inject a static + * sentinel. Codex review #34 P1: protects against the TOCTOU window + * where a session is resumed while migration is in flight. + */ + getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null + /** Logger sink. Default: silent. */ + logger?: { debug: (msg: string, ctx?: unknown) => void; info: (msg: string, ctx?: unknown) => void; warn: (msg: string, ctx?: unknown) => void; error: (msg: string, ctx?: unknown) => void } + /** Used to update hapi.db sessions.metadata.cursorSessionProtocol = 'acp' and session.model. */ + updateSessionAfterMigrate?: (sessionId: string, namespace: string, lastUsedModel: string | null) => UpdateAfterMigrateResult +} + +export type UpdateAfterMigrateResult = + | { ok: true } + | { ok: false; reason: 'version_mismatch_or_missing' } + | { ok: false; reason: 'session_active' } + +export interface LegacyStoreLocation { + workspaceHash: string + storeDbPath: string +} + +/* ---------- helpers ---------- */ + +const DEFAULT_VERIFY_PROMPT = 'Reply with exactly: ack' +const DEFAULT_VERIFY_TIMEOUT_MS = 120_000 +const DEFAULT_LOCK_RELEASE_TIMEOUT_MS = 5_000 +const DEFAULT_REPLAY_DRAIN_MS = 3_000 +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] + +function noopLogger() { + return { debug() {}, info() {}, warn() {}, error() {} } +} + +function refusal(sessionId: string, reason: CursorMigrateRefusalReason, message: string, start: number, now: () => number): CursorMigrateOutcome { + return { ok: false, sessionId, reason, message, durationMs: now() - start } +} + +/* ---------- public API ---------- */ + +/** + * Resolve the on-disk legacy ~/.cursor/chats///store.db + * for the given cursorSessionId. Scans workspace-hash dirs because HAPI does + * not record the workspace-hash; it is hashed from the original cwd by Cursor + * and not exposed via the agent CLI. + */ +export function findLegacyChatStore(cursorSessionId: string, home: string): LegacyStoreLocation | null { + const chatsRoot = join(home, '.cursor', 'chats') + if (!existsSync(chatsRoot)) return null + let entries: string[] + try { + entries = readdirSync(chatsRoot) + } catch { + return null + } + for (const wsh of entries) { + const candidate = join(chatsRoot, wsh, cursorSessionId, 'store.db') + try { + const st = statSync(candidate) + if (st.isFile()) { + return { workspaceHash: wsh, storeDbPath: candidate } + } + } catch { + // not in this wsh; keep scanning + } + } + return null +} + +/** + * Read `lastUsedModel` (and chat name) from a legacy/ACP store.db's `meta` + * record. Returns null if the store cannot be opened or the meta record is + * missing. + * + * NOTE: the meta value is stored as a hex-encoded UTF-8 JSON blob in older + * cursor-agent versions and as a plain JSON string in newer ones. We try both. + */ +export function readLegacyMetaLastUsedModel(storeDbPath: string): { name?: string; lastUsedModel?: string } | null { + let metaDb: Database | null = null + try { + metaDb = new Database(storeDbPath, { readonly: true }) + const row = metaDb.prepare('SELECT cast(value as TEXT) as v FROM meta LIMIT 1').get() as { v?: string } | undefined + if (!row?.v) return null + const decoded = decodeMetaValue(row.v) + if (!decoded) return null + return { + name: typeof decoded.name === 'string' ? decoded.name : undefined, + lastUsedModel: typeof decoded.lastUsedModel === 'string' && decoded.lastUsedModel.trim().length > 0 ? decoded.lastUsedModel.trim() : undefined + } + } catch { + return null + } finally { + try { metaDb?.close() } catch {} + } +} + +function decodeMetaValue(value: string): Record | null { + // Try JSON first (newer ACP stores) + if (value.startsWith('{')) { + try { return JSON.parse(value) as Record } catch {} + } + // Otherwise try hex-encoded UTF-8 JSON (older legacy stores). + if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) { + try { + const buf = Buffer.from(value, 'hex') + const text = buf.toString('utf8') + if (text.startsWith('{')) { + return JSON.parse(text) as Record + } + } catch {} + } + return null +} + +/** + * UUID-ish pattern: a cursor session id MUST be a basename that cannot + * escape the chats/acp-sessions trees via path traversal. Codex review #34 + * P2: validate before any join(). + */ +const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/ + +/** + * Pre-flight: return null if the session can be migrated; return a refusal + * outcome if it cannot. Pure: no side effects. + */ +export function preflightSession(session: Session | undefined, now: () => number, opts: { forceArchiveRunning?: boolean }): CursorMigrateOutcome | null { + const start = now() + if (!session) { + return refusal('(unknown)', 'internal_error', 'session not found', start, now) + } + const sessionId = session.id + const metadata = session.metadata + if (!metadata || metadata.flavor !== 'cursor') { + return refusal(sessionId, 'not_cursor_session', 'session.metadata.flavor must be "cursor"', start, now) + } + if (metadata.cursorSessionProtocol === 'acp') { + return refusal(sessionId, 'already_acp', 'session already runs over ACP; nothing to migrate', start, now) + } + if (typeof metadata.cursorSessionId !== 'string' || metadata.cursorSessionId.trim().length === 0) { + return refusal(sessionId, 'no_cursor_session_id', 'session.metadata.cursorSessionId is missing', start, now) + } + const trimmed = metadata.cursorSessionId.trim() + if (!CURSOR_SESSION_ID_RE.test(trimmed) || trimmed === '.' || trimmed === '..') { + return refusal(sessionId, 'no_cursor_session_id', `cursorSessionId '${trimmed}' fails basename validation`, start, now) + } + // Block both lifecycleState==='running' AND session.active (legacy rows + // may lack lifecycleState but still be active in the cache). Codex + // review #34 P2. + const lifecycle = typeof metadata.lifecycleState === 'string' ? metadata.lifecycleState : undefined + const isActive = lifecycle === 'running' || session.active === true + if (isActive && !opts.forceArchiveRunning) { + return refusal(sessionId, 'running_refused', 'session is active; archive first or pass forceArchiveRunning', start, now) + } + return null +} + +/* ---------- main entry ---------- */ + +export class CursorLegacyMigrator { + private readonly opts: Required> + private readonly deps: Required> + & Pick + + constructor(opts: CursorLegacyMigratorOptions, deps: CursorLegacyMigratorDeps) { + this.opts = { + lockReleaseTimeoutMs: opts.lockReleaseTimeoutMs ?? DEFAULT_LOCK_RELEASE_TIMEOUT_MS, + verifyTimeoutMs: opts.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS, + verifyPromptText: opts.verifyPromptText ?? DEFAULT_VERIFY_PROMPT + } + this.deps = { + homeDir: deps.homeDir ?? (() => homedir()), + hostName: deps.hostName ?? (() => process.env.HAPI_HOSTNAME?.trim() || hostname()), + createProbe: deps.createProbe ?? ((env, agentLookupHome) => new AcpVerifyProbe({ + env, + skipLockAcquire: true, + // tiann/hapi#844 upstream Codex Major: `migrateOne` resolves the + // legacy store under `metadata.homeDir` (the recorded session- + // owner home), so the probe MUST also look up `agent` under + // that same home. Earlier rounds plumbed `agentLookupHome` into + // the factory default using `this.deps.homeDir()` (the HUB's + // home), which falls back to the hub user's `~/.local/bin` on + // service-account deployments where the human installed Cursor + // under a different account. The caller (`verifyInTempHome`) + // now passes the resolved `sourceHome` through to keep the + // store and the binary discovery rooted in the same home. + agentLookupHome + })), + // Default: never refuse based on the global lock. The verify probe + // is isolated via HAPI_HOME override in verifyInTempHome, so the + // host's live ACP transports cannot block migration. Operator + // CLI/REST callers that want the pre-isolation conservative + // behavior can inject `() => tryAcquireAcpActiveLock(home)`. + acquireAcpActiveLock: deps.acquireAcpActiveLock ?? (() => ({ release() {} })), + checkpointLegacyStore: deps.checkpointLegacyStore ?? defaultCheckpointLegacyStore, + tmpDir: deps.tmpDir ?? (() => tmpdir()), + now: deps.now ?? (() => Date.now()), + awaitLockRelease: deps.awaitLockRelease ?? defaultAwaitLockRelease, + isAgentAcpTransportActive: deps.isAgentAcpTransportActive ?? defaultIsAgentAcpTransportActive, + // Default: cannot detect a resume — return null so the recheck + // is a no-op. SyncEngine injects a real impl that reads the + // session cache. Codex review #34 P1. + getCurrentSession: deps.getCurrentSession ?? (() => null), + logger: deps.logger ?? noopLogger(), + archiveSession: deps.archiveSession, + updateSessionAfterMigrate: deps.updateSessionAfterMigrate + } + } + + /** + * Migrate a single legacy cursor session in place. Side-effecting: writes + * to ~/.cursor/acp-sessions/, conditionally writes hapi.db (via injected + * updateSessionAfterMigrate), conditionally removes the legacy source. + */ + async migrateOne(session: Session, opts: CursorLegacyMigratorOptions): Promise { + const start = this.deps.now() + const log = this.deps.logger + + const pre = preflightSession(session, this.deps.now, { forceArchiveRunning: opts.forceArchiveRunning }) + if (pre) return pre + + // Type-narrow the metadata fields we use below. + const metadata = session.metadata as Metadata + const cursorSessionId = metadata.cursorSessionId as string + const cwd = metadata.path + + // ALL preconditions that could refuse must run BEFORE archive + // and BEFORE any other side effect. Otherwise a bulk run with + // --force-archive-running could kill a session that we then + // refuse to migrate (e.g. cross-host, acp_transport_active). + // Codex review #34 P2 v2. + + // Cross-host check: if the session was recorded on a different + // machine, its ~/.cursor/chats lives there, not here. + const recordedHost = typeof metadata.host === 'string' ? metadata.host.trim() : '' + const localHost = this.deps.hostName().trim() + if (recordedHost && localHost && recordedHost !== localHost) { + return refusal(session.id, 'cross_host_session', `session recorded host=${recordedHost} does not match local hub host=${localHost}; cannot migrate a session whose filesystem lives on a different machine`, start, this.deps.now) + } + + // ACP transport check + reservation. We used to do this as a + // point-in-time check followed by an internal lock acquire + // inside verifyInTempHome(). That left a gap: another agent + // acp could start between this check and the verify probe's + // spawn, and a --force-archive-running migration would kill the + // legacy session in that gap before refusing. Codex review + // #34 P2 v7: acquire the global agent-acp-active lock NOW and + // hold it across the entire mutation window. The verify probe + // is told skipLockAcquire=true so it inherits our hold. + let acpLock: { release(): void } | null = null + try { + acpLock = this.deps.acquireAcpActiveLock() + } catch (err) { + return refusal(session.id, 'internal_error', `agent-acp-active lock acquisition failed: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + if (acpLock === null) { + const holder = this.deps.isAgentAcpTransportActive() + return refusal(session.id, 'acp_transport_active', `another agent acp transport is registered active (holder pid=${holder.holderPid ?? '?'}); refusing to verify-migrate to avoid SIGTERMing the live ACP session — close active Cursor ACP sessions and retry`, start, this.deps.now) + } + try { + return await this.migrateOneWithLock(session, opts, start, metadata, cursorSessionId, cwd, log) + } finally { + acpLock.release() + } + } + + private async migrateOneWithLock( + session: Session, + opts: CursorLegacyMigratorOptions, + start: number, + metadata: Metadata, + cursorSessionId: string, + cwd: string, + log: NonNullable + ): Promise { + + // Resolve $HOME: prefer the recorded session owner's home from + // metadata.homeDir (populated by cli/src/agent/sessionFactory.ts) + // because the hub process may run under a service account whose + // HOME differs from the human-user account that created the + // Cursor session. Fall back to the hub's homeDir() when the + // metadata field is absent (older session records). + // Codex review #34 P2. + const recordedHome = typeof metadata.homeDir === 'string' && metadata.homeDir.trim().length > 0 + ? metadata.homeDir.trim() + : null + const home = recordedHome ?? this.deps.homeDir() + + // Locate the legacy store.db on disk BEFORE we archive. If the + // local filesystem has no such file, we have nothing to migrate + // and there's no reason to kill the session. + const legacy = findLegacyChatStore(cursorSessionId, home) + if (!legacy) { + return refusal(session.id, 'no_legacy_store_on_disk', `~/.cursor/chats/*/${cursorSessionId}/store.db not found under ${home}`, start, this.deps.now) + } + + // Pre-flight: refuse if the ACP target dir already exists. Also + // moved BEFORE archive to avoid killing a session whose target + // collision would refuse anyway. + const acpSessionDir = join(home, '.cursor', 'acp-sessions', cursorSessionId) + if (existsSync(acpSessionDir)) { + return refusal(session.id, 'target_already_exists', `~/.cursor/acp-sessions/${cursorSessionId}/ already exists; refusing to overwrite`, start, this.deps.now) + } + + // Handle force-archive on a live runner. We pre-flighted that + // running/active is allowed only with forceArchiveRunning. Gate + // the archive RPC on session.active === true: a stale + // lifecycleState='running' on an inactive cache row means the + // metadata cleanup write hasn't flushed yet — there is no live + // runner to archive, and archiveSession() would fail with a + // no-registered-handler. The metadata flip itself will clean up + // the stale lifecycle value when it writes cursorSessionProtocol. + // Codex review #34 P2 v6. + const wasActive = session.active === true + if (wasActive) { + if (!this.deps.archiveSession) { + return refusal(session.id, 'internal_error', 'forceArchiveRunning requested but archiveSession dependency not configured', start, this.deps.now) + } + try { + log.info('[migrator] archiving running session before migrate', { sessionId: session.id }) + await this.deps.archiveSession(session.id) + } catch (err) { + return refusal(session.id, 'archive_failed', err instanceof Error ? err.message : String(err), start, this.deps.now) + } + } else if (metadata.lifecycleState === 'running') { + log.info('[migrator] migrating stale lifecycle=running row without archive RPC (no live runner)', { sessionId: session.id }) + } + + // If we just archived an active session, wait for the legacy + // runner's writes to settle. The naive signal — sessionCache.active + // flipping false — is bogus here because archiveSession() calls + // handleSessionEnd() synchronously, so the cache flag is set to + // false BEFORE the runner subprocess has exited and released its + // file descriptors. The hub does not track runner subprocess PIDs + // we could process.kill(pid, 0), so we cannot directly observe + // the runner's exit. + // + // What we DO have: + // (a) SQLite BEGIN IMMEDIATE busy-probe — true while another + // connection holds a write transaction + // (b) size-stability poll on store.db — true once writes settle + // (c) minimum dwell time — fail-safe for the case where the + // runner is idle (no write txn) but still mid-shutdown: + // (a)+(b) can both pass while the subprocess is still in + // SIGTERM cleanup. The dwell guarantees we waited at least + // this long after the archive call. + // + // Codex review #34 P1 v3: removed the previous awaitSessionInactive + // step that polled cache.active — it was self-mutated by the + // archive call so could never block. Replaced with a real minimum + // dwell inside awaitLockRelease itself. + if (wasActive) { + const released = await this.deps.awaitLockRelease(legacy.storeDbPath, this.opts.lockReleaseTimeoutMs) + if (!released) { + return refusal(session.id, 'lock_release_timeout', `legacy store.db file lock not released within ${this.opts.lockReleaseTimeoutMs}ms`, start, this.deps.now) + } + } + + // Read legacy meta for lastUsedModel (best-effort) BEFORE we mutate anything. + const metaInfo = readLegacyMetaLastUsedModel(legacy.storeDbPath) ?? {} + const lastUsedModel = metaInfo.lastUsedModel ?? null + + // Flush the legacy WAL into store.db so a "cp main-file-only" copy + // is complete. Without this, un-checkpointed transactions live in + // store.db-wal and would either be stale-in-target or silently lost + // when the cleanup step removes the WAL sibling. Codex review #34 + // P1: addresses the transplant-WAL-loss case. + try { + this.deps.checkpointLegacyStore(legacy.storeDbPath) + } catch (err) { + return refusal(session.id, 'internal_error', `wal_checkpoint failed before transplant: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + + // Codex review #34 P2 v8: TRUNCATE-mode checkpoint zeroes the WAL. + // If we observe WAL bytes BEFORE capturing the baseline fingerprint, + // a writer landed between checkpoint return and this stat — that + // writer's frames are NOT in store.db (we copy main-file-only), + // and accepting them as baseline would let the post-fingerprint + // match pass and the cleanup delete the legacy WAL with those + // frames lost. Refuse rather than baseline-poisoned migrate. + try { + const walSt = statSync(`${legacy.storeDbPath}-wal`) + if (walSt.size > 0) { + return refusal(session.id, 'legacy_store_modified_during_migrate', `store.db-wal grew to ${walSt.size} bytes between checkpoint and fingerprint capture; a writer resumed during the migration window — refusing baseline-poisoned transplant`, start, this.deps.now) + } + } catch (err) { + // WAL absent (most common: TRUNCATE removed it) — fine. + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOENT') { + log.warn('[migrator] could not stat store.db-wal post-checkpoint', { sessionId: session.id, err: err instanceof Error ? err.message : String(err) }) + } + } + + // Capture a fingerprint of the legacy store.db AND its WAL/SHM + // sidecars post-checkpoint. Codex review #34 P1 v4: the WAL is + // the place SQLite stages new commits before they merge into the + // main file. A brief resume can write turns to store.db-wal + // while leaving store.db's mtime/size unchanged. We must + // fingerprint the WAL sidecar too — appearance, size change, or + // mtime change all indicate post-checkpoint writes that our cp + // missed. + type FileFp = { exists: true; mtimeMs: number; size: number } | { exists: false } + const fpOf = (p: string): FileFp => { + try { + const st = statSync(p) + return { exists: true, mtimeMs: st.mtimeMs, size: st.size } + } catch { + return { exists: false } + } + } + const fpEqual = (a: FileFp, b: FileFp): boolean => { + if (a.exists !== b.exists) return false + if (!a.exists) return true + // b.exists is also true at this point. + return (b as { exists: true; mtimeMs: number; size: number }).mtimeMs === a.mtimeMs + && (b as { exists: true; mtimeMs: number; size: number }).size === a.size + } + const preFingerprint = { + main: fpOf(legacy.storeDbPath), + wal: fpOf(`${legacy.storeDbPath}-wal`), + shm: fpOf(`${legacy.storeDbPath}-shm`) + } + if (!preFingerprint.main.exists) { + log.warn('[migrator] could not stat legacy store post-checkpoint; skipping pre/post fingerprint check', { sessionId: session.id }) + } + + // Verify-by-temp-home: build a throwaway $HOME, place a copy of the + // legacy store.db there, drive initialize + session/load. The + // session/prompt step (which exercises the agent's response loop) is + // gated on the !skipVerify flag because it requires a live model + // and may legitimately fail on policy-restricted sessions. We ALWAYS + // drive session/load — that's the cheapest reliable proof the + // transplant is loadable. Codex review #34 P2: skipVerify must not + // skip session/load. + let replayNotifications = 0 + const verifyResult = await this.verifyInTempHome(legacy.storeDbPath, cursorSessionId, cwd, { runPrompt: !opts.skipVerify, sourceHome: home }) + if (verifyResult.kind === 'transport_lock_held') { + // Lost the atomic-lock race against another verify probe. + // Codex review #34 P2 v2. + return refusal(session.id, 'acp_transport_active', verifyResult.message, start, this.deps.now) + } + if (verifyResult.kind === 'load_failed') { + return refusal(session.id, 'verify_load_failed', verifyResult.message, start, this.deps.now) + } + if (verifyResult.kind === 'prompt_failed') { + return refusal(session.id, 'verify_prompt_failed', verifyResult.message, start, this.deps.now) + } + replayNotifications = verifyResult.replayNotifications + + // Place the real ACP-sessions entry. cp, not mv - reversible. Atomic + // dir-create (no `recursive: true`) so two concurrent migrate calls + // for the same session cannot both pass the existsSync check and + // mkdir, with one then clobbering the other's store. The parent + // ~/.cursor/acp-sessions exists by precondition (cursor-agent + // creates it on first run; we create it here if absent). + // Codex review #34 P2: atomic target creation. + mkdirSync(join(home, '.cursor', 'acp-sessions'), { recursive: true }) + let acpDirCreated = false + try { + try { + // Codex #34 P2 (round 13): explicit 0o700 mode so the + // session dir is private regardless of umask. On a multi- + // user host with a default 022 umask and a non-private + // ~/.cursor, mkdir without an explicit mode produced 0755 + // — every local user could traverse and read transcripts. + mkdirSync(acpSessionDir, { recursive: false, mode: 0o700 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/EEXIST/.test(msg)) { + return refusal(session.id, 'target_already_exists', `~/.cursor/acp-sessions/${cursorSessionId}/ already exists (race with concurrent migrate); refusing to overwrite`, start, this.deps.now) + } + throw err + } + acpDirCreated = true + copyFileSync(legacy.storeDbPath, join(acpSessionDir, 'store.db')) + // Codex #34 P2 (round 13): copyFileSync inherits the source + // file's mode bits, which historically might be 0o644. Force + // 0o600 on the transplanted store so transcript contents are + // owner-only readable. + try { chmodSync(join(acpSessionDir, 'store.db'), 0o600) } catch {} + const sidecarTitle = metaInfo.name && metaInfo.name.trim().length > 0 + ? metaInfo.name.trim() + : undefined + const sidecar: Record = { + schemaVersion: 1, + cwd + } + if (sidecarTitle) sidecar.title = sidecarTitle + writeFileSync(join(acpSessionDir, 'meta.json'), JSON.stringify(sidecar), { mode: 0o600 }) + } catch (err) { + if (acpDirCreated) { + // Only rollback the dir we own (acpDirCreated implies the + // exclusive mkdir succeeded above). + tryRm(acpSessionDir) + } + return refusal(session.id, 'internal_error', `failed to place ACP session: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + + // Resume-race recheck. Between preflight and now, the session + // could have been resumed via the existing CLI/web/Telegram paths + // (especially if --force-archive-running was used: archive returns + // immediately, so a quick automation could un-archive). The + // resume path reads cursorSessionProtocol === 'legacy' from + // hapi.db and spawns a stream-json runner against the legacy + // store.db — which we are about to remove. Refuse the flip in + // that case so the running session is not amputated mid-write. + // Codex review #34 P1. + // + // Codex review #34 P2 v5: when WE are the one who just archived + // the session (wasActive=true), the cleanup metadata write that + // flips lifecycleState 'running' → 'archived' may still be in + // flight — handleSessionEnd() runs after killThisHappy() returns. + // In that window the cache shows active=false (set by archive + // synchronously) but lifecycleState is still 'running'. That is + // OUR archive completing, NOT a resume race. Trust the active + // flag in this case; awaitLockRelease's dwell+busy-probe is the + // real safety net against an in-flight runner. + const latest = this.deps.getCurrentSession(session.id, session.namespace) + if (latest && latest.active === true) { + tryRm(acpSessionDir) + return refusal(session.id, 'session_resumed_during_migrate', `session became active during migration (active=true lifecycleState=${latest.lifecycleState ?? 'n/a'}); rolled back ACP placement`, start, this.deps.now) + } + if (latest && !wasActive && latest.lifecycleState === 'running') { + // Lifecycle says running but active=false and we did NOT + // archive this session ourselves. Something external lifted + // it back to running between preflight and now (rare but + // possible if the session was preflight-archived and then + // a peer un-archived it via the lifecycle API). + tryRm(acpSessionDir) + return refusal(session.id, 'session_resumed_during_migrate', `session lifecycle became running during migration (lifecycleState=running, active=false, wasActive=false); rolled back ACP placement`, start, this.deps.now) + } + if (latest && latest.cursorSessionProtocol === 'acp') { + // Someone else migrated this session concurrently. Roll back + // our placement; the other migration's target is canonical. + tryRm(acpSessionDir) + return refusal(session.id, 'already_acp', 'session protocol flipped to acp by a concurrent migration; rolled back', start, this.deps.now) + } + + // Fingerprint check: an archived legacy session could be resumed + // AFTER our checkpoint, write turns to store.db OR store.db-wal, + // then exit before our active recheck above. The active flag + // would already be false but the legacy files would have diverged + // from the copy we just placed at the ACP target. Refuse the + // flip in that case so we don't transplant a stale snapshot. + // Codex review #34 P1 v3+v4 (now covers WAL/SHM sidecars). + if (preFingerprint.main.exists) { + const postFingerprint = { + main: fpOf(legacy.storeDbPath), + wal: fpOf(`${legacy.storeDbPath}-wal`), + shm: fpOf(`${legacy.storeDbPath}-shm`) + } + const changed = ( + !fpEqual(preFingerprint.main, postFingerprint.main) + || !fpEqual(preFingerprint.wal, postFingerprint.wal) + || !fpEqual(preFingerprint.shm, postFingerprint.shm) + ) + if (changed) { + tryRm(acpSessionDir) + const fmt = (label: string, pre: FileFp, post: FileFp) => `${label}: pre=${JSON.stringify(pre)} post=${JSON.stringify(post)}` + return refusal(session.id, 'legacy_store_modified_during_migrate', `legacy store changed during migration window — ${fmt('store.db', preFingerprint.main, postFingerprint.main)}, ${fmt('store.db-wal', preFingerprint.wal, postFingerprint.wal)}, ${fmt('store.db-shm', preFingerprint.shm, postFingerprint.shm)}; rolled back ACP placement`, start, this.deps.now) + } + } + + // Flip metadata. We rely on a caller-provided updater because the + // migrator must not import the hub Store directly (keeps the module + // pure for unit testing). + if (!this.deps.updateSessionAfterMigrate) { + tryRm(acpSessionDir) + return refusal(session.id, 'internal_error', 'updateSessionAfterMigrate dependency not configured', start, this.deps.now) + } + const updateResult = this.deps.updateSessionAfterMigrate(session.id, session.namespace, lastUsedModel) + if (!updateResult.ok) { + tryRm(acpSessionDir) + // Distinguish the atomic active-check failure from the + // metadata-version mismatch case so operators know which + // recovery path applies. Codex review #34 P1 v2. + if (updateResult.reason === 'session_active') { + return refusal(session.id, 'session_resumed_during_migrate', 'session became active inside the metadata flip (atomic check); rolled back ACP placement', start, this.deps.now) + } + return refusal(session.id, 'metadata_write_failed', `hapi.db write failed: ${updateResult.reason}`, start, this.deps.now) + } + + // Remove source unless --keep-source. The rm is the LAST step; if it + // fails, the migration is still considered successful because the ACP + // target is intact and metadata is flipped. + let sourceRemoved = false + if (!opts.keepSource) { + try { + rmSync(legacy.storeDbPath, { force: true }) + // Also drop SQLite sidecars if present (WAL + SHM). + tryRm(`${legacy.storeDbPath}-wal`) + tryRm(`${legacy.storeDbPath}-shm`) + // ONLY rmdir the parent if empty. We never recursively delete + // unknown files - a future cursor-agent version that drops + // additional artifacts in the chat dir would otherwise see + // them silently destroyed. + try { rmdirSync(dirname(legacy.storeDbPath)) } catch {} + sourceRemoved = true + log.info('[migrator] removed legacy source', { sessionId: session.id, path: legacy.storeDbPath }) + } catch (err) { + log.warn('[migrator] legacy source rm failed (target intact, treating as success)', { sessionId: session.id, error: err instanceof Error ? err.message : String(err) }) + } + } + + return { + ok: true, + sessionId: session.id, + acpSessionId: cursorSessionId, + replayNotifications, + durationMs: this.deps.now() - start, + lastUsedModelPreserved: lastUsedModel, + sourceRemoved + } + } + + /** + * Spawn `agent acp` against a temp $HOME, copy auth files, place the + * legacy store.db at /.cursor/acp-sessions//store.db + meta.json, + * drive initialize + session/load + (default) one tiny session/prompt. + * Returns a structured outcome; ALWAYS cleans up the temp dir. + */ + private async verifyInTempHome( + legacyStoreDbPath: string, + cursorSessionId: string, + cwd: string, + opts: { runPrompt: boolean; sourceHome: string } + ): Promise<{ kind: 'ok'; replayNotifications: number } | { kind: 'load_failed'; message: string } | { kind: 'prompt_failed'; message: string } | { kind: 'transport_lock_held'; message: string }> { + const tmpRoot = mkdtempSync(join(this.deps.tmpDir(), 'hapi-acp-verify-')) + const fakeHome = tmpRoot + const fakeAcpSessionDir = join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(legacyStoreDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync(join(fakeAcpSessionDir, 'meta.json'), JSON.stringify({ schemaVersion: 1, cwd })) + + // Copy auth files from the operator's real ~/.cursor into the temp $HOME. + // Auth tokens are read by `agent acp` at startup; missing files just + // mean no auth, which is fine for session/load (no-network) but breaks + // session/prompt. We try our best; the prompt step degrades gracefully + // if not authed. + const realCursor = join(opts.sourceHome, '.cursor') + const fakeCursor = join(fakeHome, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + + // Isolate the verify spawn from the operator's real ~/.cursor + // tree AND from the host's HAPI_HOME lock space. On POSIX, HOME + // is the only relevant variable for the ~/.cursor lookup. On + // Windows, `agent` may resolve the user profile from + // USERPROFILE, HOMEDRIVE+HOMEPATH instead — so override all + // three to point at the fake home. Codex review #34 P2: + // without HOME override the verify could touch the real .cursor + // tree. + // + // HAPI_HOME override (added for the auto-migration flow): the + // verify probe's child agent-cli registers an `agent-acp-active` + // lock at `/locks/agent-acp-active/`. The host's real + // HAPI_HOME is shared with every other live ACP transport on the + // machine (peer agents, IDE chats), so the verify probe would + // collide with them. Pointing the child's HAPI_HOME at the same + // per-verify temp dir as HOME gives the probe its own private + // lock space, completely isolated from anything else running. + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: fakeHome, + HAPI_HOME: fakeHome, + NO_COLOR: '1' + } + if (process.platform === 'win32') { + env.USERPROFILE = fakeHome + // Best-effort HOMEDRIVE / HOMEPATH split. If fakeHome lacks + // a drive letter (unusual on win32 but possible in tests), + // leave HOMEDRIVE unset and put the whole path in HOMEPATH. + const driveMatch = /^[A-Za-z]:/.exec(fakeHome) + if (driveMatch) { + env.HOMEDRIVE = driveMatch[0] + env.HOMEPATH = fakeHome.slice(2) + } else { + env.HOMEDRIVE = '' + env.HOMEPATH = fakeHome + } + } + const probe = this.deps.createProbe(env, opts.sourceHome) + const verifyStart = this.deps.now() + const verifyDeadline = verifyStart + this.opts.verifyTimeoutMs + try { + try { + probe.start() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/agent-acp-active lock is held/.test(msg)) { + return { kind: 'transport_lock_held', message: msg } + } + throw err + } + const initResp = await probe.initialize(remainingTime(verifyDeadline, this.deps.now)) + if (!initResp.ok) { + return { kind: 'load_failed', message: `initialize failed: ${initResp.error.message}` } + } + const loadOut = await probe.loadSession( + { sessionId: cursorSessionId, cwd, mcpServers: [] }, + DEFAULT_REPLAY_DRAIN_MS, + remainingTime(verifyDeadline, this.deps.now) + ) + if (!loadOut.response.ok) { + return { kind: 'load_failed', message: `session/load failed: ${loadOut.response.error.message}` } + } + + // Send the verify prompt only when the caller requested it + // (skipVerify=false). The load above is always run because it + // is the cheapest reliable proof the transplant is loadable. + if (opts.runPrompt) { + const promptOut = await probe.prompt( + { sessionId: cursorSessionId, text: this.opts.verifyPromptText }, + Math.max(15_000, remainingTime(verifyDeadline, this.deps.now)) + ) + if (!promptOut.response.ok) { + return { kind: 'prompt_failed', message: `session/prompt failed: ${promptOut.response.error.message}` } + } + } + + return { kind: 'ok', replayNotifications: loadOut.notificationCount } + } finally { + await probe.stop() + } + } finally { + tryRm(tmpRoot) + } + } +} + +function remainingTime(deadline: number, now: () => number): number { + return Math.max(1_000, deadline - now()) +} + +function tryRm(path: string): void { + try { + rmSync(path, { recursive: true, force: true }) + } catch { + // best-effort; caller logs + } +} + +/** + * Open the legacy store and flush the WAL into the main file with + * `PRAGMA wal_checkpoint(TRUNCATE)`. Idempotent: on non-WAL stores the + * checkpoint is a no-op (SQLite returns busy=0,log=-1,checkpointed=-1). + * + * Codex review #34 P1: a busy=1 result means another connection blocked + * the checkpoint; copying only store.db would lose WAL pages. We treat + * busy=1 as an error so the caller surfaces a refusal rather than + * proceeding with a partial copy. Caller is expected to ensure no other + * process has the DB open (pre-flight: lifecycleState not 'running'; + * archive-then-wait-for-lock-release for the force flag). + */ +function defaultCheckpointLegacyStore(storeDbPath: string): void { + const db = new Database(storeDbPath, { readwrite: true }) + try { + const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get() as { busy?: number; log?: number; checkpointed?: number } | undefined + if (row?.busy === 1) { + throw new Error('wal_checkpoint reported busy=1 - another connection has the legacy store open; refusing to copy partial WAL') + } + // For TRUNCATE mode, a fully-merged WAL is signaled by log === 0 AND + // checkpointed === 0 (or both -1 on non-WAL stores). If log !== -1 + // (so we were in WAL) and log !== checkpointed, some frames were + // skipped — refuse rather than transplant stale data. + if (typeof row?.log === 'number' && row.log !== -1 && row.log !== row.checkpointed) { + throw new Error(`wal_checkpoint did not fully apply: log=${row.log}, checkpointed=${row.checkpointed}`) + } + } finally { + db.close() + } +} + +const DEFAULT_MIN_DWELL_MS = 2_000 + +async function defaultAwaitLockRelease(storePath: string, timeoutMs: number, minDwellMs: number = DEFAULT_MIN_DWELL_MS): Promise { + // Three-way release check: + // 1. SQLite BEGIN IMMEDIATE returns BUSY -> another writer + // 2. file size still changing -> mid-write + // 3. minimum dwell -> fail-safe for idle-but-not-yet-exited runners + // The dwell is the only thing protecting against "archive ran, runner + // is shutting down, no writes in flight, no txn held" — without it + // the probe + size-stability would both pass instantly and we could + // copy a store.db whose backing FD is about to be flushed by the + // exiting subprocess. + const start = Date.now() + let lastFileSize = -1 + let stableCount = 0 + const effectiveDwell = Math.min(minDwellMs, Math.max(0, timeoutMs - 250)) + while (Date.now() - start < timeoutMs) { + const elapsed = Date.now() - start + const dwellSatisfied = elapsed >= effectiveDwell + if (dwellSatisfied && !sqliteLockHeldByOtherProcess(storePath) && fileSizeStable()) { + return true + } + await sleep(250) + } + return false + + function fileSizeStable(): boolean { + try { + const st = statSync(storePath) + const size = st.size + if (size === lastFileSize) { + stableCount += 1 + if (stableCount >= 2) return true + } else { + stableCount = 0 + lastFileSize = size + } + } catch { + // File gone = no lock to release. + return true + } + return false + } +} + +/** + * Read the agent acp single-instance lock under $HAPI_HOME (or the same + * tmpdir/hapi fallback the CLI guard uses) and report whether a live PID + * holds it. Mirrors `cli/src/agent/backends/acp/agentCliGuard.ts` + * (intentionally duplicated — the hub does not depend on the CLI module). + * Codex review #34 P1. + */ +function defaultIsAgentAcpTransportActive(): { active: boolean; holderPid: number | null } { + const home = process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi') + const lockDir = join(home, 'locks', 'agent-acp-active') + const pidPath = join(lockDir, 'pid') + // Codex review #34 P2 v5: the CLI agent guard creates the lock dir + // BEFORE writing the pid file (cli/src/agent/backends/acp/agentCliGuard.ts). + // If we only check the pid file, we report "inactive" during that + // mid-startup window — and bulk migrations with --force-archive-running + // would then archive a legacy session before verifyInTempHome() races + // the same lock and refuses anyway. Treat lock-dir-exists-but-no-pid + // as ACTIVE so we refuse early, before any side effect. + const dirExists = existsSync(lockDir) + if (!dirExists) return { active: false, holderPid: null } + if (!existsSync(pidPath)) { + // Lock dir exists, no pid file yet — caller is mid-startup. + return { active: true, holderPid: null } + } + let pid: number + try { + const raw = readFileSync(pidPath, 'utf8').trim() + pid = Number(raw) + if (!Number.isInteger(pid) || pid <= 0) { + // Malformed pid file — treat as mid-startup, not stale. + return { active: true, holderPid: null } + } + } catch { + // Read error — be conservative and treat as active. + return { active: true, holderPid: null } + } + try { + process.kill(pid, 0) + return { active: true, holderPid: pid } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + // EPERM means the process exists but we can't signal it. + if (code === 'EPERM') return { active: true, holderPid: pid } + // Pid file present but pid is dead — genuinely stale, treat as + // inactive. The probe-side acquireLock will rmSync the stale + // lock dir on its own first attempt. + return { active: false, holderPid: null } + } +} + +/** + * Probe whether SQLite reports a busy/locked store. We open the file + * readwrite, ask for an IMMEDIATE transaction, then roll back. If another + * process holds a write lock (legacy launcher running an open ACP + * connection), SQLite will report SQLITE_BUSY and we return true. + * Codex review #34 P1: real lock check, not just stat-based stability. + */ +function sqliteLockHeldByOtherProcess(storePath: string): boolean { + let db: Database | null = null + try { + db = new Database(storePath, { readwrite: true }) + db.exec('BEGIN IMMEDIATE') + db.exec('ROLLBACK') + return false + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/SQLITE_BUSY|database is locked/i.test(msg)) return true + // Anything else (corrupted, unreadable) — treat as not-our-busy-lock + // and let the upstream verify step surface the failure cleanly. + return false + } finally { + try { db?.close() } catch {} + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts b/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts new file mode 100644 index 000000000..d24a26add --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts @@ -0,0 +1,277 @@ +/** + * Integration test for the legacy stream-json → ACP migrator. + * + * Spawns a REAL `agent acp` against an isolated $HOME with a synthetic + * legacy store.db. Verifies that: + * - initialize succeeds + * - session/load succeeds against the transplanted store + * - one session/prompt completes + * + * This is the same verify recipe the production migrator runs in its + * temp-HOME staging step. The test exists to detect drift between the + * cursor-agent on the developer's machine and HAPI's assumptions about + * its on-disk layout (#824). + * + * Opt-in: set CURSOR_AGENT_INTEGRATION=1 to enable. In CI without auth, + * keep this off - the unit tests in cursorLegacyMigrator.test.ts cover + * every migrator branch with mocks. + * + * Developer recipe: + * CURSOR_AGENT_INTEGRATION=1 bun test src/cursor/cursorLegacyMigratorIntegration.test.ts + * + * Fodder-strength: if LEGACY_FODDER_WSH + LEGACY_FODDER_UUID are also set, + * the test will copy that real on-disk legacy store into the fake $HOME and + * verify it survives the full migrator round-trip. The operator's real + * ~/.cursor/chats/ is NOT mutated. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, copyFileSync, existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir, tmpdir } from 'node:os' +import { spawnSync } from 'node:child_process' + +import type { Metadata } from '@hapi/protocol/schemas' +import type { Session } from '@hapi/protocol/types' +import { CursorLegacyMigrator } from './cursorLegacyMigrator' +import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe' +import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore' + +const ENABLED = process.env.CURSOR_AGENT_INTEGRATION === '1' + +function agentBinaryAvailable(): boolean { + const which = spawnSync('agent', ['--version'], { stdio: 'pipe' }) + return which.status === 0 +} + +function copyAuthFiles(realHome: string, fakeHome: string): void { + const realCursor = join(realHome, '.cursor') + const fakeCursor = join(fakeHome, '.cursor') + mkdirSync(fakeCursor, { recursive: true }) + for (const f of ['cli-config.json', 'agent-cli-state.json', 'acp-config.json']) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } +} + +const describeIntegration = ENABLED ? describe : describe.skip + +describeIntegration('CursorLegacyMigrator INTEGRATION (real agent acp)', () => { + let fakeHome: string + let tmp: string + beforeEach(() => { + if (!ENABLED) return + if (!agentBinaryAvailable()) { + throw new Error('agent binary not on PATH; install cursor-agent or unset CURSOR_AGENT_INTEGRATION') + } + fakeHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-home-')) + tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-tmp-')) + copyAuthFiles(homedir(), fakeHome) + mkdirSync(join(fakeHome, '.cursor', 'chats'), { recursive: true }) + mkdirSync(join(fakeHome, '.cursor', 'acp-sessions'), { recursive: true }) + }) + afterEach(() => { + if (!ENABLED) return + try { rmSync(fakeHome, { recursive: true, force: true }) } catch {} + try { rmSync(tmp, { recursive: true, force: true }) } catch {} + }) + + it('migrates a tiny synthetic legacy store through the real agent acp verify path', async () => { + const cursorSessionId = '11111111-2222-3333-4444-555555555555' + const wsh = 'wsh-int' + const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId) + mkdirSync(sourceDir, { recursive: true }) + const sourceStore = join(sourceDir, 'store.db') + buildSyntheticLegacyStore({ path: sourceStore, name: 'integration synthetic', lastUsedModel: 'composer-2.5' }) + + const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = [] + const migrator = new CursorLegacyMigrator( + { verifyTimeoutMs: 120_000, verifyPromptText: 'Reply with exactly: ack' }, + { + homeDir: () => fakeHome, + hostName: () => "integration", + tmpDir: () => tmp, + now: () => Date.now(), + createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 60_000, hapiHome: tmp, skipLockAcquire: true }), + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp), + archiveSession: async () => {}, + updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => { + updateCalls.push({ sessionId, namespace, lastUsedModel }) + return { ok: true } + } + } + ) + + const session: Session = { + id: 'integration-sess', + tag: 'integration-sess', + namespace: 'default', + createdAt: 0, + updatedAt: 0, + seq: 0, + metadataVersion: 1, + agentStateVersion: 1, + metadata: { + path: tmpdir(), + host: 'integration', + flavor: 'cursor', + cursorSessionId + } as Metadata, + active: false, + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: null, + agentState: null, + todos: null, + todosUpdatedAt: null, + teamState: null, + teamStateUpdatedAt: null + } as unknown as Session + + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.acpSessionId).toBe(cursorSessionId) + expect(out.sourceRemoved).toBe(true) + expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true) + expect(existsSync(sourceStore)).toBe(false) + expect(updateCalls).toHaveLength(1) + expect(updateCalls[0].lastUsedModel).toBe('composer-2.5') + }, 180_000) + + it('migrates a REAL operator-supplied legacy store (LEGACY_FODDER_WSH + LEGACY_FODDER_UUID)', async () => { + const fodderWsh = process.env.LEGACY_FODDER_WSH + const fodderUuid = process.env.LEGACY_FODDER_UUID + if (!fodderWsh || !fodderUuid) { + // Skip silently; fodder is operator-local data we can't ship. + return + } + const realSourceStore = join(homedir(), '.cursor', 'chats', fodderWsh, fodderUuid, 'store.db') + if (!existsSync(realSourceStore)) { + throw new Error(`LEGACY_FODDER_WSH/UUID set but ${realSourceStore} does not exist`) + } + // Copy into fake HOME — operator's real store is NEVER touched. + const fakeSourceDir = join(fakeHome, '.cursor', 'chats', fodderWsh, fodderUuid) + mkdirSync(fakeSourceDir, { recursive: true }) + copyFileSync(realSourceStore, join(fakeSourceDir, 'store.db')) + + const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = [] + const migrator = new CursorLegacyMigrator( + { verifyTimeoutMs: 180_000 }, + { + homeDir: () => fakeHome, + hostName: () => "integration", + tmpDir: () => tmp, + now: () => Date.now(), + createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 120_000, hapiHome: tmp, skipLockAcquire: true }), + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp), + archiveSession: async () => {}, + updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => { + updateCalls.push({ sessionId, namespace, lastUsedModel }) + return { ok: true } + } + } + ) + const session: Session = { + id: 'fodder-sess', + tag: 'fodder-sess', + namespace: 'default', + createdAt: 0, + updatedAt: 0, + seq: 0, + metadataVersion: 1, + agentStateVersion: 1, + metadata: { + path: tmpdir(), + host: 'integration', + flavor: 'cursor', + cursorSessionId: fodderUuid + } as Metadata, + active: false, + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: null, + agentState: null, + todos: null, + todosUpdatedAt: null, + teamState: null, + teamStateUpdatedAt: null + } as unknown as Session + + const out = await migrator.migrateOne(session, { skipVerify: true }) + // skipVerify because real fodder may have policies (e.g. ask permission, model unavailability) that fail a fresh prompt. The transplant + flip is the regression-critical path. + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.acpSessionId).toBe(fodderUuid) + expect(out.sourceRemoved).toBe(true) + expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', fodderUuid, 'store.db'))).toBe(true) + // Operator's real store ON DISK is unaffected because we operated only against fakeHome. + expect(existsSync(realSourceStore)).toBe(true) + expect(updateCalls).toHaveLength(1) + }, 240_000) + + it('refuses to migrate when target collision exists', async () => { + const cursorSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const wsh = 'wsh-collide' + const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId) + mkdirSync(sourceDir, { recursive: true }) + buildSyntheticLegacyStore({ path: join(sourceDir, 'store.db') }) + // Pre-existing ACP target. + mkdirSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId), { recursive: true }) + writeFileSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'meta.json'), '{}') + + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => fakeHome, + hostName: () => "integration", + tmpDir: () => tmp, + now: () => Date.now(), + createProbe: (env) => new AcpVerifyProbe({ env, hapiHome: tmp, skipLockAcquire: true }), + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp), + archiveSession: async () => {}, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const session: Session = { + id: 'integration-collide', + tag: 'integration-collide', + namespace: 'default', + createdAt: 0, + updatedAt: 0, + seq: 0, + metadataVersion: 1, + agentStateVersion: 1, + metadata: { + path: tmpdir(), + host: 'integration', + flavor: 'cursor', + cursorSessionId + } as Metadata, + active: false, + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: null, + agentState: null, + todos: null, + todosUpdatedAt: null, + teamState: null, + teamStateUpdatedAt: null + } as unknown as Session + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('target_already_exists') + }) +}) diff --git a/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts b/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts new file mode 100644 index 000000000..9200dbf3f --- /dev/null +++ b/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts @@ -0,0 +1,68 @@ +/** + * Build a synthetic legacy stream-json store.db for tests. + * + * The real cursor-agent legacy store has the same schema as the ACP one: + * + * CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); + * CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + * + * The migrator only ever reads the meta record (for lastUsedModel + name). + * Tests that drive the migrator against a synthetic store can use this + * builder to create a sufficiently realistic file without paying token + * cost or depending on a real cursor-agent install. + * + * NOT a public hub export - used only from hub/src/cursor/*.test.ts. + */ + +import { Database } from 'bun:sqlite' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export interface BuildSyntheticStoreOpts { + /** Absolute file path to write store.db to. Parent dirs created automatically. */ + path: string + /** Free-form session name shown by the IDE; mirrors meta.name. */ + name?: string + /** lastUsedModel hint (legacy stream-json or ACP wireid; both valid). */ + lastUsedModel?: string + /** agentId; arbitrary string (cursor-agent doesn't validate it). */ + agentId?: string + /** ISO timestamp; defaults to now. */ + createdAt?: string + /** + * Whether to store meta value as hex-encoded UTF-8 JSON (older cursor-agent + * versions) or as raw JSON text (newer versions). Defaults to hex which is + * what the on-disk fodder sessions in the spike were stored as. + */ + metaEncoding?: 'hex' | 'json' +} + +export function buildSyntheticLegacyStore(opts: BuildSyntheticStoreOpts): void { + const { path } = opts + mkdirSync(dirname(path), { recursive: true }) + // Pre-touch the file so bun:sqlite definitely creates a fresh DB instead + // of opening anything pre-existing. + writeFileSync(path, '') + const db = new Database(path, { create: true, readwrite: true }) + try { + db.exec('CREATE TABLE IF NOT EXISTS blobs (id TEXT PRIMARY KEY, data BLOB)') + db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)') + const metaPayload: Record = { + agentId: opts.agentId ?? 'synthetic-agent', + latestRootBlobId: 'synthetic-root', + name: opts.name ?? 'synthetic legacy chat', + mode: 'agent', + createdAt: opts.createdAt ?? new Date().toISOString() + } + if (opts.lastUsedModel) { + metaPayload.lastUsedModel = opts.lastUsedModel + } + const json = JSON.stringify(metaPayload) + const encoded = (opts.metaEncoding ?? 'hex') === 'hex' + ? Buffer.from(json, 'utf8').toString('hex') + : json + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('record', encoded) + } finally { + db.close() + } +} diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 54d6c7615..428299ce2 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -6,9 +6,9 @@ import { registerSessionHandlers } from './sessionHandlers' class FakeSocket { readonly roomEvents: Array<{ room: string; event: string; data: unknown }> = [] - private readonly handlers = new Map void>() + private readonly handlers = new Map void) => void>() - on(event: string, handler: (data: unknown) => void): this { + on(event: string, handler: (data: unknown, ack?: (response: unknown) => void) => void): this { this.handlers.set(event, handler) return this } @@ -21,8 +21,8 @@ class FakeSocket { } } - trigger(event: string, data: unknown): void { - this.handlers.get(event)?.(data) + trigger(event: string, data: unknown, ack?: (response: unknown) => void): void { + this.handlers.get(event)?.(data, ack) } } @@ -64,4 +64,60 @@ describe('cli session handlers', () => { expect(socket.roomEvents).toHaveLength(0) expect(webEvents).toHaveLength(0) }) + + it('update-metadata broadcasts the merged value, not the pre-merge payload', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'broadcast-merged', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'broadcast-survives' + }, + null, + 'default' + ) + const socket = new FakeSocket() + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + } + }) + + let ackResponse: unknown = null + socket.trigger( + 'update-metadata', + { + sid: session.id, + expectedVersion: session.metadataVersion, + metadata: { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed' + } + }, + (response) => { + ackResponse = response + } + ) + + // Ack: success and the version bumps; the persisted value carries the + // merged metadata so other CLIs can update their cache to the truth. + const ack = ackResponse as { result: string; version: number; metadata: unknown } + expect(ack.result).toBe('success') + const ackMetadata = ack.metadata as Record + expect(ackMetadata.cursorSessionId).toBe('broadcast-survives') + expect(ackMetadata.path).toBe('/tmp/project') + + // Broadcast: the room event must carry the same merged value. + const broadcast = socket.roomEvents.find((event) => event.event === 'update') + expect(broadcast).toBeDefined() + const broadcastBody = (broadcast?.data as { body: { metadata: { value: Record } } }).body + expect(broadcastBody.metadata.value.cursorSessionId).toBe('broadcast-survives') + expect(broadcastBody.metadata.value.path).toBe('/tmp/project') + expect(broadcastBody.metadata.value.lifecycleState).toBe('archived') + }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index b28090033..67def89ce 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -202,7 +202,13 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session body: { t: 'update-session' as const, sid, - metadata: { version: result.version, value: metadata }, + // Broadcast the persisted (merged) value, not the pre-merge + // payload — otherwise other CLIs in the session room would + // overwrite their local cache with a tokenless metadata + // snapshot even though the DB row was preserved. + // See store.sessions.mergeSessionMetadata for the merge + // contract. + metadata: { version: result.version, value: result.value }, agentState: null } } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index e6fa84d41..950049054 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -34,7 +34,7 @@ const REQUIRED_TABLES = [ export class Store { private db: Database - private readonly dbPath: string + private readonly _dbPath: string private closed: boolean = false readonly sessions: SessionStore @@ -43,8 +43,17 @@ export class Store { readonly users: UserStore readonly push: PushStore + /** + * Filesystem path of the underlying SQLite database, or ':memory:' for + * in-memory stores. Used by the legacy → ACP migrator (#824) to take a + * backup before a bulk run; treat as read-only. + */ + get dbPath(): string { + return this._dbPath + } + constructor(dbPath: string) { - this.dbPath = dbPath + this._dbPath = dbPath if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) { const dir = dirname(dbPath) mkdirSync(dir, { recursive: true, mode: 0o700 }) @@ -464,9 +473,9 @@ export class Store { } private buildSchemaMismatchError(currentVersion: number): Error { - const location = (this.dbPath === ':memory:' || this.dbPath.startsWith('file::memory:')) + const location = (this._dbPath === ':memory:' || this._dbPath.startsWith('file::memory:')) ? 'in-memory database' - : this.dbPath + : this._dbPath return new Error( `SQLite schema version mismatch for ${location}. ` + `Expected ${SCHEMA_VERSION}, found ${currentVersion}. ` + diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts new file mode 100644 index 000000000..f00a03779 --- /dev/null +++ b/hub/src/store/sessions.test.ts @@ -0,0 +1,759 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +function makeStore(): Store { + return new Store(':memory:') +} + +function getMetadata(store: Store, id: string): Record | null { + const row = store.sessions.getSession(id) + return (row?.metadata ?? null) as Record | null +} + +describe('updateSessionMetadata: protocol resume token preservation', () => { + it('preserves cursorSessionId when archive payload omits it (Cursor crash-archive)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-archive-cursor-id', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-abc', + cursorSessionProtocol: 'stream-json', + lifecycleState: 'running' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + lifecycleState: 'archived', + lifecycleStateSince: 2, + archivedBy: 'cli', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + expect(result.result).toBe('success') + + const metadata = getMetadata(store, session.id) + expect(metadata).not.toBeNull() + expect(metadata?.cursorSessionId).toBe('cursor-thread-abc') + expect(metadata?.cursorSessionProtocol).toBe('stream-json') + expect(metadata?.lifecycleState).toBe('archived') + expect(metadata?.archiveReason).toBe('Session crashed') + expect(metadata?.archivedBy).toBe('cli') + }) + + it('preserves codexSessionId when archive payload omits it (Codex generic flavor)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'codex-archive', + { + path: '/tmp/project', + host: 'example', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + lifecycleState: 'running' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + flavor: 'codex', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + session.metadataVersion, + 'default' + ) + expect(result.result).toBe('success') + + const metadata = getMetadata(store, session.id) + expect(metadata?.codexSessionId).toBe('codex-thread-1') + }) + + it.each([ + ['claudeSessionId', 'claude-thread-x'], + ['codexSessionId', 'codex-thread-x'], + ['geminiSessionId', 'gemini-thread-x'], + ['opencodeSessionId', 'opencode-thread-x'], + ['cursorSessionId', 'cursor-thread-x'], + ['kimiSessionId', 'kimi-thread-x'] + ])('preserves %s across an archive metadata replacement', (field, value) => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + `archive-${field}`, + { + path: '/tmp/project', + host: 'example', + [field]: value + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + lifecycleState: 'archived', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + expect(result.result).toBe('success') + + const metadata = getMetadata(store, session.id) + expect(metadata?.[field]).toBe(value) + }) + + it('preserves cursorSessionProtocol independently of cursorSessionId', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-protocol-only', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { path: '/tmp/project', host: 'example' }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) + expect(metadata?.cursorSessionProtocol).toBe('acp') + }) + + it('lets the next write override a flavor session id when it explicitly sets a different value', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-overwrite', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'old-thread' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'new-thread' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) + expect(metadata?.cursorSessionId).toBe('new-thread') + }) + + it('does not invent fields when the prior row had no resume token', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'no-prior-token', + { path: '/tmp/project', host: 'example' }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + lifecycleState: 'archived', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) + expect(metadata).not.toBeNull() + expect('cursorSessionId' in (metadata as Record)).toBe(false) + expect('codexSessionId' in (metadata as Record)).toBe(false) + }) + + it('preserves resume token when CLI sends an empty payload (stale-cache failure mode)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-empty-payload', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'survives-empty-payload' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) + expect(metadata?.cursorSessionId).toBe('survives-empty-payload') + expect(metadata?.lifecycleState).toBe('archived') + }) + + it('preserves resume token across multiple consecutive metadata writes', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-multi-write', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'persistent-thread' + }, + null, + 'default' + ) + + const v1 = store.sessions.updateSessionMetadata( + session.id, + { path: '/tmp/project', host: 'example', name: 'renamed' }, + session.metadataVersion, + 'default' + ) + expect(v1.result).toBe('success') + + const v2 = store.sessions.updateSessionMetadata( + session.id, + { path: '/tmp/project', host: 'example', name: 'renamed', tools: ['read_file'] }, + v1.result === 'success' ? v1.version : -1, + 'default' + ) + expect(v2.result).toBe('success') + + const metadata = getMetadata(store, session.id) + expect(metadata?.cursorSessionId).toBe('persistent-thread') + expect(metadata?.name).toBe('renamed') + expect(metadata?.tools).toEqual(['read_file']) + }) + + it('returns version-mismatch unchanged when the expected version is stale', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-version-mismatch', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'stable-id' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { path: '/tmp/project', host: 'example' }, + session.metadataVersion + 99, + 'default' + ) + expect(result.result).toBe('version-mismatch') + if (result.result === 'version-mismatch') { + const value = result.value as Record | null + expect(value?.cursorSessionId).toBe('stable-id') + } + }) + + it('returns error when the session row does not exist', () => { + const store = makeStore() + const result = store.sessions.updateSessionMetadata( + 'no-such-session', + { path: '/tmp/project', host: 'example' }, + 0, + 'default' + ) + expect(result.result).toBe('error') + }) + + it('archive then read-back ships a payload that legacy resume routing can use', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-roundtrip', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + cursorSessionId: 'legacy-uuid', + lifecycleState: 'running' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + lifecycleState: 'archived', + archiveReason: 'Session crashed', + archivedBy: 'cli' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) + // Legacy routing in cursorProtocol.isLegacyCursorSession() defaults to + // legacy when cursorSessionProtocol is unset and cursorSessionId is + // truthy. Preserving the id alone is enough for resume to route + // correctly even if the protocol marker was never persisted. + expect(metadata?.cursorSessionId).toBe('legacy-uuid') + expect(metadata?.flavor).toBe('cursor') + }) + + // P1 from cold review: a sparse archive payload must result in a + // metadata blob that still parses against MetadataSchema (path/host + // are required). Without these, downstream consumers null-out the + // metadata and resume cannot find the session even though the + // resume token survived in the DB. + it('preserves required path and host when archive payload is sparse (sparse-cache failure mode)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-sparse-archive', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + cursorSessionId: 'parse-required' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.path).toBe('/tmp/project') + expect(metadata?.host).toBe('example') + expect(metadata?.cursorSessionId).toBe('parse-required') + expect(metadata?.lifecycleState).toBe('archived') + }) + + it('does not invent path or host when prior had none', () => { + const store = makeStore() + // create with minimal raw metadata (path is technically required by + // the schema, but the store accepts any JSON; this exercises the + // edge case where prior is missing identity fields) + const session = store.sessions.getOrCreateSession( + 'no-prior-identity', + { flavor: 'cursor' }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { lifecycleState: 'archived' }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.lifecycleState).toBe('archived') + expect('path' in (metadata ?? {})).toBe(false) + expect('host' in (metadata ?? {})).toBe(false) + }) + + // P2 from cold review: flavor + machineId are routing fields. Without + // flavor, hub/src/web/routes/sessions.ts and syncEngine fall through + // to the `?? 'claude'` default and ignore the preserved Cursor/Codex + // token. Without machineId, the CLI's resumable listing filters the + // row out of the resume picker. Both must survive sparse archive. + it('preserves flavor and machineId across sparse archive (resume routing)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-routing-survives', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + machineId: 'mach-xyz', + cursorSessionId: 'cursor-thread-routed' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.flavor).toBe('cursor') + expect(metadata?.machineId).toBe('mach-xyz') + expect(metadata?.cursorSessionId).toBe('cursor-thread-routed') + }) + + it('does not invent flavor or machineId when prior had none', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'no-prior-routing', + { path: '/tmp/project', host: 'example' }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { lifecycleState: 'archived' }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.lifecycleState).toBe('archived') + expect('flavor' in (metadata ?? {})).toBe(false) + expect('machineId' in (metadata ?? {})).toBe(false) + }) + + it('lets the next write override flavor and machineId when explicitly set', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'override-routing', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + machineId: 'mach-old' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + flavor: 'codex', + machineId: 'mach-new' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.flavor).toBe('codex') + expect(metadata?.machineId).toBe('mach-new') + }) + + // P2 from cold review: cursorSessionProtocol must NOT carry over + // when the next write explicitly sets a different cursorSessionId. + // The protocol is tied to the id, and a different id may use a + // different protocol (e.g. legacy stream-json id under an old + // ACP marker would be misrouted). + it('drops cursorSessionProtocol when a new cursorSessionId is written', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-protocol-pair-drop', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'old-id', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'new-id' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.cursorSessionId).toBe('new-id') + expect(metadata?.cursorSessionProtocol).toBeUndefined() + }) + + it('preserves cursorSessionProtocol when neither id nor protocol is in the next write', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-protocol-pair-preserve', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'stable-id', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + lifecycleState: 'archived', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.cursorSessionId).toBe('stable-id') + expect(metadata?.cursorSessionProtocol).toBe('acp') + }) + + it('respects an explicit cursorSessionProtocol on the next write even when the id is unchanged', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-protocol-pair-explicit', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'stable-id' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + cursorSessionProtocol: 'stream-json' + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata?.cursorSessionId).toBe('stable-id') + expect(metadata?.cursorSessionProtocol).toBe('stream-json') + }) + + // P2 from cold review: the broadcast on a successful update must + // ship the merged value so other CLIs in the session room update + // their local cache to the persisted state. This is enforced in the + // socket handler (see hub/src/socket/handlers/cli/sessionHandlers.ts); + // the store-level guarantee here is that result.value reflects the + // merged state and not the pre-merge input. + it('returns the merged value in the success ack, not the pre-merge input', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'cursor-ack-merged', + { + path: '/tmp/project', + host: 'example', + cursorSessionId: 'should-survive-ack' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed' + }, + session.metadataVersion, + 'default' + ) + + expect(result.result).toBe('success') + if (result.result === 'success') { + const value = result.value as Record | null + expect(value?.path).toBe('/tmp/project') + expect(value?.host).toBe('example') + expect(value?.cursorSessionId).toBe('should-survive-ack') + expect(value?.lifecycleState).toBe('archived') + } + }) + + // Upstream cold-review (Major): preserve-on-omit must not block + // intentional clears. `cli/src/codex/session.ts resetCodexThread()` + // is the existing site that needs to drop `codexSessionId` (called + // from /clear in codexRemoteLauncher.ts). The explicit-clear + // sentinel: `null` in `next` means "drop this field entirely from + // the merged blob" (key removed, not stored as null) — distinct + // from omitted (`undefined`) which carries forward. + it('drops a carry-forward field when next sets it to null (explicit clear)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'codex-explicit-clear', + { + path: '/tmp/project', + host: 'example', + flavor: 'codex', + codexSessionId: 'old-thread' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + flavor: 'codex', + codexSessionId: null + }, + session.metadataVersion, + 'default' + ) + + expect(result.result).toBe('success') + const metadata = getMetadata(store, session.id) as Record | null + expect(metadata).not.toBeNull() + expect('codexSessionId' in (metadata ?? {})).toBe(false) + expect(metadata?.flavor).toBe('codex') + }) + + it('treats null as clear for any carry-forward field, independently of others', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'multi-token-explicit-clear', + { + path: '/tmp/project', + host: 'example', + flavor: 'cursor', + cursorSessionId: 'cursor-keep', + codexSessionId: 'codex-clear-me' + }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + codexSessionId: null + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect('codexSessionId' in (metadata ?? {})).toBe(false) + expect(metadata?.cursorSessionId).toBe('cursor-keep') + expect(metadata?.flavor).toBe('cursor') + }) + + it('null on a never-set field is a no-op (does not introduce the key)', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'null-on-absent', + { path: '/tmp/project', host: 'example' }, + null, + 'default' + ) + + store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + codexSessionId: null + }, + session.metadataVersion, + 'default' + ) + + const metadata = getMetadata(store, session.id) as Record | null + expect('codexSessionId' in (metadata ?? {})).toBe(false) + }) + + it('explicit clear leaves the merged value in the success ack', () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession( + 'explicit-clear-ack', + { + path: '/tmp/project', + host: 'example', + codexSessionId: 'thread-x' + }, + null, + 'default' + ) + + const result = store.sessions.updateSessionMetadata( + session.id, + { + path: '/tmp/project', + host: 'example', + codexSessionId: null + }, + session.metadataVersion, + 'default' + ) + + expect(result.result).toBe('success') + if (result.result === 'success') { + const value = result.value as Record | null + expect('codexSessionId' in (value ?? {})).toBe(false) + expect(value?.path).toBe('/tmp/project') + } + }) +}) diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 82264b838..449214035 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -5,6 +5,130 @@ import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' +// Carry-forward fields that the hub preserves across any metadata +// replacement when the incoming write omits them. +// +// The CLI's archive transition (cli/src/agent/runnerLifecycle.ts +// archiveAndClose) spreads `currentMetadata` from the session client's +// local cache; if that cache is `null` (e.g. the row's metadata failed +// Zod parse at bootstrap and got nulled out in cli/src/api/api.ts) or +// stale, the resulting payload is sparse and the unconditional REPLACE +// in updateSessionMetadata wipes whatever it omits. That breaks resume +// even though the on-disk chat data still exists. +// +// Three preservation tiers cover the failure modes: +// +// - PARSE_IDENTITY_FIELDS: required by MetadataSchema in +// shared/src/schemas.ts. Without these, hub session cache and CLI +// getSession reject the row with safeParse → metadata becomes null +// downstream and resume cannot find a path even when the resume +// token survived. +// +// - ROUTING_FIELDS: flavor + machineId. `flavor` is what +// hub/src/web/routes/sessions.ts and hub/src/sync/syncEngine.ts use +// to pick which session id field to read; if it's dropped, the +// `?? 'claude'` fallback misroutes a Cursor/Codex/Gemini session as +// Claude and the preserved token is ignored. `machineId` is the +// filter the CLI's resumable listing uses to scope rows to the +// current host; without it the row drops out of the resume picker. +// +// - SIMPLE_RESUME_TOKENS: flavor-specific resume identifiers that are +// write-once-keep semantics. Mirror of pickExistingSessionMetadata +// in cli/src/agent/sessionFactory.ts. +// +// `cursorSessionProtocol` is paired with `cursorSessionId`: protocol is +// tied to a specific chat id, so a write that explicitly sets a new +// `cursorSessionId` must drop a stale prior protocol. Handled in +// preserveCursorProtocolPair below. +// +// Explicit-clear sentinel: when `next` sets a carry-forward field to +// `null`, the merge drops the key entirely from the output (the +// resulting blob has neither the prior value nor `null`). This lets +// callers intentionally remove a preserved field — e.g. +// `cli/src/codex/session.ts` `resetCodexThread()` clears the codex +// thread id with `codexSessionId: null` so a `/clear` command actually +// drops the persisted thread. `undefined` (key missing from `next`) +// continues to mean "carry forward". +const PARSE_IDENTITY_FIELDS = ['path', 'host'] as const + +const ROUTING_FIELDS = ['flavor', 'machineId'] as const + +const SIMPLE_RESUME_TOKENS = [ + 'claudeSessionId', + 'codexSessionId', + 'geminiSessionId', + 'opencodeSessionId', + 'cursorSessionId', + 'kimiSessionId' +] as const + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function carryForwardIfMissing( + prior: Record, + next: Record, + merged: Record | null, + fields: ReadonlyArray +): Record | null { + let result = merged + for (const field of fields) { + // Explicit-clear sentinel: `null` in next means "drop this field". + // Strip it from the merged output so the persisted blob stays + // schema-clean (MetadataSchema fields are `string().optional()` + // — string|undefined, not nullable). + if (next[field] === null) { + if (result === null) { + result = { ...next } + } + delete result[field] + continue + } + if (next[field] === undefined && prior[field] !== undefined) { + if (result === null) { + result = { ...next } + } + result[field] = prior[field] + } + } + return result +} + +function preserveCursorProtocolPair( + prior: Record, + next: Record, + merged: Record | null +): Record | null { + // If next explicitly sets cursorSessionId, the protocol is tied to + // the new id — never carry over the prior protocol. The next write + // can include its own cursorSessionProtocol if it knows the protocol. + if (next.cursorSessionId !== undefined) { + return merged + } + // Otherwise next is silent on the id (and possibly the protocol); + // carry over the prior protocol so it stays paired with the prior id + // (which is preserved via SIMPLE_RESUME_TOKENS above). + if (next.cursorSessionProtocol === undefined && prior.cursorSessionProtocol !== undefined) { + const result = merged ?? { ...next } + result.cursorSessionProtocol = prior.cursorSessionProtocol + return result + } + return merged +} + +export function mergeSessionMetadata(prior: unknown, next: unknown): unknown { + if (!isPlainObject(prior) || !isPlainObject(next)) { + return next + } + let merged: Record | null = null + merged = carryForwardIfMissing(prior, next, merged, PARSE_IDENTITY_FIELDS) + merged = carryForwardIfMissing(prior, next, merged, ROUTING_FIELDS) + merged = carryForwardIfMissing(prior, next, merged, SIMPLE_RESUME_TOKENS) + merged = preserveCursorProtocolPair(prior, next, merged) + return merged ?? next +} + type DbSessionRow = { id: string tag: string | null @@ -128,29 +252,42 @@ export function updateSessionMetadata( const now = Date.now() const touchUpdatedAt = options?.touchUpdatedAt !== false - return updateVersionedField({ - db, - table: 'sessions', - id, - namespace, - field: 'metadata', - versionField: 'metadata_version', - expectedVersion, - value: metadata, - encode: (value) => { - const json = JSON.stringify(value) - return json === undefined ? null : json - }, - decode: safeJsonParse, - setClauses: [ - 'updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END', - 'seq = seq + 1' - ], - params: { - updated_at: now, - touch_updated_at: touchUpdatedAt ? 1 : 0 - } - }) + try { + return db.transaction((): VersionedUpdateResult => { + const priorRow = db.prepare( + 'SELECT metadata FROM sessions WHERE id = ? AND namespace = ?' + ).get(id, namespace) as { metadata: string | null } | undefined + + const prior = priorRow ? safeJsonParse(priorRow.metadata) : null + const merged = mergeSessionMetadata(prior, metadata) + + return updateVersionedField({ + db, + table: 'sessions', + id, + namespace, + field: 'metadata', + versionField: 'metadata_version', + expectedVersion, + value: merged, + encode: (value) => { + const json = JSON.stringify(value) + return json === undefined ? null : json + }, + decode: safeJsonParse, + setClauses: [ + 'updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END', + 'seq = seq + 1' + ], + params: { + updated_at: now, + touch_updated_at: touchUpdatedAt ? 1 : 0 + } + }) + })() + } catch { + return { result: 'error' } + } } export function updateSessionAgentState( diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 50e558f23..ecfabd7e3 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -512,6 +512,134 @@ export class SessionCache { this.refreshSession(sessionId) } + /** + * Clear archive-related metadata on an archived session so it can be resumed. + * - Removes `lifecycleState`, `archivedBy`, `archiveReason`, and stamps + * `lifecycleStateSince` so subsequent CLI lifecycle writes still win on time. + * - For Cursor sessions that pre-date #799 (no `cursorSessionProtocol` set, but a + * `cursorSessionId` exists) defaults the protocol to `stream-json` so routing + * reaches the legacy launcher instead of the new ACP path. + * + * Returns the protocol that was applied (or already present) for cursor sessions, + * or `undefined` for other flavors. Throws on version mismatch / store error. + * No-op when metadata is null (callers should pre-check). + */ + async clearSessionArchiveMetadata(sessionId: string): Promise<{ cursorSessionProtocol?: 'acp' | 'stream-json' }> { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error('Session not found') + } + + const currentMetadata = session.metadata + if (!currentMetadata) { + throw new Error('Session metadata missing') + } + + const next: Record = { ...currentMetadata } + delete next.lifecycleState + delete next.archivedBy + delete next.archiveReason + next.lifecycleStateSince = Date.now() + + let cursorSessionProtocol: 'acp' | 'stream-json' | undefined + if (currentMetadata.flavor === 'cursor') { + const existing = currentMetadata.cursorSessionProtocol + if (existing === 'acp' || existing === 'stream-json') { + cursorSessionProtocol = existing + } else if (currentMetadata.cursorSessionId) { + // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. + cursorSessionProtocol = 'stream-json' + next.cursorSessionProtocol = 'stream-json' + } + } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'version-mismatch') { + throw new Error('Session was modified concurrently. Please try again.') + } + + this.refreshSession(sessionId) + return cursorSessionProtocol ? { cursorSessionProtocol } : {} + } + + /** + * Restore archive-related metadata fields that were captured before a reopen attempt. + * Used when `resumeSession` fails after `clearSessionArchiveMetadata` already ran so the + * session does not drift into a "not archived, not active" zombie state. + * + * Restores the four archive fields **exactly**: if a field was present in the snapshot + * it is written, if it was absent it is deleted (covering the case where + * `clearSessionArchiveMetadata` stamped a fresh `lifecycleStateSince` on a row that did + * not have one originally). Other concurrent edits (e.g. a rename in flight) are + * preserved. Returns silently if the session is gone or its metadata is unset; throws + * on version mismatch so the caller can decide whether to retry. + */ + async restoreSessionArchiveMetadata( + sessionId: string, + snapshot: { + lifecycleState?: string + archivedBy?: string + archiveReason?: string + lifecycleStateSince?: number + } + ): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + + const next: Record = { ...current } + if (snapshot.lifecycleState !== undefined) { + next.lifecycleState = snapshot.lifecycleState + } else { + delete next.lifecycleState + } + if (snapshot.archivedBy !== undefined) { + next.archivedBy = snapshot.archivedBy + } else { + delete next.archivedBy + } + if (snapshot.archiveReason !== undefined) { + next.archiveReason = snapshot.archiveReason + } else { + delete next.archiveReason + } + if (snapshot.lifecycleStateSince !== undefined) { + next.lifecycleStateSince = snapshot.lifecycleStateSince + } else { + delete next.lifecycleStateSince + } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to restore archive metadata') + } + + if (result.result === 'version-mismatch') { + throw new Error('Session was modified concurrently during reopen rollback') + } + + this.refreshSession(sessionId) + } + async deleteSession(sessionId: string): Promise { const session = this.sessions.get(sessionId) if (!session) { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 6cb45ebcf..4ec57f5a1 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1806,4 +1806,313 @@ describe('session model', () => { expect(state.completedRequests?.['req-1']).toBeDefined() }) }) + + describe('clearSessionArchiveMetadata', () => { + it('clears lifecycleState/archivedBy/archiveReason from an archived session', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-archived', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-X', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBeUndefined() + const updated = cache.getSession(session.id) + const meta = updated?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBeUndefined() + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.archiveReason).toBeUndefined() + expect(typeof meta?.lifecycleStateSince).toBe('number') + }) + + it('defaults cursorSessionProtocol to stream-json for pre-#799 cursor sessions', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-legacy', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'legacy-cursor-id', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBe('stream-json') + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBe('stream-json') + }) + + it('keeps an existing acp protocol intact when clearing archive metadata', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-acp', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'acp-cursor-id', + cursorSessionProtocol: 'acp', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBe('acp') + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBe('acp') + expect(meta?.lifecycleState).toBeUndefined() + }) + + it('does not stamp cursorSessionProtocol when no cursorSessionId is present', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-fresh', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBeUndefined() + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBeUndefined() + }) + + it('throws when the session id is unknown', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + await expect(cache.clearSessionArchiveMetadata('missing-session')).rejects.toThrow('Session not found') + }) + }) + + describe('reopenSession rollback', () => { + it('restores archive metadata when resumeSession fails after the clear', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-reopen-rollback', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed', + lifecycleStateSince: 1000 + }, + null, + 'default' + ) + // No machine registered -> resumeSession returns no_machine_online. + + const result = await engine.reopenSession(session.id, 'default') + + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.code).toBe('no_machine_online') + } + + const restored = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record | null | undefined + expect(restored?.lifecycleState).toBe('archived') + expect(restored?.archivedBy).toBe('cli') + expect(restored?.archiveReason).toBe('Session crashed') + } finally { + engine.stop() + } + }) + + it('does not roll back when resumeSession succeeds', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-reopen-success', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + ;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id }) + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.reopenSession(session.id, 'default') + + expect(result.type).toBe('success') + if (result.type === 'success') { + expect(result.resumed).toBe(true) + } + + const after = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record | null | undefined + expect(after?.lifecycleState).toBeUndefined() + expect(after?.archivedBy).toBeUndefined() + expect(after?.archiveReason).toBeUndefined() + } finally { + engine.stop() + } + }) + }) + + describe('restoreSessionArchiveMetadata', () => { + it('puts back lifecycleState/archivedBy/archiveReason from a snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-Y', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234567890 + }, + null, + 'default' + ) + + await cache.clearSessionArchiveMetadata(session.id) + const cleared = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(cleared?.lifecycleState).toBeUndefined() + + await cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234567890 + }) + + const restored = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(restored?.lifecycleState).toBe('archived') + expect(restored?.archivedBy).toBe('cli') + expect(restored?.archiveReason).toBe('User terminated') + expect(restored?.lifecycleStateSince).toBe(1234567890) + }) + + it('deletes archive fields that were absent in the snapshot for an exact restore', async () => { + // Covers the legacy case: an archived session that predates `lifecycleStateSince`. + // `clearSessionArchiveMetadata` stamps a fresh `lifecycleStateSince`; if reopen + // then fails, the restore must drop that stamp so the row's lifecycle age does + // not appear to be "just now" to UI / import code. + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore-partial', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-Z', + lifecycleState: 'archived', + archiveReason: 'Session crashed' + // no archivedBy, no lifecycleStateSince + }, + null, + 'default' + ) + + await cache.clearSessionArchiveMetadata(session.id) + // lifecycleStateSince was just stamped fresh by the clear; verify it's set so + // the next assertion proves the restore actively deleted it. + const cleared = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(typeof cleared?.lifecycleStateSince).toBe('number') + + await cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archiveReason: 'Session crashed' + // archivedBy + lifecycleStateSince intentionally absent from snapshot + }) + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archiveReason).toBe('Session crashed') + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.lifecycleStateSince).toBeUndefined() + }) + + it('is a no-op when the session is gone', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + await expect(cache.restoreSessionArchiveMetadata('missing', { + lifecycleState: 'archived' + })).resolves.toBeUndefined() + }) + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index cd08717c0..df9c600ca 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,7 +8,7 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' -import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' @@ -16,6 +16,8 @@ import type { Store, CancelQueuedMessageResult } from '../store' import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' import type { RpcRegistry } from '../socket/rpcRegistry' import type { SSEManager } from '../sse/sseManager' +import { CursorLegacyMigrator, type CursorLegacyMigratorOptions } from '../cursor/cursorLegacyMigrator' + import { EventPublisher, type SyncEventListener } from './eventPublisher' import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' @@ -60,6 +62,11 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type ReopenSessionResult = + | { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' | 'metadata_conflict' } + | { type: 'incomplete'; message: string; missing: [string, ...string[]] } + export type LocalResumeTargetResult = | { type: 'success'; target: LocalResumeTarget } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' } @@ -423,6 +430,154 @@ export class SyncEngine { this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } + /** + * Apply the post-migration metadata flip in hapi.db: + * - metadata.cursorSessionProtocol = 'acp' + * - session.model = lastUsedModel (if provided) + * + * Returns 'success' on a clean write, 'version-mismatch' if the metadata + * version moved underneath us (caller retries) or 'not-found' if the row + * is gone. + * + * Used by CursorLegacyMigrator after the on-disk transplant + verify + * succeeds. Kept on the engine (not on the migrator) so that all hapi.db + * writes funnel through the existing cache-refresh path. + */ + flipCursorSessionProtocolToAcp( + sessionId: string, + namespace: string, + lastUsedModel: string | null + ): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) { + return { result: 'not-found' } + } + // Combined SSE-event payload contract (UX A++): clear the + // `cursorMigrationState='in_progress'` flag in the SAME metadata + // write that flips `cursorSessionProtocol` to 'acp'. The web + // banner keys off `cursorMigrationState`, so a single SSE + // session-updated event swaps both atomically — banner gone, + // protocol flipped — preventing a flicker window where the + // banner has already disappeared but the chat hasn't re-rendered + // to the ACP transport yet. + const carriedMigrationState = latest.metadata.cursorMigrationState + // Atomic active-check inside the same synchronous flip op so + // that a resume cannot land between the migrator's recheck + // and the actual DB update. Bun is single-threaded — once + // we've read `latest` and the row is inactive, no other JS + // can mutate active=true until this method returns. Codex + // review #34 P1 v2: the migrator's recheck is best-effort; + // this is the authoritative gate. + // + // Codex review #34 P2 v5: only block on `active === true`, + // NOT on lifecycleState === 'running'. After a force-archive + // flow archiveSession() synchronously sets active=false but + // the cleanup metadata write that flips lifecycleState + // 'running' → 'archived' may still be in-flight, and that + // is OUR archive completing, not a resume race. The active + // flag is the authoritative live-runner signal. + if (latest.active === true) { + return { result: 'session-active' } + } + // Codex review #34 P2 v7: ALSO clear a stale lifecycleState + // value if it still says 'running'. The migrator now skips + // archiveSession() for stale-running rows (active=false but + // lifecycle=running with --force-archive-running) because + // there's no live runner to archive. Without this fixup, + // successfully migrated stale rows would retain lifecycle= + // running forever and any downstream code that filters by + // lifecycleState (not the cache active flag) would keep + // treating archived ACP sessions as live. + const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined + const nextMetadata: typeof latest.metadata = { + ...latest.metadata, + cursorSessionProtocol: 'acp' as const, + ...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {}) + } + // Drop the migration-in-progress flag in the same write (see + // header comment). Safe whether or not it was set. + if (carriedMigrationState !== undefined) { + delete nextMetadata.cursorMigrationState + } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + nextMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'version-mismatch') { + this.sessionCache.refreshSession(sessionId) + continue + } + if (result.result !== 'success') { + return { result: 'not-found' } + } + this.sessionCache.refreshSession(sessionId) + if (lastUsedModel && lastUsedModel.trim().length > 0) { + this.store.sessions.setSessionModel(sessionId, lastUsedModel.trim(), namespace, { touchUpdatedAt: false }) + this.sessionCache.refreshSession(sessionId) + } + return { result: 'success' } + } + return { result: 'version-mismatch' } + } + + /** + * Migrate a single legacy cursor session to ACP. Hub-side; runs on the + * operator's machine (the hub host); see tiann/hapi#824 design. + * Returns a structured outcome (ok or refusal); does not throw. + */ + async migrateLegacyCursorSession( + sessionId: string, + namespace: string, + request: CursorMigrateToAcpRequest + ): Promise { + const session = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!session) { + return { ok: false, sessionId, reason: 'internal_error', message: 'session not found in namespace', durationMs: 0 } + } + const migrator = this.buildMigratorForRequest(request) + return migrator.migrateOne(session, { + keepSource: request.keepSource, + forceArchiveRunning: request.forceArchiveRunning, + skipVerify: request.skipVerify + }) + } + + private buildMigratorForRequest(_request: CursorMigrateToAcpRequest): CursorLegacyMigrator { + const migratorOpts: CursorLegacyMigratorOptions = {} + return new CursorLegacyMigrator(migratorOpts, { + archiveSession: async (sessionId) => { + await this.archiveSession(sessionId) + }, + // NOTE: no awaitSessionInactive injection — handleSessionEnd() + // synchronously sets cache.active=false inside archiveSession, + // so any cache-based poll would return immediately and provide + // false reassurance. The migrator now relies on + // awaitLockRelease's minimum-dwell + SQLite busy-probe + + // size-stability combination instead. Codex review #34 P1 v3. + getCurrentSession: (sessionId, namespace) => { + const s = this.sessionCache.getSessionByNamespace(sessionId, namespace) + if (!s) return null + return { + active: s.active === true, + lifecycleState: typeof s.metadata?.lifecycleState === 'string' ? s.metadata.lifecycleState : undefined, + cursorSessionProtocol: typeof s.metadata?.cursorSessionProtocol === 'string' ? s.metadata.cursorSessionProtocol : undefined + } + }, + updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => { + const result = this.flipCursorSessionProtocolToAcp(sessionId, namespace, lastUsedModel) + if (result.result === 'success') return { ok: true } + if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const } + return { ok: false, reason: 'version_mismatch_or_missing' as const } + } + }) + } + async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { await this.rpcGateway.switchSession(sessionId, to) } @@ -626,6 +781,183 @@ export class SyncEngine { return undefined } + /** + * tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly + * refreshed-from-cache) session. If the session is a legacy stream-json + * Cursor session AND the env flag is on, attempts a transplant migration + * synchronously before the caller spawns the runner. + * + * The migrator's verify probe runs in an isolated HAPI_HOME (see + * verifyInTempHome), so this method is safe to call even when other ACP + * transports are alive on the host: per tiann/hapi#832, two `agent acp` + * processes coexist on the same host without conflict, and swear01's + * tiann/hapi#835 refactors the agent-acp-active lock into a cross-process + * refcount that explicitly supports this. We rely on #835 landing before + * this PR — see manifest layer ordering and the dependency note in + * PR #34's body. + * + * Failure modes are all soft — the session is returned unchanged and the + * caller proceeds with the legacy launcher. + */ + private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise { + const md = session.metadata + const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? '' + console.info('[auto-migrate] considering', { + sessionId: session.id, + flavor: md?.flavor ?? null, + proto: md?.cursorSessionProtocol ?? null, + hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0, + envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw + }) + + if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') { + console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id }) + return session + } + if (!md || md.flavor !== 'cursor') { + console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null }) + return session + } + if (md.cursorSessionProtocol === 'acp') { + console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id }) + return session + } + if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) { + console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id }) + return session + } + + console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId }) + // UX A++: surface the migration to the user via a banner in the + // web UI. We set `cursorMigrationState='in_progress'` on the row + // BEFORE the long-running transplant; the sessionCache.refresh() + // call emits a `session-updated` SSE event the web client uses to + // render the banner. The flag is cleared on success by the same + // metadata write that flips cursorSessionProtocol to 'acp' (see + // flipCursorSessionProtocolToAcp) so the banner disappears in the + // same render tick the chat re-renders as ACP — no flicker. On + // failure we clear the flag explicitly in the catch path below. + const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace) + let bannerCleanupNeeded = flagSet + try { + const migrator = this.buildMigratorForRequest({}) + // Codex #34 P2 (round 13): for inactive rows whose metadata + // still reads `lifecycleState === 'running'` (e.g. orphaned by + // a hub crash where the lifecycle transition didn't land), the + // migrator's preflight refuses with `running_refused` unless + // `forceArchiveRunning` is true. resumeSession's caller-side + // guard already ensured `session.active === false` (see the + // early-return above), so we know there's no runner to yank; + // the `running` lifecycle is stale metadata, not a live agent. + // This is exactly the stale-row case the sync-on-open path is + // meant to clean up — refusing here would defeat the whole + // point and silently fall back to the legacy launcher forever. + const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true }) + if (outcome.ok) { + console.info('[auto-migrate] success', { + sessionId: session.id, + cursorSessionId: md.cursorSessionId, + acpSessionId: outcome.acpSessionId, + durationMs: outcome.durationMs, + sourceRemoved: outcome.sourceRemoved, + replayNotifications: outcome.replayNotifications, + lastUsedModelPreserved: outcome.lastUsedModelPreserved + }) + // Successful migration already cleared the flag atomically + // in flipCursorSessionProtocolToAcp; skip the cleanup write. + bannerCleanupNeeded = false + const refreshed = this.sessionCache.getSessionByNamespace(session.id, namespace) + if (refreshed) return refreshed + return session + } + // Soft fail — log and let the legacy launcher handle it. + console.info('[auto-migrate] legacy cursor session left as stream-json', { + sessionId: session.id, + reason: outcome.reason, + message: outcome.message + }) + } catch (err) { + console.warn('[auto-migrate] unexpected error; falling back to legacy launcher', { + sessionId: session.id, + err: err instanceof Error ? err.message : String(err) + }) + } finally { + // Failure or exception path: clear the in-progress banner flag + // so the user isn't left with a permanent "Upgrading..." banner + // even though we silently fell back to the legacy launcher. + if (bannerCleanupNeeded) { + this.clearCursorMigrationState(session.id, namespace) + } + } + return session + } + + /** + * Set `metadata.cursorMigrationState='in_progress'` on the session row + * with a single retry on version-mismatch. Returns true if the flag was + * persisted (so the caller knows the finally-cleanup is required), false + * if the write failed entirely — in which case the banner never appeared + * and there's nothing to clean up. UX A++ helper for the auto-migrate + * banner; see maybeAutoMigrateLegacyCursorSession. + */ + private setCursorMigrationStateInProgress(sessionId: string, namespace: string): boolean { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) return false + if (latest.metadata.cursorMigrationState === 'in_progress') return true + const nextMetadata = { ...latest.metadata, cursorMigrationState: 'in_progress' as const } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + nextMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return true + } + if (result.result === 'version-mismatch') { + this.sessionCache.refreshSession(sessionId) + continue + } + return false + } + return false + } + + /** + * Clear `metadata.cursorMigrationState` (failure / exception cleanup). + * Idempotent; safe to call when the flag was never set. UX A++ helper. + */ + private clearCursorMigrationState(sessionId: string, namespace: string): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) return + if (latest.metadata.cursorMigrationState === undefined) return + const nextMetadata: typeof latest.metadata = { ...latest.metadata } + delete nextMetadata.cursorMigrationState + const result = this.store.sessions.updateSessionMetadata( + sessionId, + nextMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return + } + if (result.result === 'version-mismatch') { + this.sessionCache.refreshSession(sessionId) + continue + } + return + } + } + /** Inactive session with directory path but no agent thread and no prior user turn. */ private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean { const metadata = session.metadata @@ -648,11 +980,19 @@ export class SyncEngine { } } - const session = access.session - if (session.active) { + const initialSession = access.session + if (initialSession.active) { return { type: 'success', sessionId: access.sessionId } } + // tiann/hapi#824 — invisible, automatic, per-session ACP migration on + // first open. If this is a legacy stream-json Cursor session and we + // can safely migrate it right now (no other agent acp transport + // would block the post-migration ACP launcher), run the transplant + // synchronously before resuming. The user sees the regular session + // loading state for ~3–5s longer; the session opens as ACP. + const session = await this.maybeAutoMigrateLegacyCursorSession(initialSession, namespace) + const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace) let flavor: AgentFlavor let resumeToken: string | undefined @@ -744,6 +1084,106 @@ export class SyncEngine { return { type: 'success', sessionId: spawnResult.sessionId } } + /** + * Revive an archived session so the web UI can reach it again. + * + * Behaviour: + * - Active session: idempotent no-op (`resumed: false`). + * - Non-archived inactive session: forwards to `resumeSession` without touching metadata. + * - Archived session: validates that the agent has enough metadata to resume (Cursor + * sessions require a `cursorSessionId` once they have any messages), clears the + * archive metadata (`lifecycleState`, `archivedBy`, `archiveReason`), defaults the + * Cursor protocol to `stream-json` for pre-#799 sessions, then forwards to + * `resumeSession`. The CLI's `sessionFactory` will re-stamp `lifecycleState='running'` + * when it boots, so we do not pre-write that here. + * + * Failure rollback: if `resumeSession` fails (no machine online, spawn timeout, etc.) + * the archive snapshot is restored so the operator can retry without losing + * `archiveReason`/`archivedBy`/`lifecycleState` and the UI still shows the row as + * archived rather than a dangling inactive non-archived ghost. + * + * Returns `incomplete` (HTTP 422 from the route layer) when the agent metadata + * needed to resume is missing. + */ + async reopenSession(sessionId: string, namespace: string): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', + code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + + if (session.active) { + return { type: 'success', sessionId: access.sessionId, resumed: false } + } + + const isArchived = metadata?.lifecycleState === 'archived' + + if (isArchived && metadata) { + if (metadata.flavor === 'cursor' && !metadata.cursorSessionId) { + const hasMessages = this.store.messages.getFirstMessages(access.sessionId, 1).length > 0 + if (hasMessages) { + return { + type: 'incomplete', + message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + } + } + } + + const archiveSnapshot = { + lifecycleState: metadata.lifecycleState, + archivedBy: metadata.archivedBy, + archiveReason: metadata.archiveReason, + lifecycleStateSince: metadata.lifecycleStateSince + } + + let applied: { cursorSessionProtocol?: 'acp' | 'stream-json' } + try { + applied = await this.sessionCache.clearSessionArchiveMetadata(access.sessionId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to clear archive metadata' + return { type: 'error', message, code: 'metadata_conflict' } + } + + const resumeResult = await this.resumeSession(access.sessionId, namespace) + if (resumeResult.type === 'error') { + // Resume failed - put the archive flags back so the row stays archived in the UI + // and the operator can retry. Best-effort: a concurrent metadata write that + // succeeded between clear and restore (e.g. an unrelated rename) wins, in + // which case we surface the original resume error rather than masking it. + try { + await this.sessionCache.restoreSessionArchiveMetadata(access.sessionId, archiveSnapshot) + } catch { + // Swallow restore failures - the resume error is the more important signal. + } + return resumeResult + } + + return { + type: 'success', + sessionId: resumeResult.sessionId, + resumed: true, + ...(applied.cursorSessionProtocol ? { cursorSessionProtocol: applied.cursorSessionProtocol } : {}) + } + } + + // Not active and not archived (e.g. brand-new session that has not yet connected, + // or one that ended without writing archive metadata). Forward to resume so the + // operator still gets one-click revival. + const resumeResult = await this.resumeSession(access.sessionId, namespace) + if (resumeResult.type === 'error') { + return resumeResult + } + + return { type: 'success', sessionId: resumeResult.sessionId, resumed: true } + } + async handoffSessionToLocal(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { diff --git a/hub/src/sync/syncEngineAutoMigrate.test.ts b/hub/src/sync/syncEngineAutoMigrate.test.ts new file mode 100644 index 000000000..38753dfe4 --- /dev/null +++ b/hub/src/sync/syncEngineAutoMigrate.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Session } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +/** + * tiann/hapi#824 — sync-on-open auto-migration tests. + * + * The helper `maybeAutoMigrateLegacyCursorSession` is the per-session gate + * that runs the transplant migrator inside `resumeSession` before the runner + * spawns. These tests cover the guard-clause matrix (env flag, metadata + * shape) and the happy-path metadata refresh. + * + * Per tiann/hapi#832: two `agent acp` processes coexist on the same host + * without conflict, and swear01's tiann/hapi#835 refactors the + * agent-acp-active lock into a cross-process refcount that explicitly + * supports this. So this helper does NOT pre-check the lock — the + * migrator's verify probe runs in an isolated HAPI_HOME (see + * verifyInTempHome) and the post-migration runner goes through the + * normal refcount-aware lock acquisition path. + * + * The migrator itself has its own 53-test unit suite (cursorLegacyMigrator + * .test.ts) and 3 integration tests against a real `agent acp`. Here we only + * verify that the SyncEngine triggers it under the right conditions and + * honours the env override. + */ +describe('SyncEngine.maybeAutoMigrateLegacyCursorSession', () => { + let store: Store + let engine: SyncEngine + let hapiHomeRoot: string + let originalEnvFlag: string | undefined + let originalHapiHome: string | undefined + + function makeLegacySession(overrides: Partial = {}): Session { + return { + id: 'session-auto-migrate-test', + machineId: 'machine-x', + createdAt: 1000, + updatedAt: 1000, + active: false, + model: null, + metadata: { + path: '/tmp/proj', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'cursor-uuid-123', + cursorSessionProtocol: 'stream-json', + ...overrides + } + } as unknown as Session + } + + beforeEach(() => { + store = new Store(':memory:') + engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never) + hapiHomeRoot = mkdtempSync(join(tmpdir(), 'auto-migrate-test-')) + originalEnvFlag = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE + originalHapiHome = process.env.HAPI_HOME + process.env.HAPI_HOME = hapiHomeRoot + delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE + }) + + afterEach(() => { + if (originalEnvFlag === undefined) { + delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE + } else { + process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = originalEnvFlag + } + if (originalHapiHome === undefined) { + delete process.env.HAPI_HOME + } else { + process.env.HAPI_HOME = originalHapiHome + } + try { rmSync(hapiHomeRoot, { recursive: true, force: true }) } catch {} + }) + + async function callHelper(session: Session): Promise { + return await (engine as unknown as { + maybeAutoMigrateLegacyCursorSession(s: Session, ns: string): Promise + }).maybeAutoMigrateLegacyCursorSession(session, 'default') + } + + function stubMigrator(outcome: { ok: boolean; reason?: string; message?: string }): { calls: Array<{ sessionId: string }> } { + const calls: Array<{ sessionId: string }> = [] + ;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({ + migrateOne: async (s: Session) => { + calls.push({ sessionId: s.id }) + if (outcome.ok) { + return { + ok: true, + sessionId: s.id, + legacyStoreDbPath: '/fake', + acpSessionDir: '/fake', + keptSource: false, + replayNotifications: 0, + lastUsedModel: null, + durationMs: 1 + } + } + return { + ok: false, + sessionId: s.id, + reason: outcome.reason ?? 'internal_error', + message: outcome.message ?? 'stub failure', + durationMs: 1 + } + } + }) + return { calls } + } + + it('skips non-cursor sessions without calling the migrator', async () => { + const session = makeLegacySession({ flavor: 'codex' as never }) + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(out).toBe(session) + expect(calls).toHaveLength(0) + }) + + it('skips already-ACP cursor sessions without calling the migrator', async () => { + const session = makeLegacySession({ cursorSessionProtocol: 'acp' as never }) + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(out).toBe(session) + expect(calls).toHaveLength(0) + }) + + it('skips cursor sessions with no cursorSessionId', async () => { + const session = makeLegacySession({ cursorSessionId: undefined }) + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(out).toBe(session) + expect(calls).toHaveLength(0) + }) + + it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=0', async () => { + process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = '0' + const session = makeLegacySession() + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(out).toBe(session) + expect(calls).toHaveLength(0) + }) + + it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=false', async () => { + process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = 'false' + const session = makeLegacySession() + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(out).toBe(session) + expect(calls).toHaveLength(0) + }) + + it('proceeds with migration regardless of the agent-acp-active lock state (refcount-aware after #835)', async () => { + // Per tiann/hapi#832/#835: multiple `agent acp` processes coexist on + // the same host. The auto-migrate helper does NOT pre-check the + // lock — the migrator's verify probe uses HAPI_HOME isolation and + // the post-migration runner uses the refcount-aware lock path. + const session = makeLegacySession() + const { calls } = stubMigrator({ ok: true }) + const out = await callHelper(session) + expect(calls).toHaveLength(1) + expect(out).toBe(session) + }) + + it('falls back to the original session when migration fails (soft fail)', async () => { + const session = makeLegacySession() + const { calls } = stubMigrator({ ok: false, reason: 'target_already_exists', message: 'collision' }) + const out = await callHelper(session) + expect(calls).toHaveLength(1) + expect(out).toBe(session) + }) + + it('swallows unexpected migrator errors and returns the original session', async () => { + const session = makeLegacySession() + ;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({ + migrateOne: async () => { throw new Error('boom') } + }) + const out = await callHelper(session) + expect(out).toBe(session) + }) + + /** + * UX A++ (Codex #34 round 14): the helper sets + * `metadata.cursorMigrationState='in_progress'` BEFORE the long-running + * transplant, surfacing the migration to the web UI via the SSE + * session-updated event. The flag is cleared on failure (and atomically + * with the protocol flip on success). These tests pin the metadata + * transitions so the web banner has a stable contract. + */ + describe('UX A++ migration-in-progress banner flag', () => { + // Insert a real cursor legacy session into the store so the helper's + // metadata writes have something to update. + function insertLegacy(sessionId: string): Session { + const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache + const persisted = cache.getOrCreateSession( + sessionId, + { + path: '/tmp/proj', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'cursor-uuid-real', + cursorSessionProtocol: 'stream-json' + }, + null, + 'default' + ) + return persisted + } + function getStoredMetadata(sessionId: string): Record | undefined { + const store = (engine as unknown as { store: Store }).store + const row = store.sessions.getSession(sessionId) + if (!row) return undefined + return row.metadata as unknown as Record + } + + it('sets cursorMigrationState=in_progress before the migrator runs and clears it on failure', async () => { + const session = insertLegacy('session-flag-fail') + + let observedFlagAtMigrate: unknown + ;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({ + migrateOne: async (s: Session) => { + observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState + return { ok: false, sessionId: s.id, reason: 'internal_error', message: 'stub fail', durationMs: 1 } + } + }) + + await callHelper(session) + expect(observedFlagAtMigrate).toBe('in_progress') + expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined() + }) + + it('sets cursorMigrationState=in_progress before the migrator runs and clears it on unexpected exception', async () => { + const session = insertLegacy('session-flag-exception') + + let observedFlagAtMigrate: unknown + ;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({ + migrateOne: async (s: Session) => { + observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState + throw new Error('boom') + } + }) + + await callHelper(session) + expect(observedFlagAtMigrate).toBe('in_progress') + expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined() + }) + + it('on migrator success the helper does NOT clear the flag (the flip writer is responsible)', async () => { + // Pins the contract that the helper itself does NOT clear the + // flag on the ok branch — flipCursorSessionProtocolToAcp does + // (in the same write that flips the protocol). We stub a + // migrator that returns ok=true WITHOUT calling the flip path, + // so the flag should still be set after the helper returns. + const session = insertLegacy('session-flag-success-no-clear') + ;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({ + migrateOne: async (s: Session) => ({ + ok: true, + sessionId: s.id, + legacyStoreDbPath: '/fake', + acpSessionDir: '/fake', + keptSource: false, + replayNotifications: 0, + lastUsedModel: null, + durationMs: 1 + }) + }) + await callHelper(session) + expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress') + }) + + it('flipCursorSessionProtocolToAcp clears cursorMigrationState in the same metadata write that flips protocol', () => { + const session = insertLegacy('session-flip-clears-flag') + const store = (engine as unknown as { store: Store }).store + const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache + + // Manually plant the flag (simulating the helper having run earlier). + const initial = store.sessions.getSession(session.id)! + const initialMeta = initial.metadata as unknown as Record + store.sessions.updateSessionMetadata( + session.id, + { ...initialMeta, cursorMigrationState: 'in_progress' } as unknown as typeof initial.metadata, + initial.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + cache.refreshSession(session.id) + + expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress') + + const result = engine.flipCursorSessionProtocolToAcp(session.id, 'default', null) + expect(result.result).toBe('success') + + const after = getStoredMetadata(session.id) + // Both must be true in a SINGLE atomic metadata write. + expect(after?.cursorSessionProtocol).toBe('acp') + expect(after?.cursorMigrationState).toBeUndefined() + }) + }) +}) diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 90a4824d6..30a24124b 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { CreateOrLoadMachineRequestSchema, CreateOrLoadSessionRequestSchema, + CursorMigrateToAcpRequestSchema, PROTOCOL_VERSION } from '@hapi/protocol' import { getConfiguration } from '../../configuration' @@ -198,6 +199,44 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not ready' }, 503) + } + const sessionId = c.req.param('id') + const namespace = c.get('namespace') + const resolved = resolveSessionForNamespace(engine, sessionId, namespace) + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status) + } + // Codex #34 P2 (round 13): mirror the sessions.ts route hardening — + // distinguish "no body" from "malformed JSON". A silent fallback to + // {} would run the migration with destructive defaults even when + // the operator's intended body was mangled in transit. + const rawBody = await c.req.text() + let body: unknown = {} + if (rawBody.trim().length > 0) { + try { + body = JSON.parse(rawBody) + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + } + const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {}) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + const outcome = await engine.migrateLegacyCursorSession(resolved.sessionId, namespace, parsed.data) + const status = outcome.ok ? 200 + : outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409 + : outcome.reason === 'running_refused' ? 409 + : outcome.reason === 'target_already_exists' ? 409 + : outcome.reason === 'no_legacy_store_on_disk' ? 404 + : 500 + return c.json(outcome, status) + }) + app.post('/machines', 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 68e1f9f16..604ef8674 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -50,10 +50,17 @@ function createSession(overrides?: Partial): Session { } } +type ReopenResultMock = + | { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' } + | { type: 'error'; message: string; code: string } + | { type: 'incomplete'; message: string; missing: [string, ...string[]] } + function createApp(session: Session, opts?: { resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> + reopenSession?: (sessionId: string, namespace: string) => Promise listSlashCommands?: SyncEngine['listSlashCommands'] getSessionExport?: (sessionId: string, session: Session) => unknown + sessionExists?: boolean }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -82,13 +89,22 @@ function createApp(session: Session, opts?: { currentModelId: 'composer-2.5' }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) + const reopenSession = opts?.reopenSession ?? (async (sessionId: string) => ({ + type: 'success' as const, + sessionId, + resumed: true + })) + const sessionExists = opts?.sessionExists !== false const engine = { - resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), + resolveSessionAccess: () => sessionExists + ? { ok: true, sessionId: session.id, session } + : { ok: false, reason: 'not-found' }, applySessionConfig, listCodexModelsForSession, listCursorModelsForSession, listOpencodeModelsForSession, resumeSession, + reopenSession, getSessionExport: opts?.getSessionExport ?? (() => ({ type: 'success', payload: { @@ -737,6 +753,132 @@ describe('sessions routes', () => { }) }) + it('reopens an archived session and reports resumed=true', async () => { + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-1', + cursorSessionProtocol: 'acp', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + } + }) + const reopenCalls: Array<[string, string]> = [] + const { app } = createApp(session, { + reopenSession: async (sessionId, namespace) => { + reopenCalls.push([sessionId, namespace]) + return { type: 'success', sessionId, resumed: true, cursorSessionProtocol: 'acp' } + } + }) + + const response = await app.request('/api/sessions/session-1/reopen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + sessionId: 'session-1', + resumed: true, + cursorSessionProtocol: 'acp' + }) + expect(reopenCalls).toEqual([['session-1', 'default']]) + }) + + it('reopens a running session as an idempotent no-op (resumed=false)', async () => { + const session = createSession({ active: true }) + const { app } = createApp(session, { + reopenSession: async (sessionId) => ({ type: 'success', sessionId, resumed: false }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + sessionId: 'session-1', + resumed: false + }) + }) + + it('returns 422 when a cursor archive is missing cursorSessionId', async () => { + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + lifecycleState: 'archived' + } + }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'incomplete', + message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + error: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }) + }) + + it('returns 404 when reopening a non-existent session', async () => { + const session = createSession() + const { app } = createApp(session, { sessionExists: false }) + + const response = await app.request('/api/sessions/missing-id/reopen', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Session not found' }) + }) + + it('maps engine resume_unavailable into a 409', async () => { + const session = createSession({ active: false }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'error', + message: 'Resume session ID unavailable', + code: 'resume_unavailable' + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Resume session ID unavailable', + code: 'resume_unavailable' + }) + }) + + it('maps engine no_machine_online into a 503', async () => { + const session = createSession({ active: false }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(503) + expect((await response.json() as { code: string }).code).toBe('no_machine_online') + }) + it('merges RPC and metadata slash commands without hiding built-ins', async () => { const session = createSession({ metadata: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 50b6f6659..b0f113f21 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,4 +1,5 @@ import { + CursorMigrateToAcpRequestSchema, DeleteUploadRequestSchema, getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, @@ -171,6 +172,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ type: 'success', sessionId: result.sessionId }) }) + app.post('/sessions/:id/reopen', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: false }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const namespace = c.get('namespace') + const result = await engine.reopenSession(sessionResult.sessionId, namespace) + + if (result.type === 'incomplete') { + return c.json({ error: result.message, missing: result.missing }, 422) + } + + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : result.code === 'resume_unavailable' ? 409 + : result.code === 'metadata_conflict' ? 409 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json({ + ok: true, + sessionId: result.sessionId, + resumed: result.resumed, + ...(result.cursorSessionProtocol ? { cursorSessionProtocol: result.cursorSessionProtocol } : {}) + }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { @@ -267,6 +304,54 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ ok: true }) }) + app.post('/sessions/:id/migrate-to-acp', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + // Codex #34 P2 (round 13): `c.req.json().catch(() => ({}))` silently + // converts malformed JSON into an empty object — which then passes + // CursorMigrateToAcpRequestSchema (all fields optional) and runs + // the migration with DESTRUCTIVE defaults (keepSource defaults to + // remove-after-flip). An operator who intended `{"keepSource": true}` + // but sent a truncated body would see the legacy store removed + // anyway. Distinguish "no body at all" (defaults are fine) from + // "malformed JSON" (reject with 400). + const rawBody = await c.req.text() + let body: unknown = {} + if (rawBody.trim().length > 0) { + try { + body = JSON.parse(rawBody) + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + } + const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {}) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + + const namespace = c.get('namespace') + const outcome = await engine.migrateLegacyCursorSession( + sessionResult.sessionId, + namespace, + parsed.data + ) + const status = outcome.ok ? 200 + : outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409 + : outcome.reason === 'running_refused' ? 409 + : outcome.reason === 'target_already_exists' ? 409 + : outcome.reason === 'no_legacy_store_on_disk' ? 404 + : 500 + return c.json(outcome, status) + }) + app.post('/sessions/:id/switch', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 2762a3732..48e933023 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -101,6 +101,22 @@ export const ResumeSessionRequestSchema = z.object({ export type ResumeSessionRequest = z.infer +export const ReopenSessionResponseSchema = z.object({ + ok: z.literal(true), + sessionId: z.string(), + resumed: z.boolean(), + cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional() +}) + +export type ReopenSessionResponse = z.infer + +export const ReopenSessionMissingMetadataResponseSchema = z.object({ + error: z.string(), + missing: z.array(z.string()).nonempty() +}) + +export type ReopenSessionMissingMetadataResponse = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) @@ -131,6 +147,40 @@ export const RenameSessionRequestSchema = z.object({ export type RenameSessionRequest = z.infer +/** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */ +export const CursorMigrateToAcpRequestSchema = z.object({ + /** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */ + keepSource: z.boolean().optional(), + /** Allow migrating a session whose lifecycleState === 'running' by archiving it first. */ + forceArchiveRunning: z.boolean().optional(), + /** Skip the verify-by-prompt step (session/load alone is run). */ + skipVerify: z.boolean().optional() +}) + +export type CursorMigrateToAcpRequest = z.infer + +export type CursorMigrateOutcome = + | { ok: true; sessionId: string; acpSessionId: string; replayNotifications: number; durationMs: number; lastUsedModelPreserved: string | null; sourceRemoved: boolean } + | { ok: false; sessionId: string; reason: CursorMigrateRefusalReason; message: string; durationMs: number } + +export type CursorMigrateRefusalReason = + | 'not_cursor_session' + | 'already_acp' + | 'running_refused' + | 'no_cursor_session_id' + | 'no_legacy_store_on_disk' + | 'target_already_exists' + | 'verify_load_failed' + | 'verify_prompt_failed' + | 'metadata_write_failed' + | 'archive_failed' + | 'lock_release_timeout' + | 'acp_transport_active' + | 'session_resumed_during_migrate' + | 'legacy_store_modified_during_migrate' + | 'cross_host_session' + | 'internal_error' + export const UploadFileRequestSchema = z.object({ filename: z.string().min(1).max(255), content: z.string().min(1), diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts index 764bff666..aca469a81 100644 --- a/shared/src/buildInfo.ts +++ b/shared/src/buildInfo.ts @@ -1 +1 @@ -export const APP_VERSION = '0.20.0' +export const APP_VERSION = '0.20.1' diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1087aeee..5671cbe69 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -39,6 +39,7 @@ export const MetadataSchema = z.object({ opencodeSessionId: z.string().optional(), cursorSessionId: z.string().optional(), cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(), + cursorMigrationState: z.enum(['in_progress']).optional(), kimiSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index a82b8b774..a0c8845a4 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -74,4 +74,16 @@ describe('toSessionSummary', () => { expect(summary.backgroundTaskCount).toBe(2) expect(summary.futureScheduledMessageCount).toBe(0) }) + + it('includes lifecycleState in summary metadata', () => { + const summary = toSessionSummary(makeSession({ + metadata: { + path: '/proj', + host: 'local', + lifecycleState: 'archived' + } + })) + + expect(summary.metadata?.lifecycleState).toBe('archived') + }) }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 97e45ee3f..16421c4be 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -18,6 +18,7 @@ export type SessionSummaryMetadata = { flavor?: string | null worktree?: WorktreeMetadata agentSessionId?: string + lifecycleState?: string } export type SessionSummary = { @@ -68,7 +69,8 @@ export function toSessionSummary(session: Session): SessionSummary { ?? session.metadata.opencodeSessionId ?? session.metadata.cursorSessionId ?? session.metadata.kimiSessionId - ?? undefined + ?? undefined, + lifecycleState: session.metadata.lifecycleState } : null const todoProgress = session.todos?.length ? { diff --git a/web/package.json b/web/package.json index f9cfc56e0..ba28bffca 100644 --- a/web/package.json +++ b/web/package.json @@ -58,10 +58,14 @@ "@tailwindcss/postcss": "^4.1.18", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", + "hast-util-to-html": "^9.0.5", "jsdom": "^26.1.0", "postcss": "^8.5.6", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", + "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16" } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 000000000..343e33364 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClient, ApiError } from './client' + +describe('ApiClient error mapping', () => { + let originalFetch: typeof globalThis.fetch + let fetchMock: ReturnType + + beforeEach(() => { + originalFetch = globalThis.fetch + fetchMock = vi.fn() + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('prefers the stable `code` field over the human-readable `error` message in ApiError.code', async () => { + // Match the shape /sessions/:id/reopen actually returns on a 503. + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: 'No machine online', code: 'no_machine_online' }), + { status: 503, statusText: 'Service Unavailable' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-X') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(503) + // The stable taxonomy must survive into ApiError.code so callers can + // branch on `no_machine_online` rather than parsing the message text. + expect(apiError.code).toBe('no_machine_online') + expect(apiError.body).toContain('no_machine_online') + } + }) + + it('falls back to `parsed.error` when `code` is absent (legacy route shape)', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: 'something broke' }), + { status: 500, statusText: 'Internal Server Error' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-Y') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).code).toBe('something broke') + } + }) + + it('passes the 422 missing-metadata body through unchanged so the UI can show the missing fields', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }), + { status: 422, statusText: 'Unprocessable Entity' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-Z') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(422) + expect(apiError.body).toContain('cursorSessionId') + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a29678a19..29e34b8b8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -25,6 +25,8 @@ import type { } from '@/types/api' import type { CodexModelsResponse, + CursorMigrateOutcome, + CursorMigrateToAcpRequest, CursorModelsResponse, DeleteUploadResponse, FileReadResponse, @@ -33,6 +35,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + ReopenSessionResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor } from '@hapi/protocol' @@ -46,12 +49,15 @@ type ApiClientOptions = { type ErrorPayload = { error?: unknown + code?: unknown } function parseErrorCode(bodyText: string): string | undefined { try { const parsed = JSON.parse(bodyText) as ErrorPayload - return typeof parsed.error === 'string' ? parsed.error : undefined + if (typeof parsed.code === 'string') return parsed.code + if (typeof parsed.error === 'string') return parsed.error + return undefined } catch { return undefined } @@ -131,7 +137,13 @@ export class ApiClient { if (!res.ok) { const body = await res.text().catch(() => '') - throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`) + const code = parseErrorCode(body) + throw new ApiError( + `HTTP ${res.status} ${res.statusText}: ${body}`, + res.status, + code, + body || undefined + ) } return await res.json() as T @@ -408,6 +420,61 @@ export class ApiClient { }) } + async reopenSession(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/reopen`, + { method: 'POST', body: JSON.stringify({}) } + ) + } + + /** + * Migrate a legacy stream-json Cursor session to ACP. See tiann/hapi#824. + * + * Refusals (e.g. running session, missing on-disk store, target collision) + * are returned as structured `{ok: false, reason, message}` outcomes + * rather than thrown - the UI surfaces the reason to the operator and the + * underlying state on disk is unchanged. + * + * 401s trigger the same onUnauthorized refresh path as the shared + * `request()` helper so an expired JWT silently re-auths instead of + * hard-failing the migration dialog (Codex review #34 P2). + */ + async migrateCursorSessionToAcp(sessionId: string, body: CursorMigrateToAcpRequest = {}): Promise { + const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp` + const tryOnce = async (overrideToken: string | null): Promise => { + const headers = new Headers({ 'content-type': 'application/json' }) + const liveToken = this.getToken ? this.getToken() : null + const authToken = overrideToken ?? liveToken ?? this.token + if (authToken) { + headers.set('authorization', `Bearer ${authToken}`) + } + return fetch(this.buildUrl(path), { method: 'POST', headers, body: JSON.stringify(body) }) + } + + let res = await tryOnce(null) + if (res.status === 401 && this.onUnauthorized) { + const refreshed = await this.onUnauthorized() + if (refreshed) { + this.token = refreshed + res = await tryOnce(refreshed) + } + } + if (res.status === 401) { + throw new Error('Session expired. Please sign in again.') + } + const text = await res.text() + let parsed: CursorMigrateOutcome | null = null + try { + parsed = text ? JSON.parse(text) as CursorMigrateOutcome : null + } catch { + parsed = null + } + if (parsed && typeof parsed === 'object' && 'ok' in parsed) { + return parsed + } + throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`) + } + async switchSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { method: 'POST', diff --git a/web/src/components/AssistantChat/ComposerButtons.test.tsx b/web/src/components/AssistantChat/ComposerButtons.test.tsx new file mode 100644 index 000000000..90ed25beb --- /dev/null +++ b/web/src/components/AssistantChat/ComposerButtons.test.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { UnifiedButton } from './ComposerButtons' + +function renderInProviders(ui: ReactElement) { + return render({ui}) +} + +/** + * Regression tests for upstream review on PR #798 + * (github-actions[bot] [Major]: "Send button advertises scratchlist + * routing even when the submit will go to chat"). + * + * UnifiedButton's visible state (amber + "Send to scratchlist" label + * vs. black + "Send message" label) MUST reflect the actual routing + * decision rather than the raw scratchlist toggle. Callers are + * responsible for computing routesToScratchlist from + * (mode, attachments, schedule); these tests pin the contract that + * routesToScratchlist=false drives the chat-style render. + */ + +function getButton(label: RegExp | string): HTMLButtonElement { + return screen.getByRole('button', { name: label }) as HTMLButtonElement +} + +describe('UnifiedButton — routesToScratchlist visual state', () => { + const noop = () => {} + + afterEach(() => { + cleanup() + }) + + it('paints amber + announces "Send to scratchlist" when routesToScratchlist=true', () => { + renderInProviders( + , + ) + const btn = getButton(/scratchlist/i) + expect(btn.className).toContain('bg-amber-500') + }) + + it('paints chat black + announces "Send" when routesToScratchlist=false even if scratchlist toggle conceptually on', () => { + // Caller computed routesToScratchlist=false because the payload + // would carry attachments or a pending schedule. The button must + // therefore look like a normal chat send. + renderInProviders( + , + ) + const btn = getButton('Send') + expect(btn.className).not.toContain('bg-amber-500') + expect(btn.className).toContain('bg-black') + }) + + it('defaults routesToScratchlist to false when omitted', () => { + renderInProviders( + , + ) + const btn = getButton('Send') + expect(btn.className).not.toContain('bg-amber-500') + }) +}) diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 28d1fcc88..5b0325a6f 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -4,6 +4,8 @@ import { useTranslation } from '@/lib/use-translation' import { ScheduleIcon } from '@/components/icons' import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' +import { useFue } from '@/lib/use-fue' +import { FueCallout, FueDot } from '@/components/Fue' import { useRef, useState } from 'react' function VoiceAssistantIcon() { @@ -198,6 +200,101 @@ function SendIcon() { ) } +function ScratchlistToggleIcon() { + return ( + + + + + ) +} + +/** + * ScratchlistToggleButton — composer affordance for toggling scratchlist mode, + * wrapped in the generic FUE (First-User Experience) primitive so a new + * operator sees a pulsing dot + a one-time explainer popover the first time + * they encounter the feature; once they engage with it, the dot disappears + * for good and the entry counter takes over. + * + * The FUE wiring here is the canonical example for future features: the + * pattern is "wrap the affordance in useFue + FueDot, conditionally render + * FueCallout while engaging". See web/src/lib/use-fue.ts for the contract. + */ +function ScratchlistToggleButton(props: { + scratchlistMode: boolean + scratchlistCount: number + onScratchlistToggle: () => void + controlsDisabled?: boolean +}) { + const { t } = useTranslation() + const fue = useFue('scratchlist-toggle') + const buttonRef = useRef(null) + + const showFueDot = fue.status !== 'acknowledged' + // Counter and FUE dot are mutually exclusive (see FueDot doc comment). + // Onboarding signal beats inventory signal: the user can't read the + // counter as "you have N items" until they understand the feature. + const showCounter = !showFueDot && props.scratchlistCount > 0 + + return ( + <> + + {fue.status === 'engaging' ? ( + + ) : null} + + ) +} + function StopIcon() { return ( void onVoiceToggle: () => void + /** + * When true, the send button repaints amber and the aria-label + * announces "Send to scratchlist" instead of "Send message". The + * actual routing happens in SessionChat's wrapped onSend - the + * button itself is content-agnostic. + * + * Caller MUST compute this from the actual routing decision (mode + * AND no-attachments AND no-pending-schedule), not the raw + * scratchlist toggle. If the toggle is on but the submission would + * fall back to chat (because the scratchlist can't represent the + * payload), the button must look like a normal chat send. Per + * upstream review on PR #798: [Major] "Send button advertises + * scratchlist routing even when the submit will go to chat". + */ + routesToScratchlist?: boolean }) { const { t } = useTranslation() - // Determine button state const isConnecting = props.voiceStatus === 'connecting' const isConnected = props.voiceStatus === 'connected' const isVoiceActive = isConnecting || isConnected const hasText = props.canSend + const routesToScratchlist = props.routesToScratchlist ?? false - // Determine button behavior const handleClick = () => { if (isVoiceActive) { props.onVoiceToggle() // Stop voice } else if (hasText) { - props.onSend() // Send message - } else if (props.voiceEnabled) { - props.onVoiceToggle() // Start voice + props.onSend() // Send message (or scratchlist add — wrapper decides) + } else if (props.voiceEnabled && !routesToScratchlist) { + props.onVoiceToggle() // Start voice (suppressed in scratchlist mode) } } - // Determine button style and icon let icon: React.ReactNode let className: string let ariaLabel: string @@ -270,6 +380,13 @@ function UnifiedButton(props: { icon = className = 'bg-black text-white' ariaLabel = t('composer.stop') + } else if (routesToScratchlist) { + // Amber send button - matches the scratchlist drawer accent. + // Single visual signal carries the "this goes to the scratchlist" + // contract; without it, the modal state is invisible to the user. + icon = + className = 'bg-amber-500 text-white hover:bg-amber-600' + ariaLabel = t('scratchlist.sendToScratchlist') } else if (hasText) { icon = className = 'bg-black text-white' @@ -284,7 +401,16 @@ function UnifiedButton(props: { ariaLabel = t('composer.send') } - const isDisabled = props.controlsDisabled || (!hasText && !props.voiceEnabled && !isVoiceActive) + // When the submission routes to scratchlist the send button is the + // only path that does anything useful, so it must be enabled whenever + // there is text - we deliberately do NOT fall back to voice-toggle-on- + // empty-text. (When attachments / schedule force a chat fallback the + // normal chat-send disable rules apply.) + const isDisabled = props.controlsDisabled || ( + routesToScratchlist + ? !hasText + : !hasText && !props.voiceEnabled && !isVoiceActive + ) return ( ) : null} + {/* + * Scratchlist toggle - prototype of the composer-controlled + * drawer (replaces the always-visible orange band). Counter + * shown only when entries exist (>0); empty-state shows just + * the icon to avoid the "you have 0 things" guilt UI. + * + * Clicking enters scratchlist mode: the send button repaints + * amber and SessionChat's wrapped onSend routes the next + * submission to addScratchlistEntry() instead of the chat. + * Mode is sticky - operator clicks the icon again to exit. + */} + {props.onScratchlistToggle ? ( + + ) : null} + {/* Schedule button — only shown when onSchedule handler is provided */} {props.onSchedule ? ( <> @@ -466,6 +619,21 @@ export function ComposerButtons(props: { controlsDisabled={props.controlsDisabled} onSend={props.onSend} onVoiceToggle={props.onVoiceToggle} + /* + * Derived, NOT raw scratchlistMode. Mirror SessionChat's + * shouldRouteToScratchlist so the visible send-button state + * matches the actual routing decision: amber + "Send to + * scratchlist" only when mode is on AND the payload would + * be a pure-text scratchlist add. Attachments or a pending + * schedule force a chat fallback in onSendForComposer; the + * button must reflect that, otherwise the UI lies about + * where the user's content is going. + */ + routesToScratchlist={ + (props.scratchlistMode ?? false) + && !hasAttachments + && props.pendingSchedule == null + } /> ) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index c47dcdae2..96ae5753f 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -40,6 +40,31 @@ export interface TextInputState { selection: { start: number; end: number } } +/** + * One rejected send. `id` is bumped per failure so two failures with the + * same `text` still trigger a fresh restore (the dedupe key is the id, not + * the text). + * + * - `text` is the original input that should be put back into the composer. + * - `message` is the user-facing error string we render inline. + * - `scheduledAt` is the absolute epoch-ms the rejected send was bound for, + * or null for an immediate send. When non-null, the composer also + * restores the schedule via `onSchedule` so the operator can edit and + * retry without silently downgrading a scheduled send to immediate. + * + * Owned by the route component (`router.tsx`); the composer is a pure + * consumer that: + * 1. restores the text once per `id` via `api.composer().setText`, + * 2. restores the schedule (if any) via `onSchedule`, and + * 3. shows a red ring + inline message until the user types or sends. + */ +export type ComposerSendError = { + id: number + text: string + message: string + scheduledAt: number | null +} + const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { @@ -89,6 +114,17 @@ export function HappyComposer(props: { pendingSchedule?: PendingSchedule | null onSchedule?: (pending: PendingSchedule) => void onClearSchedule?: () => void + // Scratchlist drawer props - SessionChat owns the state. Threaded + // straight through to ComposerButtons. When undefined, the toggle + // button doesn't render (back-compat for any other consumer). + scratchlistMode?: boolean + scratchlistCount?: number + onScratchlistToggle?: () => void + // Set when the most recent send failed (4xx/5xx/network). The composer + // restores the original text once per `sendError.id` and renders an + // inline error affordance until the user dismisses or starts editing. + sendError?: ComposerSendError | null + onClearSendError?: () => void }) { const { t } = useTranslation() const { @@ -131,7 +167,9 @@ export function HappyComposer(props: { onVoiceMicToggle, pendingSchedule: pendingScheduleProp, onSchedule: onScheduleProp, - onClearSchedule: onClearScheduleProp + onClearSchedule: onClearScheduleProp, + sendError = null, + onClearSendError } = props // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) @@ -183,6 +221,40 @@ export function HappyComposer(props: { useComposerDraft(sessionId, composerText, (text) => api.composer().setText(text)) + // assistant-ui clears `composer.text` synchronously the moment a send is + // invoked AND `SessionChat.handleSend` clears `pendingSchedule` the + // moment the mutation is accepted, so by the time the mutation's + // onError fires both the typed text and the schedule are gone. When + // the route hands us a `sendError`, splice both back in -- once per + // `sendError.id` so a second failure with the same text still triggers + // a fresh restore. + const restoredErrorIdRef = useRef(null) + useEffect(() => { + if (!sendError) { + return + } + if (restoredErrorIdRef.current === sendError.id) { + return + } + restoredErrorIdRef.current = sendError.id + // Only restore when the composer is empty. If the user has already + // typed something new (rare -- composer is `disabled` during send, + // but possible if isSending toggles before this effect runs), we + // would otherwise stomp on their fresh input. + if (composerText.length === 0 && sendError.text.length > 0) { + api.composer().setText(sendError.text) + } + // Restore the pending schedule too. `scheduledAt` was already + // resolved to an absolute epoch-ms before the failed send (presets + // are computed at send time -- see `resolvePendingSchedule`), so + // we feed it back as an 'absolute' PendingSchedule. The existing + // shouldAutoClearPendingSchedule effect in SessionChat handles the + // case where the absolute time has passed by the time we restore. + if (sendError.scheduledAt !== null && onScheduleProp) { + onScheduleProp({ type: 'absolute', ms: sendError.scheduledAt }) + } + }, [sendError, api, composerText, onScheduleProp]) + useEffect(() => { setInputState((prev) => { if (prev.text === composerText) return prev @@ -438,7 +510,13 @@ export function HappyComposer(props: { end: e.target.selectionEnd } setInputState({ text: e.target.value, selection }) - }, []) + // Editing the restored text is the operator's "I'm handling it" + // signal -- drop the inline error so the affordance doesn't shout + // at them while they fix the message. + if (sendError && onClearSendError) { + onClearSendError() + } + }, [sendError, onClearSendError]) const handleSelect = useCallback((e: ReactSyntheticEvent) => { const target = e.target as HTMLTextAreaElement @@ -559,6 +637,11 @@ export function HappyComposer(props: { // and async inactive-session resume failure. Clearing here unconditionally // would race ahead of that check and drop the user's schedule on every // rejected send path. + // + // The inline send-error affordance is intentionally NOT cleared here: + // the route-level state (`onSuccess`/`onError` in router.tsx) replaces + // or clears it based on the actual mutation result, so the user keeps + // the error context while the new attempt is in flight. }, [api]) const overlays = useMemo(() => { @@ -890,7 +973,21 @@ export function HappyComposer(props: { /> ) : null} -
+ {sendError ? ( +
+ {sendError.message} +
+ ) : null} + +
{attachments.length > 0 ? (
@@ -941,6 +1038,9 @@ export function HappyComposer(props: { onSchedule={setPendingSchedule} onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)} hasAttachments={hasAttachments} + scratchlistMode={props.scratchlistMode} + scratchlistCount={props.scratchlistCount} + onScratchlistToggle={props.onScratchlistToggle} />
diff --git a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx index 06bb4de3d..79cd55c6b 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx @@ -61,6 +61,21 @@ describe('ScratchlistPanel', () => { expect(toggle.textContent).toContain('held') }) + it('uses the chat user surface for the panel background and keeps a subtle amber border (regression guard for #812)', () => { + // The amber chrome was too loud as an always-visible scroll element + // (#812). The fix swaps the warning *fill* for the chat-user-surface + // tone but keeps the warning *border* as a soft accent so the panel + // still reads as a different destination from a normal user message. + // The strong amber destination signal lives on the composer Send + // button, not here. See PR 827 (swear01) for the styling note this + // test guards. + renderPanel() + const panel = screen.getByTestId('scratchlist-panel') + expect(panel.className).toContain('bg-[var(--app-chat-user-surface-bg)]') + expect(panel.className).not.toContain('bg-[var(--app-badge-warning-bg)]') + expect(panel.className).toContain('border-[var(--app-badge-warning-border)]') + }) + it('starts collapsed by default; clicking the header expands it', () => { renderPanel() const toggle = screen.getByRole('button', { name: /Scratchlist/ }) @@ -208,6 +223,63 @@ describe('ScratchlistPanel', () => { expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a']) }) + it('copy button writes the entry text to clipboard and shows briefly the "Copied" tooltip', async () => { + // Clipboard API isn't implemented in jsdom; install a mock that + // captures the writeText call. (web/src/lib/clipboard.ts already + // tries navigator.clipboard first, then falls back to execCommand.) + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + persistScratchlist(SID, [makeEntry({ id: 'a', text: 'copy me' })]) + renderPanel() + expandPanel() + + const copyBtn = screen.getByRole('button', { name: 'Copy to clipboard' }) + fireEvent.click(copyBtn) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith('copy me')) + + // After the async copy resolves, the same button (it stays in the + // DOM, only its label/icon flip) should advertise the success. + await waitFor(() => + expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(), + ) + // Entry is preserved — copy is non-destructive. + expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a']) + }) + + it('clipboard write failure leaves the icon in the default state (no false success)', async () => { + // Force navigator.clipboard.writeText to reject AND make the + // execCommand fallback fail too, so safeCopyToClipboard throws. + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + // jsdom doesn't implement document.execCommand. Define a stub + // that returns false so safeCopyToClipboard's fallback path + // also fails (covering the "everything failed" branch). + Object.defineProperty(document, 'execCommand', { + value: () => false, + configurable: true, + writable: true, + }) + + persistScratchlist(SID, [makeEntry({ id: 'a', text: 'try copy' })]) + renderPanel() + expandPanel() + + fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' })) + + await waitFor(() => expect(writeText).toHaveBeenCalled()) + // Should NOT flip to "Copied!" because the copy failed. + expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull() + expect(screen.getByRole('button', { name: 'Copy to clipboard' })).toBeTruthy() + }) + it('persists collapse state across mounts for the same session', () => { const { unmount } = renderPanel() expandPanel() diff --git a/web/src/components/AssistantChat/ScratchlistPanel.tsx b/web/src/components/AssistantChat/ScratchlistPanel.tsx index b9f274511..f684b6922 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.tsx @@ -18,6 +18,7 @@ import { shouldConfirmDelete, type ScratchlistEntry, } from '@/lib/scratchlist' +import { safeCopyToClipboard } from '@/lib/clipboard' import { useTranslation } from '@/lib/use-translation' const STORAGE_KEY_PREFIX = 'hapi.scratchlist-collapsed.v1.' @@ -134,6 +135,290 @@ function TrashIcon() { ) } +function CopyIcon() { + return ( + + ) +} + +function ClipboardCheckIcon() { + return ( + + ) +} + +/** + * Tracks which entry was most-recently copied to the clipboard so the UI + * can briefly swap the copy icon to a check + the tooltip to "Copied". + * Auto-clears after `clearAfterMs` (default 1500ms). Pure state machine - + * the caller wires `safeCopyToClipboard` separately so the hook stays + * easy to test and free of jsdom clipboard quirks. + */ +const COPIED_FEEDBACK_MS = 1500 +function useCopiedFeedback(clearAfterMs: number = COPIED_FEEDBACK_MS) { + const [copiedEntryId, setCopiedEntryId] = useState(null) + const timerRef = useRef | null>(null) + const signalCopied = useCallback((entryId: string) => { + setCopiedEntryId(entryId) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopiedEntryId(null), clearAfterMs) + }, [clearAfterMs]) + useEffect(() => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, []) + return { copiedEntryId, signalCopied } +} + +/** + * Inventory list with per-entry action buttons. Pure presentational - takes + * entries + callbacks. Used by both the always-visible ScratchlistPanel + * and the composer-controlled drawer below. + */ +function ScratchlistInventory({ + entries, + busyEntryId, + onPromoteToComposer, + onPromoteToQueue, + onDelete, + onMove, +}: { + entries: ScratchlistEntry[] + busyEntryId: string | null + onPromoteToComposer: (entry: ScratchlistEntry) => void + onPromoteToQueue: (entry: ScratchlistEntry) => void + onDelete: (entry: ScratchlistEntry) => void + onMove: (entry: ScratchlistEntry, direction: 'up' | 'down') => void +}) { + const { t } = useTranslation() + const { copiedEntryId, signalCopied } = useCopiedFeedback() + const handleCopy = useCallback(async (entry: ScratchlistEntry) => { + try { + await safeCopyToClipboard(entry.text) + signalCopied(entry.id) + } catch { + // safeCopyToClipboard exhausted both the navigator.clipboard + // path and the execCommand fallback; nothing useful left to do. + // Silently no-op rather than throw at the click handler. + } + }, [signalCopied]) + if (entries.length === 0) { + return ( +

+ {t('scratchlist.emptyHint')} +

+ ) + } + return ( +
    + {entries.map((entry, index) => { + const isFirst = index === 0 + const isLast = index === entries.length - 1 + const isBusy = busyEntryId === entry.id + return ( +
  • + + {entry.text} + +
    + + + + + + +
    +
  • + ) + })} +
+ ) +} + +/** + * Composer-controlled drawer. No own header / no own textarea: the composer + * is the input source (composerSendsToScratchlist toggle in SessionChat). + * + * State is owned by the caller via useScratchlist(). The drawer is purely + * presentational + behavior glue around the inventory list. + */ +export function ScratchlistDrawer({ + entries, + onMove, + onDelete, + onPromoteToComposer, + onPromoteToQueue, +}: { + entries: ScratchlistEntry[] + onMove: (id: string, direction: 'up' | 'down') => void + onDelete: (id: string) => void + onPromoteToComposer: (text: string) => void + onPromoteToQueue: (text: string) => Promise +}) { + const { t } = useTranslation() + const [busyEntryId, setBusyEntryId] = useState(null) + + const summary = useMemo(() => { + if (entries.length === 0) return t('scratchlist.empty') + if (entries.length === 1) return t('scratchlist.count.one') + return t('scratchlist.count.other', { n: entries.length }) + }, [entries.length, t]) + + const handleDelete = useCallback((entry: ScratchlistEntry) => { + if (shouldConfirmDelete(entry)) { + const confirmed = typeof window !== 'undefined' + ? window.confirm(t('scratchlist.confirmDelete')) + : true + if (!confirmed) return + } + onDelete(entry.id) + }, [onDelete, t]) + + const handleMove = useCallback((entry: ScratchlistEntry, direction: 'up' | 'down') => { + onMove(entry.id, direction) + }, [onMove]) + + const handlePromoteToComposer = useCallback((entry: ScratchlistEntry) => { + onPromoteToComposer(entry.text) + }, [onPromoteToComposer]) + + const handlePromoteToQueue = useCallback(async (entry: ScratchlistEntry) => { + if (busyEntryId) return + setBusyEntryId(entry.id) + try { + const accepted = await onPromoteToQueue(entry.text) + if (accepted) onDelete(entry.id) + } finally { + setBusyEntryId(null) + } + }, [busyEntryId, onDelete, onPromoteToQueue]) + + return ( +
+
+
+ + + {t('scratchlist.title')} + + + + {summary} + +
+ +
+

+ {t('scratchlist.drawerHint')} +

+ +
+
+
+ ) +} + /** * Per-session scratchlist (issue #11) -- the operator's "workbench". * @@ -142,8 +427,11 @@ function TrashIcon() { * - Scratchlist = workbench: notes / drafts / parking-lot ideas held until the * operator explicitly promotes them (to the composer or into the queue). * - * The "held -- not sent" pill plus the amber accent is the visual signal - * that nothing here is being sent without an explicit action. + * The "held -- not sent" pill plus a subtle amber border is the visual + * signal that nothing here is being sent without an explicit action. The + * panel surface mirrors the user-message chat surface so it stays calm in + * the scroll; the strong amber destination signal lives on the composer + * Send button (which only goes amber while scratchlist mode is routing). */ export function ScratchlistPanel({ sessionId, @@ -172,6 +460,15 @@ export function ScratchlistPanel({ const [draft, setDraft] = useState('') const [busyEntryId, setBusyEntryId] = useState(null) const inputRef = useRef(null) + const { copiedEntryId, signalCopied } = useCopiedFeedback() + const handleCopy = useCallback(async (entry: ScratchlistEntry) => { + try { + await safeCopyToClipboard(entry.text) + signalCopied(entry.id) + } catch { + // see ScratchlistInventory.handleCopy for rationale + } + }, [signalCopied]) // Re-hydrate when the session id changes (route navigation between sessions). useEffect(() => { @@ -278,7 +575,7 @@ export function ScratchlistPanel({ return (
@@ -408,6 +705,25 @@ export function ScratchlistPanel({ > + +
+ {/* Affirmative-action dismiss. No auto-timeout: reading speed + varies, and a popover that disappears on its own undercuts + the "user is in control" model. */} +
+ +
+
+ ) + + return createPortal(node, document.body) +} diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx new file mode 100644 index 000000000..ec53b0f11 --- /dev/null +++ b/web/src/components/SessionActionMenu.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionActionMenu } from '@/components/SessionActionMenu' + +afterEach(() => cleanup()) + +function renderMenu(overrides: Partial> = {}) { + const defaults: React.ComponentProps = { + isOpen: true, + onClose: vi.fn(), + sessionActive: false, + onRename: vi.fn(), + onArchive: vi.fn(), + onReopen: vi.fn(), + onDelete: vi.fn(), + anchorPoint: { x: 0, y: 0 }, + } + const merged = { ...defaults, ...overrides } + return { + ...render( + + + + ), + props: merged + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('SessionActionMenu - Reopen action', () => { + it('renders the Reopen item on inactive sessions when onReopen is provided', () => { + renderMenu({ sessionActive: false }) + + expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument() + }) + + it('does not render the Reopen item on active sessions', () => { + renderMenu({ sessionActive: true }) + + expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull() + }) + + it('does not render the Reopen item when onReopen is omitted (back-compat)', () => { + renderMenu({ sessionActive: false, onReopen: undefined }) + + expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull() + // Delete item is still present for inactive sessions. + expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument() + }) + + it('fires onReopen and closes the menu when the Reopen item is clicked', () => { + const onReopen = vi.fn() + const onClose = vi.fn() + renderMenu({ sessionActive: false, onReopen, onClose }) + + fireEvent.click(screen.getByRole('menuitem', { name: /Reopen/ })) + + expect(onReopen).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders Reopen alongside Delete for inactive sessions', () => { + renderMenu({ sessionActive: false }) + + expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument() + // Archive should not show up for inactive sessions (it is the active-session destructive). + expect(screen.queryByRole('menuitem', { name: /Archive/ })).toBeNull() + }) +}) diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 60068b9dc..9a958b401 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -16,6 +16,7 @@ type SessionActionMenuProps = { onRename: () => void onExport?: () => void onArchive: () => void + onReopen?: () => void onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -83,6 +84,26 @@ function DownloadIcon(props: { className?: string }) { ) } +function ReopenIcon(props: { className?: string }) { + return ( + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onReopen?.() + } + const handleExport = () => { onClose() onExport?.() @@ -290,15 +317,28 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.archive')} ) : ( - + <> + {onReopen ? ( + + ) : null} + + )}
diff --git a/web/src/components/SessionChat.exit-mode.test.tsx b/web/src/components/SessionChat.exit-mode.test.tsx new file mode 100644 index 000000000..f0e0f145a --- /dev/null +++ b/web/src/components/SessionChat.exit-mode.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import type { ScratchlistEntry } from '@/lib/scratchlist' + +/** + * Regression test for upstream review on PR #798 (HAPI Bot follow-up + * after b256fe5): + * + * > Found one major issue: promoting a scratchlist item to the + * > composer keeps scratchlist mode enabled, so the next send re-adds + * > it to the scratchlist instead of sending to chat. + * + * The fix is for ScratchlistDrawerHost to call `onExitScratchlistMode` + * whenever it promotes an entry to the composer (since promoting means + * "I want to send this for real now"). This test mocks the assistant-ui + * runtime hook and asserts both the setText call AND the exit-mode call + * fire when the operator clicks promote-to-composer. + * + * Promote-to-queue does NOT exit the mode - the queue path bypasses the + * scratchlist-mode wrapper entirely, and the operator may still want to + * capture related notes. + */ + +const setText = vi.fn() +vi.mock('@assistant-ui/react', () => ({ + useAssistantApi: () => ({ + composer: () => ({ setText }), + }), +})) + +import { ScratchlistDrawerHost } from './SessionChat' + +function makeEntry(overrides: Partial & { id: string }): ScratchlistEntry { + return { text: 'note', createdAt: 1000, ...overrides } +} + +afterEach(() => { + cleanup() + setText.mockReset() +}) + +describe('ScratchlistDrawerHost.onPromoteToComposer', () => { + it('exits scratchlist mode AND sets composer text when an entry is promoted to composer', () => { + const onExitScratchlistMode = vi.fn() + const onSend = vi.fn(async () => true) + const onMove = vi.fn() + const onDelete = vi.fn() + + render( + + + , + ) + + // The drawer renders a "promote to composer" button per entry. + // Match by aria-label so we do not depend on icon/glyph copy. + const promoteButtons = screen.getAllByRole('button', { name: /composer|edit/i }) + expect(promoteButtons.length).toBeGreaterThan(0) + fireEvent.click(promoteButtons[0]!) + + expect(setText).toHaveBeenCalledWith('queued thought') + expect(onExitScratchlistMode).toHaveBeenCalledTimes(1) + // Promote-to-composer must NOT call onSend (that's promote-to-queue). + expect(onSend).not.toHaveBeenCalled() + }) + + it('does NOT exit scratchlist mode when an entry is promoted to queue', async () => { + const onExitScratchlistMode = vi.fn() + const onSend = vi.fn(async () => true) + const onMove = vi.fn() + const onDelete = vi.fn() + + render( + + + , + ) + + const queueButtons = screen.getAllByRole('button', { name: /queue|send/i }) + expect(queueButtons.length).toBeGreaterThan(0) + fireEvent.click(queueButtons[0]!) + + // Allow the async onSend to settle + await Promise.resolve() + await Promise.resolve() + + expect(onSend).toHaveBeenCalledWith('send-to-queue text') + expect(onExitScratchlistMode).not.toHaveBeenCalled() + expect(setText).not.toHaveBeenCalled() + }) +}) + +describe('ScratchlistDrawer copy-to-clipboard action', () => { + it('writes the entry text to the clipboard and flips the button label to "Copied!" briefly', async () => { + // Mock navigator.clipboard so safeCopyToClipboard's primary path + // resolves successfully (it tries this before the execCommand fallback). + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const onExitScratchlistMode = vi.fn() + const onSend = vi.fn(async () => true) + const onMove = vi.fn() + const onDelete = vi.fn() + + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' })) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith('copy this')) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(), + ) + + // Copy must NOT mutate the list — entry stays, no other handlers fire. + expect(onDelete).not.toHaveBeenCalled() + expect(onSend).not.toHaveBeenCalled() + expect(setText).not.toHaveBeenCalled() + expect(onExitScratchlistMode).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/SessionChat.test.ts b/web/src/components/SessionChat.test.ts index d3618bd8d..2e92dab62 100644 --- a/web/src/components/SessionChat.test.ts +++ b/web/src/components/SessionChat.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest' -import { buildGoalStateMessages, shouldAutoClearPendingSchedule } from './SessionChat' +import { + buildGoalStateMessages, + isScratchlistHotkeyBlockedTarget, + isScratchlistToggleHotkey, + shouldAutoClearPendingSchedule, + shouldRouteToScratchlist, +} from './SessionChat' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' -import type { DecryptedMessage } from '@/types/api' +import type { AttachmentMetadata, DecryptedMessage } from '@/types/api' function userMessage(props: { id: string @@ -67,6 +73,173 @@ describe('shouldAutoClearPendingSchedule', () => { }) }) +/** + * Unit tests for shouldRouteToScratchlist. + * + * Regression cover for upstream review on PR #798 (github-actions[bot] + * [Major]): scratchlist-mode submissions used to silently drop + * attachments and scheduledAt because the wrapper short-circuited to + * scratchlist.add(text) regardless of payload. The fix is to fall + * through to the regular chat send whenever the submission can't be + * represented as a pure-text scratchlist entry. + */ +describe('shouldRouteToScratchlist', () => { + function attachment(): AttachmentMetadata { + return { + id: 'attach-1', + filename: 'attach-1.png', + mimeType: 'image/png', + size: 1024, + path: '/tmp/attach-1.png', + } + } + + it('returns false when scratchlist mode is off, regardless of payload', () => { + expect(shouldRouteToScratchlist(false, undefined, null)).toBe(false) + expect(shouldRouteToScratchlist(false, [attachment()], null)).toBe(false) + expect(shouldRouteToScratchlist(false, undefined, Date.now() + 60_000)).toBe(false) + }) + + it('returns true when scratchlist mode is on and the payload is pure text', () => { + expect(shouldRouteToScratchlist(true, undefined, null)).toBe(true) + expect(shouldRouteToScratchlist(true, undefined, undefined)).toBe(true) + expect(shouldRouteToScratchlist(true, [], null)).toBe(true) + }) + + it('returns false when scratchlist mode is on but attachments are present', () => { + expect(shouldRouteToScratchlist(true, [attachment()], null)).toBe(false) + expect(shouldRouteToScratchlist(true, [attachment(), attachment()], null)).toBe(false) + }) + + it('returns false when scratchlist mode is on but a scheduled-send is set', () => { + expect(shouldRouteToScratchlist(true, undefined, Date.now() + 60_000)).toBe(false) + expect(shouldRouteToScratchlist(true, [], 0)).toBe(false) + }) + + it('returns false when both attachments and scheduledAt are set', () => { + expect(shouldRouteToScratchlist(true, [attachment()], Date.now() + 60_000)).toBe(false) + }) + + /** + * Bot follow-up on PR #798: handleSend gates pendingSchedule cleanup on + * routedToScratchlist, not scratchlistMode. So a scheduled chat send made + * while the scratchlist toggle is on (which falls through to chat per + * the previous tests) MUST also trigger schedule clear + scroll bump. + * This test pins the decision matrix that handleSend depends on. + */ + it('cleanup gate: scheduled chat send while scratchlist toggle is on still clears schedule', () => { + const scheduledAt = Date.now() + 60_000 + // Scenario: mode on, no attachments, scheduled. shouldRouteToScratchlist + // must return false so handleSend's `if (!routedToScratchlist)` runs + // setPendingSchedule(null). + const routed = shouldRouteToScratchlist(true, undefined, scheduledAt) + expect(routed).toBe(false) + const shouldClearAfterAccepted = !routed + expect(shouldClearAfterAccepted).toBe(true) + }) + + it('cleanup gate: pure-text scratchlist add does NOT clear schedule', () => { + const routed = shouldRouteToScratchlist(true, undefined, null) + expect(routed).toBe(true) + const shouldClearAfterAccepted = !routed + expect(shouldClearAfterAccepted).toBe(false) + }) +}) + +describe('isScratchlistToggleHotkey', () => { + function k(over: Partial<{ + metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; key: string + }>): { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; key: string } { + return { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, key: '', ...over } + } + + it('matches Ctrl+Shift+S (Linux/Windows)', () => { + expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'S' }))).toBe(true) + expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 's' }))).toBe(true) + }) + + it('matches Cmd+Shift+S (macOS)', () => { + expect(isScratchlistToggleHotkey(k({ metaKey: true, shiftKey: true, key: 'S' }))).toBe(true) + }) + + it('rejects Cmd/Ctrl + S without shift (browser Save)', () => { + // Browsers reserve Ctrl-S / Cmd-S for "Save Page". The toggle MUST + // require shift so the user's save-page muscle memory keeps working. + expect(isScratchlistToggleHotkey(k({ ctrlKey: true, key: 's' }))).toBe(false) + expect(isScratchlistToggleHotkey(k({ metaKey: true, key: 's' }))).toBe(false) + }) + + it('rejects bare S / Shift+S (literal typing)', () => { + expect(isScratchlistToggleHotkey(k({ key: 's' }))).toBe(false) + expect(isScratchlistToggleHotkey(k({ shiftKey: true, key: 'S' }))).toBe(false) + }) + + it('rejects when Alt is also held (avoid clashes with OS shortcuts)', () => { + expect(isScratchlistToggleHotkey(k({ + ctrlKey: true, shiftKey: true, altKey: true, key: 'S', + }))).toBe(false) + }) + + it('rejects unrelated keys', () => { + expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'A' }))).toBe(false) + expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'Tab' }))).toBe(false) + }) +}) + +describe('isScratchlistHotkeyBlockedTarget', () => { + // Note: tests run under jsdom, so HTMLElement / HTMLInputElement etc. + // are real constructors that we can construct via document.createElement. + + it('blocks hotkey when focus is in a single-line input', () => { + const input = document.createElement('input') + expect(isScratchlistHotkeyBlockedTarget(input)).toBe(true) + }) + + it('blocks hotkey when focus is in a select element', () => { + const select = document.createElement('select') + expect(isScratchlistHotkeyBlockedTarget(select)).toBe(true) + }) + + it('blocks hotkey when focus is on a contentEditable host', () => { + const div = document.createElement('div') + div.setAttribute('contenteditable', 'true') + expect(isScratchlistHotkeyBlockedTarget(div)).toBe(true) + }) + + it('blocks hotkey when focus is anywhere inside a [role=dialog]', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + const inner = document.createElement('button') + dialog.appendChild(inner) + document.body.appendChild(dialog) + expect(isScratchlistHotkeyBlockedTarget(inner)).toBe(true) + document.body.removeChild(dialog) + }) + + it('does NOT block hotkey when focus is on the composer textarea', () => { + // The composer textarea is the EXPECTED focus target when the + // operator presses the shortcut. Blocking it would defeat the + // shortcut entirely. + const textarea = document.createElement('textarea') + expect(isScratchlistHotkeyBlockedTarget(textarea)).toBe(false) + }) + + it('does NOT block hotkey when focus is on a regular button', () => { + const button = document.createElement('button') + expect(isScratchlistHotkeyBlockedTarget(button)).toBe(false) + }) + + it('does NOT block hotkey when target is null (unfocused)', () => { + expect(isScratchlistHotkeyBlockedTarget(null)).toBe(false) + }) + + it('does NOT block hotkey when target is non-Element (e.g. window)', () => { + // Some keyboard events come with a non-Element target (e.g. window + // before focus settles). Should fall through. + expect(isScratchlistHotkeyBlockedTarget(window as unknown as EventTarget)).toBe(false) + }) +}) + describe('buildGoalStateMessages', () => { it('keeps immediate queued user messages so completed goal status can clear before timeline render', () => { const now = 1_700_000_000_000 diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 12c86dbf7..913f10c65 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -19,16 +19,18 @@ import { buildConversationOutline } from '@/chat/outline' import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups' import { isQueuedForInvocation, mergeMessages } from '@/lib/messages' import { inactiveSessionCanResume } from '@/lib/sessionResume' -import { HappyComposer } from '@/components/AssistantChat/HappyComposer' +import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar' -import { ScratchlistPanel } from '@/components/AssistantChat/ScratchlistPanel' +import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel' +import { useScratchlist } from '@/lib/use-scratchlist' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { useTranslation } from '@/lib/use-translation' import { SessionHeader } from '@/components/SessionHeader' +import { CursorMigrationBanner } from '@/components/CursorMigrationBanner' import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' @@ -36,11 +38,14 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { - buildCursorCatalogFromSources, - buildCursorPickerState, + mergeCursorCliModelSkus, resolveCursorBaseFromWire } from '@/lib/cursorPickerState' import { + buildSessionCursorPickerState, + isSessionCursorCatalogAwaitingSkus, + isSessionCursorCatalogPendingWithTimeout, + SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS, resolveSessionCursorBaseSelectValue, resolveSessionCursorModelChange, resolveSessionCursorVariantSelectValue @@ -64,33 +69,135 @@ export function shouldAutoClearPendingSchedule(pending: PendingSchedule | null): return pending !== null && pending.type === 'absolute' } +/** + * True if the keystroke matches the scratchlist-mode toggle shortcut + * (Ctrl/Cmd + Shift + S, no Alt). Pure / exported for unit tests. + * + * Convention: matches the v1 always-visible panel's shortcut so muscle + * memory carries over. Sibling globals follow the same modifier shape + * (Ctrl/Cmd-m cycles agent model in HappyComposer). + */ +export function isScratchlistToggleHotkey(e: { + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean + key: string +}): boolean { + if (!(e.metaKey || e.ctrlKey)) return false + if (!e.shiftKey) return false + if (e.altKey) return false + return e.key === 'S' || e.key === 's' +} + +/** + * True when the global scratchlist hotkey should be SKIPPED for the + * given event target. Window-level shortcuts that fire regardless of + * focus can quietly toggle modes "behind" modal dialogs (rename, + * schedule picker, FUE callout) and that's the kind of UX bug the bot + * caught on PR #798. + * + * Block targets: + * - any descendant of an open dialog (Radix UI's DialogContent renders + * role="dialog", as do FueCallout / ScheduleTimePicker / ImagePreview) + * - HTMLInputElement (single-line inputs) + * - HTMLSelectElement + * - any contentEditable host + * + * NOT blocked: + * - HTMLTextAreaElement (the composer textarea is the normal focus + * target when the operator presses the hotkey - blocking it would + * defeat the shortcut) + * - the document body / unfocused targets + * + * Pure / exported for unit tests. + */ +export function isScratchlistHotkeyBlockedTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + if (target.closest('[role="dialog"]') !== null) return true + if (target instanceof HTMLInputElement) return true + if (target instanceof HTMLSelectElement) return true + // isContentEditable is the authoritative check in real browsers but + // jsdom doesn't implement it; the attribute fallback covers both. + if (target.isContentEditable === true) return true + return target.getAttribute('contenteditable') === 'true' +} + +/** + * Decide whether a submit should be routed to the per-session scratchlist + * or to the regular chat send. Scratchlist entries are pure text - they + * don't carry attachments or schedules - so any submit that includes + * either of those MUST fall through to the normal chat path even if the + * scratchlist toggle is on. Otherwise the wrapper would silently drop + * attachments / scheduled-send metadata while telling the composer the + * submission succeeded (which then clears the composer state, losing + * the user's data). + * + * Per upstream review on PR #798 (github-actions[bot] [Major]). + * + * Pure / exported so it can be unit tested without mounting SessionChat. + */ +export function shouldRouteToScratchlist( + scratchlistMode: boolean, + attachments: AttachmentMetadata[] | undefined, + scheduledAt: number | null | undefined, +): boolean { + if (!scratchlistMode) return false + if (attachments && attachments.length > 0) return false + if (scheduledAt != null) return false + return true +} + function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { return message.invokedAt == null && message.scheduledAt != null } /** - * Mounts the per-session scratchlist (issue #11) inside the AssistantUI - * runtime so promote-to-composer can call `composer().setText(...)`. - * Promote-to-queue routes to the same `onSend` path as a normal composer - * send, so a promoted entry shows up immediately in `QueuedMessagesBar`. + * Mounts the per-session scratchlist DRAWER (composer-controlled). + * + * The drawer renders only when the operator toggles into "scratchlist + * mode" via the notepad icon in the composer toolbar. While in that mode: + * - drawer (this component) is visible above the composer + * - composer's send button repaints amber (handled in ComposerButtons) + * - SessionChat's wrapped onSend routes adds into the scratchlist + * + * Entries state is owned by SessionChat's useScratchlist() so the + * composer-toolbar counter and the drawer share one source of truth. */ -function ScratchlistHost({ - sessionId, - onSend, -}: { - sessionId: string +export function ScratchlistDrawerHost(props: { + entries: ReturnType['entries'] + onMove: ReturnType['move'] + onDelete: ReturnType['remove'] onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise + /** + * Called when the operator promotes an entry to the composer. + * + * Promoting means "I want to send this for real now" - so the host + * MUST exit scratchlist mode, otherwise the next composer submit + * routes back to scratchlist (per the v1.1 modal-mode contract) and + * the user re-adds the same text instead of sending it to chat. + * Per upstream review on PR #798 (HAPI Bot, v6 follow-up). + */ + onExitScratchlistMode: () => void }) { const assistantApi = useAssistantApi() const handlePromoteToComposer = useCallback((text: string) => { assistantApi.composer().setText(text) - }, [assistantApi]) + props.onExitScratchlistMode() + }, [assistantApi, props.onExitScratchlistMode]) const handlePromoteToQueue = useCallback(async (text: string) => { - return await onSend(text) - }, [onSend]) + // Promote-to-queue bypasses the scratchlist-mode wrapper by + // calling props.onSend directly (the chat send), so the queue + // entry lands in the conversation regardless of scratchlist + // mode. Mode itself stays on - the operator may still be + // capturing related notes. + return await props.onSend(text) + }, [props.onSend]) return ( - @@ -138,7 +245,7 @@ function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean { return false } -export function SessionChat(props: { +type SessionChatProps = { api: ApiClient session: Session messages: DecryptedMessage[] @@ -163,7 +270,35 @@ export function SessionChat(props: { onRetryMessage?: (localId: string) => void autocompleteSuggestions?: (query: string) => Promise availableSlashCommands?: readonly SlashCommand[] -}) { + // The latest send the hub rejected (4xx/5xx/network). When set, the + // composer is asked to restore the typed text and surface an inline + // error -- see HappyComposer. Cleared by `onClearSendError` once the + // user dismisses or starts editing. + sendError?: ComposerSendError | null + onClearSendError?: () => void +} + +/** + * Public entry point. Thin wrapper around `SessionChatInner` keyed by + * the session id so that ALL inner state - including the scratchlist + * (entries + mode) and the assistant-ui runtime - resets atomically + * when the operator navigates between sessions on the same route + * (e.g. /sessions/A -> /sessions/B). + * + * Without the key, React reuses the same component instance, and + * effects run AFTER the first paint of the new session. That window + * briefly renders the new session with the previous session's + * scratchlist entries / drawer-open state, which is the bot finding + * on PR #798 (PRRT_kwDOQuQOSc6HHOsa). The keyed wrapper is the + * canonical React pattern for "fully reset state on prop change"; it + * supersedes the effect-based mode-reset that previously lived in + * SessionChatInner. + */ +export function SessionChat(props: SessionChatProps) { + return +} + +function SessionChatInner(props: SessionChatProps) { const { haptic } = usePlatform() const { t } = useTranslation() const navigate = useNavigate() @@ -177,6 +312,79 @@ export function SessionChat(props: { const [outlineOpen, setOutlineOpen] = useState(false) const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) + const scratchlist = useScratchlist(props.session.id) + const [scratchlistMode, setScratchlistMode] = useState(false) + // Mode resets across sessions implicitly: SessionChat is keyed by + // session.id at the public-export boundary, so a session switch + // remounts SessionChatInner from scratch and `scratchlistMode` + // initializes to false again. (Previous effect-based reset was + // racy on first paint - see public-export comment for context.) + const handleScratchlistToggle = useCallback(() => { + setScratchlistMode((m) => !m) + }, []) + /** + * Global keyboard shortcut: Ctrl/Cmd + Shift + S toggles scratchlist + * mode (open/close drawer + flip composer routing). + * + * Convention matches the v1 always-visible panel's shortcut so muscle + * memory carries over. Other composer-adjacent globals in the app use + * the same modifier shape: Ctrl/Cmd-m cycles agent model in + * HappyComposer. Ctrl/Cmd-Shift-S is unreserved by Chrome / Firefox / + * Safari at the app level (browser Save As is Ctrl-S / Cmd-S, no + * Shift), so requiring Shift keeps the user's save-page muscle memory + * working. Bound at SessionChat scope (not the drawer) because the + * drawer is unmounted while mode is off — a drawer-scoped listener + * couldn't reopen it. + * + * Skipped when focus is inside an open dialog or single-line input + * (see isScratchlistHotkeyBlockedTarget). Otherwise fires for any + * focus target - composer textarea is the expected case so it's + * deliberately allowed. Window-level shortcut without target + * filtering would silently toggle mode "behind" modal dialogs + * (rename, schedule picker, FUE callout); the bot caught this on + * PR #798. + */ + useEffect(() => { + const onKeyDown = (e: globalThis.KeyboardEvent) => { + if (!isScratchlistToggleHotkey(e)) return + if (isScratchlistHotkeyBlockedTarget(e.target)) return + e.preventDefault() + setScratchlistMode((m) => !m) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + /** + * onSend wrapper: when scratchlist mode is on AND the submission is + * pure text (no attachments, no scheduledAt), the operator's submit + * is treated as "add to scratchlist" instead of "send to chat". + * + * If the submission carries attachments or a scheduledAt value, + * scratchlist can't represent it (entries are text-only), so we + * fall through to the normal chat send. Silently dropping + * attachments / schedule while reporting success to the composer + * caused PR #798 review's [Major] data-loss finding. + * + * The composer (HappyComposer) uses the boolean return value to + * decide whether to clear text/attachments/schedule, so we resolve + * true on a successful add - the operator's text gets cleared and + * they can keep adding entries while sticky-mode is on. If add() + * returns false (empty after trim, at-cap), we resolve false so + * the composer keeps its text and the operator can fix it. + */ + const onSendForComposer = useCallback( + async ( + text: string, + attachments?: AttachmentMetadata[], + scheduledAt?: number | null, + ): Promise => { + if (shouldRouteToScratchlist(scratchlistMode, attachments, scheduledAt)) { + return scratchlist.add(text) + } + return props.onSend(text, attachments, scheduledAt) + }, + [props.onSend, scratchlist, scratchlistMode], + ) const agentFlavor = props.session.metadata?.flavor ?? null const controlledByUser = props.session.agentState?.controlledByUser === true const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser @@ -225,39 +433,73 @@ export function SessionChat(props: { machineId: sessionMachineId, enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId) }) - const sessionCliModelSkus = useMemo(() => { - if (cursorModelsState.cliModelSkus.length > 0) { - return cursorModelsState.cliModelSkus - } - return machineCursorModelsState.cliModelSkus - }, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus]) + const sessionCliModelSkus = useMemo(() => ( + mergeCursorCliModelSkus( + machineCursorModelsState.cliModelSkus, + cursorModelsState.cliModelSkus + ) + ), [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus]) const cursorPicker = useMemo(() => { if (agentFlavor !== 'cursor') { return null } - const catalog = buildCursorCatalogFromSources({ + return buildSessionCursorPickerState({ sessionModels: cursorModelsState.availableModels, machineModels: machineCursorModelsState.availableModels, cliModelSkus: sessionCliModelSkus, - currentWireId: cursorModelsState.currentModelId ?? props.session.model, - sessionModelFromHub: props.session.model, - defaultValue: null - }) - return buildCursorPickerState({ - catalog, - currentWireId: props.session.model ?? cursorModelsState.currentModelId, - defaultValue: null + sessionModel: props.session.model, + sessionCurrentModelId: cursorModelsState.currentModelId }) }, [ agentFlavor, cursorModelsState.availableModels, - cursorModelsState.cliModelSkus, cursorModelsState.currentModelId, machineCursorModelsState.availableModels, sessionCliModelSkus, props.session.model ]) + const cursorCatalogReadinessArgs = useMemo(() => ({ + sessionLoading: cursorModelsState.isLoading, + machineLoading: machineCursorModelsState.isLoading, + hasMachineId: Boolean(sessionMachineId), + sessionError: cursorModelsState.error, + machineError: machineCursorModelsState.error, + mergedSkus: sessionCliModelSkus, + picker: cursorPicker + }), [ + cursorModelsState.isLoading, + cursorModelsState.error, + machineCursorModelsState.isLoading, + machineCursorModelsState.error, + sessionMachineId, + sessionCliModelSkus, + cursorPicker + ]) + const cursorCatalogAwaitingSkus = useMemo( + () => isSessionCursorCatalogAwaitingSkus(cursorCatalogReadinessArgs), + [cursorCatalogReadinessArgs] + ) + const [cursorSkuAwaitingSince, setCursorSkuAwaitingSince] = useState(null) + const [cursorCatalogNowMs, setCursorCatalogNowMs] = useState(() => Date.now()) + useEffect(() => { + if (cursorCatalogAwaitingSkus) { + setCursorSkuAwaitingSince((previous) => previous ?? Date.now()) + const timer = setTimeout( + () => setCursorCatalogNowMs(Date.now()), + SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS + ) + return () => clearTimeout(timer) + } + setCursorSkuAwaitingSince(null) + setCursorCatalogNowMs(Date.now()) + return undefined + }, [cursorCatalogAwaitingSkus]) + const cursorCatalogPending = isSessionCursorCatalogPendingWithTimeout({ + ...cursorCatalogReadinessArgs, + awaitingStartedAtMs: cursorSkuAwaitingSince, + nowMs: cursorCatalogNowMs + }) useEffect(() => { if (agentFlavor !== 'cursor' || !cursorPicker) { @@ -671,15 +913,32 @@ export function SessionChat(props: { }, [pendingSchedule]) const handleSend = useCallback(async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => { - const accepted = await props.onSend(text, attachments, scheduledAt) + // Route through the scratchlist-aware wrapper. When scratchlistMode + // is on AND the payload is pure text, this turns into + // addScratchlistEntry; otherwise it goes to props.onSend (the chat + // send path). The wrapper resolves true on success either way so + // the composer-clear is shared, but the schedule-clear / scroll + // dance below must gate on the actual route taken (not just + // scratchlistMode), or a scheduled chat send made while the + // scratchlist toggle is on will leave pendingSchedule sticky and + // the next normal send would reuse the same schedule. (Per + // upstream review on PR #798: [Major] "Clear accepted scheduled + // chat sends after scratchlist fallback".) + const routedToScratchlist = shouldRouteToScratchlist(scratchlistMode, attachments, scheduledAt) + const accepted = await onSendForComposer(text, attachments, scheduledAt) if (!accepted) return - // Clear pendingSchedule only after the mutation is actually accepted — - // covers both pre-mutation guards AND async inactive-session resume - // failure. SessionChat is the single owner of schedule clear (HappyComposer - // no longer clears on its own send path). - setPendingSchedule(null) - setForceScrollToken((token) => token + 1) - }, [props.onSend]) + if (!routedToScratchlist) { + // Clear pendingSchedule only after the mutation is actually + // accepted - covers both pre-mutation guards AND async + // inactive-session resume failure. SessionChat is the single + // owner of schedule clear (HappyComposer no longer clears on + // its own send path). Schedule clear / forced scroll only + // matter for chat sends; scratchlist adds don't have a + // schedule and shouldn't move the chat viewport. + setPendingSchedule(null) + setForceScrollToken((token) => token + 1) + } + }, [onSendForComposer, scratchlistMode]) const attachmentAdapter = useMemo(() => { if (!props.session.active) { @@ -709,8 +968,17 @@ export function SessionChat(props: { onOpenOutline={() => setOutlineOpen(true)} api={props.api} onSessionDeleted={props.onBack} + onSessionReopened={(newSessionId) => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: newSessionId }, + replace: true + }) + }} /> + + {props.session.teamState && ( )} @@ -763,23 +1031,21 @@ export function SessionChat(props: {
{/* - * Key by session id so React unmounts/remounts when - * the operator switches sessions without remounting - * SessionChat (e.g. same-route navigation A -> B). - * Without this, ScratchlistPanel's useState - * initializer reads sessionId once at mount; the - * useEffect rehydrate then races against the persist - * effect, briefly rendering A's entries under B and - * writing them into B's localStorage before - * correcting. Keying makes the first render for B - * read B's storage directly. Cleaner than chasing - * the race inside the panel. + * Scratchlist drawer - composer-controlled. Only + * mounted when the operator clicks the notepad icon + * in the composer toolbar. State lives in the + * useScratchlist hook above (so the toolbar counter + * and the drawer share one source of truth). */} - + {scratchlistMode ? ( + setScratchlistMode(false)} + /> + ) : null} 1 @@ -856,7 +1125,7 @@ export function SessionChat(props: { : agentFlavor === 'cursor' ? (props.session.active && !controlledByUser - && !cursorModelsState.isLoading + && !cursorCatalogPending && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 @@ -868,6 +1137,7 @@ export function SessionChat(props: { agentFlavor === 'cursor' && props.session.active && !controlledByUser + && !cursorCatalogPending && !cursorModelsState.error ? handleCursorEffortChange : undefined @@ -886,6 +1156,11 @@ export function SessionChat(props: { voiceMicMuted={voice?.micMuted} onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined} onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined} + scratchlistMode={scratchlistMode} + scratchlistCount={scratchlist.entries.length} + onScratchlistToggle={handleScratchlistToggle} + sendError={props.sendError ?? null} + onClearSendError={props.onClearSendError} />
diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 407b23d4c..a65f84d0f 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -7,6 +7,7 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { SessionExportDialog } from '@/components/SessionExportDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { formatReopenError } from '@/lib/reopenError' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' @@ -93,9 +94,10 @@ export function SessionHeader(props: { onOpenOutline?: () => void api: ApiClient | null onSessionDeleted?: () => void + onSessionReopened?: (newSessionId: string) => void }) { const { t } = useTranslation() - const { session, api, onSessionDeleted } = props + const { session, api, onSessionDeleted, onSessionReopened } = props const title = useMemo(() => getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch const modelLabel = getSessionModelLabel(session) @@ -109,17 +111,30 @@ export function SessionHeader(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions( api, session.id, session.metadata?.flavor ?? null ) + const [reopenError, setReopenError] = useState(null) const handleDelete = async () => { await deleteSession() onSessionDeleted?.() } + const handleReopen = async () => { + setReopenError(null) + try { + const result = await reopenSession() + if (result.sessionId && result.sessionId !== session.id) { + onSessionReopened?.(result.sessionId) + } + } catch (error) { + setReopenError(formatReopenError(error)) + } + } + const handleMenuToggle = () => { if (!menuOpen && menuAnchorRef.current) { const rect = menuAnchorRef.current.getBoundingClientRect() @@ -225,11 +240,25 @@ export function SessionHeader(props: { onRename={() => setRenameOpen(true)} onExport={() => setExportOpen(true)} onArchive={() => setArchiveOpen(true)} + onReopen={handleReopen} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} menuId={menuId} /> + {reopenError ? ( + setReopenError(null)} + title={t('dialog.reopen.errorTitle')} + description={reopenError} + confirmLabel={t('dialog.reopen.dismiss')} + confirmingLabel={t('dialog.reopen.dismiss')} + onConfirm={async () => setReopenError(null)} + isPending={false} + /> + ) : null} + setRenameOpen(false)} diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 23a4deecb..7d2270d31 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' import type { SessionSummary } from '@/types/api' -import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, getVisibleSessionPreview, normalizeSearch, sessionMatchesQuery } from './SessionList' +import { + deduplicateSessionsByAgentId, + expandSelectedSessionCollapseOverrides, + getSessionDedupKey, + getVisibleSessionPreview, + isSidebarEmptySessionStub, + normalizeSearch, + prepareSidebarSessions, + sessionMatchesQuery, + shouldShowSessionInSidebar +} from './SessionList' function makeSession(overrides: Partial & { id: string }): SessionSummary { return { @@ -71,6 +81,25 @@ describe('deduplicateSessionsByAgentId', () => { expect(result).toHaveLength(3) }) + it('deduplicates cursor sessions by summary agentSessionId', () => { + const sessions = [ + makeSession({ + id: 'a', + active: true, + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, + updatedAt: 100 + }), + makeSession({ + id: 'b', + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, + updatedAt: 200 + }) + ] + const result = deduplicateSessionsByAgentId(sessions) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('a') + }) + it('deduplicates independently across different agentSessionIds', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), @@ -82,8 +111,115 @@ describe('deduplicateSessionsByAgentId', () => { expect(result).toHaveLength(2) expect(result.map(s => s.id).sort()).toEqual(['b', 'd']) }) + + it('does not dedupe across flavors sharing the same flattened agentSessionId', () => { + const sessions = [ + makeSession({ + id: 'codex', + metadata: { path: '/p', flavor: 'codex', agentSessionId: 'stale-shared-id' }, + updatedAt: 100 + }), + makeSession({ + id: 'cursor', + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'stale-shared-id' }, + updatedAt: 200 + }) + ] + + expect(getSessionDedupKey(sessions[0])).toBe('codex:stale-shared-id') + expect(getSessionDedupKey(sessions[1])).toBe('cursor:stale-shared-id') + expect(deduplicateSessionsByAgentId(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) + expect(prepareSidebarSessions(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) + }) +}) + + +describe('isSidebarEmptySessionStub', () => { + it('treats inactive sessions without agent id or title as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'stub', + metadata: { path: '/work/hapi' } + }))).toBe(true) + }) + + it('does not treat active sessions as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'live', + active: true, + metadata: { path: '/work/hapi' } + }))).toBe(false) + }) + + it('does not treat sessions with agentSessionId as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'resume', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } + }))).toBe(false) + }) + + it('does not treat sessions with summary text as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'titled', + metadata: { path: '/work/hapi', summary: { text: 'Fix sidebar' } } + }))).toBe(false) + }) }) +describe('prepareSidebarSessions', () => { + it('hides inactive empty stubs but keeps real sessions', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'real', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1', summary: { text: 'Real chat' } } + }) + ] + + const result = prepareSidebarSessions(sessions) + expect(result.map(session => session.id)).toEqual(['real']) + }) + + it('keeps the selected inactive stub visible', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'real', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } + }) + ] + + const result = prepareSidebarSessions(sessions, 'stub') + expect(result.map(session => session.id).sort()).toEqual(['real', 'stub']) + }) + + it('deduplicates before filtering stubs', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'older', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, + updatedAt: 100 + }), + makeSession({ + id: 'newer', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, + updatedAt: 200 + }) + ] + + const result = prepareSidebarSessions(sessions) + expect(result.map(session => session.id)).toEqual(['newer']) + }) +}) + +describe('shouldShowSessionInSidebar', () => { + it('always shows active and selected sessions', () => { + const stub = makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }) + expect(shouldShowSessionInSidebar(stub)).toBe(false) + expect(shouldShowSessionInSidebar(stub, 'stub')).toBe(true) + expect(shouldShowSessionInSidebar({ ...stub, active: true })).toBe(true) + }) +}) describe('session list search helpers', () => { it('normalizes whitespace and case before filtering', () => { diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index bc39cc4d7..a173e7aeb 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -17,6 +17,7 @@ import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' +import { formatReopenError } from '@/lib/reopenError' type SessionGroup = { key: string @@ -100,21 +101,29 @@ function getGroupDisplayName(directory: string): string { export const UNKNOWN_MACHINE_ID = '__unknown__' export const GROUP_SESSION_PREVIEW_LIMIT = DEFAULT_SESSION_PREVIEW_LIMIT +export function getSessionDedupKey(session: SessionSummary): string | null { + const agentId = session.metadata?.agentSessionId?.trim() + if (!agentId) return null + // Scope by flavor: agentSessionId is flattened from native ids and can retain a + // stale cross-flavor value (codexSessionId ?? claudeSessionId ?? ...). + return `${session.metadata?.flavor ?? 'unknown'}:${agentId}` +} + export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { const byAgentId = new Map() const result: SessionSummary[] = [] for (const session of sessions) { - const agentId = session.metadata?.agentSessionId - if (!agentId) { + const dedupKey = getSessionDedupKey(session) + if (!dedupKey) { result.push(session) continue } - const group = byAgentId.get(agentId) + const group = byAgentId.get(dedupKey) if (group) { group.push(session) } else { - byAgentId.set(agentId, [session]) + byAgentId.set(dedupKey, [session]) } } @@ -133,6 +142,34 @@ export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selecte return result } +function hasSidebarTitleSignal(session: SessionSummary): boolean { + const meta = session.metadata + if (!meta) return false + if (meta.name?.trim()) return true + if (meta.summary?.text?.trim()) return true + return false +} + +export function isSidebarEmptySessionStub(session: SessionSummary): boolean { + if (session.active) return false + const meta = session.metadata + if (!meta) return true + if (meta.agentSessionId?.trim()) return false + if (hasSidebarTitleSignal(session)) return false + return true +} + +export function shouldShowSessionInSidebar(session: SessionSummary, selectedSessionId?: string | null): boolean { + if (session.id === selectedSessionId) return true + if (session.active) return true + return !isSidebarEmptySessionStub(session) +} + +export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { + return deduplicateSessionsByAgentId(sessions, selectedSessionId) + .filter(session => shouldShowSessionInSidebar(session, selectedSessionId)) +} + function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { const groups = new Map() @@ -567,11 +604,26 @@ function SessionItem(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions( api, s.id, s.metadata?.flavor ?? null ) + const [reopenError, setReopenError] = useState(null) + + const handleReopen = async () => { + setReopenError(null) + try { + const result = await reopenSession() + // resumeSession may merge the row into a freshly-spawned sessionId. + // Follow it so the operator lands on the live session. + if (result.sessionId && result.sessionId !== s.id) { + onSelect(result.sessionId) + } + } catch (error) { + setReopenError(formatReopenError(error)) + } + } const longPressHandlers = useLongPress({ onLongPress: (point) => { @@ -661,10 +713,24 @@ function SessionItem(props: { sessionActive={s.active} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} + onReopen={handleReopen} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} /> + {reopenError ? ( + setReopenError(null)} + title={t('dialog.reopen.errorTitle')} + description={reopenError} + confirmLabel={t('dialog.reopen.dismiss')} + confirmingLabel={t('dialog.reopen.dismiss')} + onConfirm={async () => setReopenError(null)} + isPending={false} + /> + ) : null} + setRenameOpen(false)} @@ -741,8 +807,8 @@ export function SessionList(props: { } const allSessions = useMemo( - () => props.sessions, - [props.sessions] + () => prepareSidebarSessions(props.sessions, selectedSessionId), + [props.sessions, selectedSessionId] ) const visibleSessions = useMemo( () => isSearching @@ -890,7 +956,7 @@ export function SessionList(props: {
{isSearching ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length }) - : t('sessions.count', { n: props.sessions.length, m: allGroups.length })} + : t('sessions.count', { n: allSessions.length, m: allGroups.length })}