From c87f88f02fbebe907d77238212affaa266726b78 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:26:41 +0100 Subject: [PATCH 01/14] fix(web): disable single-dollar inline math in remark-math (#805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web/markdown): disable single-dollar inline math in remark-math Default remark-math configuration treats $...$ as inline LaTeX, which turns any prose containing two currency amounts (e.g. "save $400 vs the $200 plan") into a KaTeX block — paragraphs collapse, whitespace is stripped, the running text is re-rendered as math symbols. Pass `singleDollarTextMath: false` to remarkMath so single $ is plain text. Block math `$$...$$` (on its own line) still renders, matching GitHub-flavored markdown semantics. Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText, Reasoning, and MarkdownRenderer — fix lands in all three surfaces. Adds 3 regression tests that drive the unified pipeline end-to-end: prose with multiple "$N" amounts produces no `class="katex"` and no `` element; `$$...$$` block math still does. Co-authored-by: Cursor * chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html The new markdown-text regression test imports these directly to drive the unified pipeline end-to-end. They were resolving via transitive deps from remark-math and rehype-katex, which is fragile — a future dep upgrade can remove the transitives and break the test. Declare them explicitly under devDependencies. No code change; lockfile records the same versions that were already installed transitively (unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2, hast-util-to-html@9.0.5). Addresses the HAPI Bot review finding on PR #805. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bun.lock | 6 +++ web/package.json | 4 ++ .../assistant-ui/markdown-text.test.ts | 54 ++++++++++++++++++- .../components/assistant-ui/markdown-text.tsx | 9 +++- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 92fe93d1c..14b926e3c 100644 --- a/bun.lock +++ b/bun.lock @@ -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", }, @@ -1070,6 +1074,8 @@ "@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-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/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/components/assistant-ui/markdown-text.test.ts b/web/src/components/assistant-ui/markdown-text.test.ts index 9a7b2a329..df4adea72 100644 --- a/web/src/components/assistant-ui/markdown-text.test.ts +++ b/web/src/components/assistant-ui/markdown-text.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from 'vitest' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import { toHtml } from 'hast-util-to-html' import remarkBreaks from 'remark-breaks' import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' -import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS } from '@/components/assistant-ui/markdown-text' +import { + MARKDOWN_PLUGINS, + MARKDOWN_PLUGINS_WITH_BREAKS, + MARKDOWN_REHYPE_PLUGINS, +} from '@/components/assistant-ui/markdown-text' describe('MARKDOWN_PLUGINS integration', () => { it('includes remarkNonHttpsAutolink', () => { @@ -21,3 +29,47 @@ describe('MARKDOWN_PLUGINS integration', () => { expect(MARKDOWN_PLUGINS_WITH_BREAKS).toContain(remarkBreaks) }) }) + +function render(markdown: string): string { + const processor = unified() + .use(remarkParse) + .use(MARKDOWN_PLUGINS) + .use(remarkRehype) + .use(MARKDOWN_REHYPE_PLUGINS) + const tree = processor.runSync(processor.parse(markdown)) + return toHtml(tree as never) +} + +describe('MARKDOWN_PLUGINS — currency prose vs KaTeX', () => { + // Regression: prose with multiple "$N" amounts must NOT be eaten by KaTeX. + // remarkMath is configured with `singleDollarTextMath: false` so single + // dollar signs are treated as literal text, matching GitHub markdown. + + it('does not render single-$ currency amounts as KaTeX math', () => { + const md = "The plan is $200/mo and the bill is $80 — total $400 saved." + const html = render(md) + expect(html).not.toContain('class="katex"') + expect(html).not.toContain(' { + // Lifted (paraphrased) from the bug report: paragraph with multiple + // "$N" amounts and apostrophes that previously collapsed into a single + // KaTeX block and stripped whitespace from the running text. + const md = "Cursor's UI quotes the ratio: at least $400 of API usage on a $200 plan. That's 2:1." + const html = render(md) + expect(html).not.toContain('class="katex"') + expect(html).not.toContain(' { + const md = "Before\n\n$$\nE = mc^2\n$$\n\nAfter" + const html = render(md) + expect(html).toContain('class="katex"') + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index b07d6cc7a..4b7fe53d1 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -34,10 +34,17 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' // from them. Both must come before remarkMath (to avoid treating TeX as URI). // remarkFilePathLinks runs last to convert file paths → links after all other // transforms have settled. +// +// remarkMath is configured with `singleDollarTextMath: false` so that prose +// containing currency amounts (e.g. "$200/mo ... $80 bill") is not garbled +// into KaTeX output. Block math `$$...$$` (on its own line) still works. +// This matches GitHub-flavored markdown behavior. The option lives on the +// shared TAIL so both MARKDOWN_PLUGINS (default) and MARKDOWN_PLUGINS_WITH_BREAKS +// (user-prompt rendering with hard breaks) inherit the fix. const MARKDOWN_PLUGIN_TAIL = [ remarkNonHttpsAutolink, remarkStripCjkAutolink, - remarkMath, + [remarkMath, { singleDollarTextMath: false }], remarkDisableIndentedCode, remarkFilePathLinks, // upstream — file path → link conversion, runs last ] satisfies NonNullable From 9349acba62ea4ad61ccae1830e15dc0d54df9f72 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:27:00 +0100 Subject: [PATCH 02/14] fix(web): use session flavor label in voice context formatters (closes #680) (#815) Replaces hardcoded \"Claude Code\" strings in voice context injections with the active session's flavor label (Cursor, Codex, Gemini, etc.) via getFlavorLabel() from @hapi/protocol. Falls back to \"coding agent\" for unknown or missing flavors. Threads an agentLabel string param through: - formatMessage - formatNewSingleMessage - formatNewMessages - formatHistory - formatSessionFull - formatPermissionRequest - buildSessionVoiceContextPlan (passes to its internal formatMessage call) voiceHooks adds a single getAgentLabel(session) helper that reads session.metadata.flavor and resolves the display label once per call. Tests updated to cover the new parameter shape and to assert that \"Claude Code\" is never substituted regardless of agent flavor. formatReadyEvent already used a generic \"coding agent\" phrasing and needs no signature change. Closes #680 Co-authored-by: Cursor --- .../realtime/hooks/contextFormatters.test.ts | 42 +++++++++++++---- web/src/realtime/hooks/contextFormatters.ts | 45 +++++++++++-------- .../realtime/hooks/voiceContextPlan.test.ts | 5 ++- web/src/realtime/hooks/voiceContextPlan.ts | 8 +++- web/src/realtime/hooks/voiceHooks.ts | 26 ++++++++--- 5 files changed, 88 insertions(+), 38 deletions(-) diff --git a/web/src/realtime/hooks/contextFormatters.test.ts b/web/src/realtime/hooks/contextFormatters.test.ts index c2b4ac759..b1692edf0 100644 --- a/web/src/realtime/hooks/contextFormatters.test.ts +++ b/web/src/realtime/hooks/contextFormatters.test.ts @@ -125,7 +125,7 @@ describe('formatReadyEvent', () => { }) describe('formatMessage', () => { - it('formats codex stream-json assistant messages for voice context', () => { + it('uses the supplied agent label as the assistant prefix', () => { const formatted = formatMessage(msg({ id: '1', seq: 1, @@ -139,12 +139,33 @@ describe('formatMessage', () => { } } } - })) + }), 'Codex') - expect(formatted).toContain('Claude Code:') + expect(formatted).toContain('Codex:') + expect(formatted).not.toContain('Claude Code') expect(formatted).toContain('Indexed 5,018 items in the search database.') }) + it('threads agentLabel for a different flavor (regression for #680)', () => { + const formatted = formatMessage(msg({ + id: '1', + seq: 1, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Cursor is generating a plan.' + } + } + } + }), 'Cursor') + + expect(formatted).toContain('Cursor:') + expect(formatted).not.toContain('Claude Code') + }) + it('ignores codex ready and tool-call payloads', () => { expect(formatMessage(msg({ id: '1', @@ -156,7 +177,7 @@ describe('formatMessage', () => { data: { type: 'ready' } } } - }))).toBeNull() + }), 'Codex')).toBeNull() }) it('does not treat session status events as speakable assistant text', () => { @@ -171,10 +192,10 @@ describe('formatMessage', () => { data: { type: 'message', message: 'Aborting task.' } } } - }))).toBeNull() + }), 'Codex')).toBeNull() }) - it('preserves tool-call context for mixed text+tool_use content array', () => { + it('preserves tool-call context for mixed text+tool_use content array and uses the agent label', () => { const formatted = formatMessage(msg({ id: '1', seq: 1, @@ -185,10 +206,11 @@ describe('formatMessage', () => { { type: 'tool_use', name: 'Bash', input: { command: 'ls' } } ] } - })) + }), 'Claude') expect(formatted).toContain('Here is the result.') - expect(formatted).toContain('Claude Code is using Bash') + expect(formatted).toContain('Claude is using Bash') + expect(formatted).not.toContain('Claude Code is using') }) }) @@ -209,9 +231,11 @@ describe('formatNewMessages', () => { } } }) - ]) + ], 'Codex') expect(update).toContain('New messages in session: session-1') expect(update).toContain('Local database file size is 2.43 GiB.') + expect(update).toContain('Codex:') + expect(update).not.toContain('Claude Code') }) }) diff --git a/web/src/realtime/hooks/contextFormatters.ts b/web/src/realtime/hooks/contextFormatters.ts index 98768429f..54443925f 100644 --- a/web/src/realtime/hooks/contextFormatters.ts +++ b/web/src/realtime/hooks/contextFormatters.ts @@ -66,32 +66,39 @@ function unwrapOutputContent(content: unknown): { roleOverride: NormalizedRole | return { roleOverride, content: messageContent } } -function formatPlainText(role: NormalizedRole | null, text: string): string { +function formatPlainText(role: NormalizedRole | null, text: string, agentLabel: string): string { if (role === 'assistant') { - return `Claude Code: \n${text}` + return `${agentLabel}: \n${text}` } return `User sent message: \n${text}` } /** - * Format a permission request for natural language context + * Format a permission request for natural language context. + * + * `agentLabel` is the display label for the session's agent flavor + * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call. */ export function formatPermissionRequest( sessionId: string, requestId: string, toolName: string, - toolArgs: unknown + toolArgs: unknown, + agentLabel: string ): string { - return `Claude Code is requesting permission to use ${toolName} (session ${sessionId}): + return `${agentLabel} is requesting permission to use ${toolName} (session ${sessionId}): ${requestId} ${toolName} ${JSON.stringify(toolArgs)}` } /** - * Format a single message for voice context + * Format a single message for voice context. + * + * `agentLabel` is the display label for the session's agent flavor + * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call. */ -export function formatMessage(message: DecryptedMessage): string | null { +export function formatMessage(message: DecryptedMessage, agentLabel: string): string | null { const { role, content: wrappedContent } = unwrapRoleWrappedContent(message) const { roleOverride, content } = unwrapOutputContent(wrappedContent) const normalizedRole = roleOverride ?? role @@ -103,7 +110,7 @@ export function formatMessage(message: DecryptedMessage): string | null { const speakable = !isContentArray(content) ? extractSpeakableFromContent(content) : null if (speakable) { const roleForFormat = normalizedRole === 'user' ? 'user' : 'assistant' - return formatPlainText(roleForFormat, speakable) + return formatPlainText(roleForFormat, speakable, agentLabel) } if (!isContentArray(content)) { @@ -122,13 +129,13 @@ export function formatMessage(message: DecryptedMessage): string | null { for (const item of content) { if (item.type === 'text' && item.text) { - lines.push(formatPlainText(isAssistant ? 'assistant' : 'user', item.text)) + lines.push(formatPlainText(isAssistant ? 'assistant' : 'user', item.text, agentLabel)) } else if (item.type === 'tool_use' && !VOICE_CONFIG.DISABLE_TOOL_CALLS) { const name = item.name || 'unknown' if (VOICE_CONFIG.LIMITED_TOOL_CALLS) { - lines.push(`Claude Code is using ${name}`) + lines.push(`${agentLabel} is using ${name}`) } else { - lines.push(`Claude Code is using ${name} with arguments: ${JSON.stringify(item.input)}`) + lines.push(`${agentLabel} is using ${name} with arguments: ${JSON.stringify(item.input)}`) } } } @@ -214,18 +221,18 @@ export function extractLastAssistantSpeakable(messages: DecryptedMessage[]): str return null } -export function formatNewSingleMessage(sessionId: string, message: DecryptedMessage): string | null { - const formatted = formatMessage(message) +export function formatNewSingleMessage(sessionId: string, message: DecryptedMessage, agentLabel: string): string | null { + const formatted = formatMessage(message, agentLabel) if (!formatted) { return null } return 'New message in session: ' + sessionId + '\n\n' + formatted } -export function formatNewMessages(sessionId: string, messages: DecryptedMessage[]): string | null { +export function formatNewMessages(sessionId: string, messages: DecryptedMessage[], agentLabel: string): string | null { const formatted = [...messages] .sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) - .map(formatMessage) + .map((m) => formatMessage(m, agentLabel)) .filter(Boolean) if (formatted.length === 0) { return null @@ -233,15 +240,15 @@ export function formatNewMessages(sessionId: string, messages: DecryptedMessage[ return 'New messages in session: ' + sessionId + '\n\n' + formatted.join('\n\n') } -export function formatHistory(sessionId: string, messages: DecryptedMessage[]): string { +export function formatHistory(sessionId: string, messages: DecryptedMessage[], agentLabel: string): string { const messagesToFormat = VOICE_CONFIG.MAX_HISTORY_MESSAGES > 0 ? messages.slice(-VOICE_CONFIG.MAX_HISTORY_MESSAGES) : messages - const formatted = messagesToFormat.map(formatMessage).filter(Boolean) + const formatted = messagesToFormat.map((m) => formatMessage(m, agentLabel)).filter(Boolean) return 'History of messages in session: ' + sessionId + '\n\n' + formatted.join('\n\n') } -export function formatSessionFull(session: Session | null, messages: DecryptedMessage[]): string { +export function formatSessionFull(session: Session | null, messages: DecryptedMessage[], agentLabel: string): string { if (!session) { return 'Session not available' } @@ -262,7 +269,7 @@ export function formatSessionFull(session: Session | null, messages: DecryptedMe lines.push('## Our interaction history so far') lines.push('') - lines.push(formatHistory(session.id, messages)) + lines.push(formatHistory(session.id, messages, agentLabel)) return lines.join('\n\n') } diff --git a/web/src/realtime/hooks/voiceContextPlan.test.ts b/web/src/realtime/hooks/voiceContextPlan.test.ts index 3caf0bd7a..bd315d2ae 100644 --- a/web/src/realtime/hooks/voiceContextPlan.test.ts +++ b/web/src/realtime/hooks/voiceContextPlan.test.ts @@ -25,17 +25,18 @@ describe('buildSessionVoiceContextPlan', () => { const session = makeSession('sess-1') const messages = Array.from({ length: 10 }, (_, i) => makeMessage(i + 1, `line ${i + 1}`)) - const plan = buildSessionVoiceContextPlan(session, messages) + const plan = buildSessionVoiceContextPlan(session, messages, 'Codex') expect(plan.bootstrap).toContain('sess-1') expect(plan.bootstrap).toContain('Auth refactor') expect(utf8ByteLength(plan.bootstrap)).toBeLessThanOrEqual(ELEVENLABS_WEBRTC_CONTEXT_MAX_BYTES) expect(plan.streamChunks.length).toBeGreaterThan(0) expect(plan.messagesInBootstrap).toBeLessThanOrEqual(2) + expect(plan.bootstrap).not.toContain('Claude Code') }) test('handles missing session', () => { - const plan = buildSessionVoiceContextPlan(null, []) + const plan = buildSessionVoiceContextPlan(null, [], 'Claude') expect(plan.bootstrap).toBe('Session not available') expect(plan.streamChunks).toEqual([]) }) diff --git a/web/src/realtime/hooks/voiceContextPlan.ts b/web/src/realtime/hooks/voiceContextPlan.ts index 5ef0a7d9e..fd160f4b2 100644 --- a/web/src/realtime/hooks/voiceContextPlan.ts +++ b/web/src/realtime/hooks/voiceContextPlan.ts @@ -66,10 +66,14 @@ function chunkTextByBytes(parts: string[], maxBytes: number): string[] { /** * Small handshake context for startSession; remainder is streamed after connect. + * + * `agentLabel` is the display label for the session's agent flavor + * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call. */ export function buildSessionVoiceContextPlan( session: Session | null, - messages: DecryptedMessage[] + messages: DecryptedMessage[], + agentLabel: string ): SessionVoiceContextPlan { if (!session) { return { @@ -89,7 +93,7 @@ export function buildSessionVoiceContextPlan( : all const formatted = capped - .map((m) => formatMessage(m)) + .map((m) => formatMessage(m, agentLabel)) .filter((line): line is string => Boolean(line)) const recentCount = Math.min(BOOTSTRAP_RECENT_MESSAGES, formatted.length) diff --git a/web/src/realtime/hooks/voiceHooks.ts b/web/src/realtime/hooks/voiceHooks.ts index 4c502559c..203871f33 100644 --- a/web/src/realtime/hooks/voiceHooks.ts +++ b/web/src/realtime/hooks/voiceHooks.ts @@ -11,7 +11,8 @@ import { } from './contextFormatters' import { VOICE_CONFIG } from '../voiceConfig' import { buildSessionVoiceContextPlan, type SessionVoiceContextPlan } from './voiceContextPlan' -import type { DecryptedMessage, Session } from '@/types/api' +import { getFlavorLabel, isKnownFlavor } from '@hapi/protocol' +import type { DecryptedMessage, Session, SessionMetadataSummary } from '@/types/api' interface SessionMetadata { summary?: { text?: string } @@ -19,6 +20,17 @@ interface SessionMetadata { machineId?: string } +/** + * Resolve the display label for the session's agent flavor. Falls back to a + * generic "coding agent" string for unknown or missing flavors so the voice + * context never bottoms out with a literal "undefined" or the old hardcoded + * "Claude Code" (closes #680). + */ +function getAgentLabel(session: Session | null): string { + const flavor = (session?.metadata as SessionMetadataSummary | undefined)?.flavor + return isKnownFlavor(flavor) ? getFlavorLabel(flavor) : 'coding agent' +} + // Track which sessions have been reported const shownSessions = new Set() let lastFocusSession: string | null = null @@ -66,7 +78,7 @@ function reportSession(sessionId: string) { if (!session) return const messages = messagesGetter?.(sessionId) ?? [] - const contextUpdate = formatSessionFull(session, messages) + const contextUpdate = formatSessionFull(session, messages, getAgentLabel(session)) reportContextualUpdate(contextUpdate) } @@ -106,13 +118,14 @@ export const voiceHooks = { }, /** - * Called when Claude requests permission for a tool use + * Called when the active agent requests permission for a tool use */ onPermissionRequested(sessionId: string, requestId: string, toolName: string, toolArgs: unknown) { if (VOICE_CONFIG.DISABLE_PERMISSION_REQUESTS) return + const session = sessionGetter?.(sessionId) ?? null reportSession(sessionId) - reportTextUpdate(formatPermissionRequest(sessionId, requestId, toolName, toolArgs)) + reportTextUpdate(formatPermissionRequest(sessionId, requestId, toolName, toolArgs, getAgentLabel(session))) }, /** @@ -121,8 +134,9 @@ export const voiceHooks = { onMessages(sessionId: string, messages: DecryptedMessage[]) { if (VOICE_CONFIG.DISABLE_MESSAGES) return + const session = sessionGetter?.(sessionId) ?? null reportSession(sessionId) - reportContextualUpdate(formatNewMessages(sessionId, messages)) + reportContextualUpdate(formatNewMessages(sessionId, messages, getAgentLabel(session))) }, /** @@ -136,7 +150,7 @@ export const voiceHooks = { const session = sessionGetter?.(sessionId) ?? null const messages = messagesGetter?.(sessionId) ?? [] - const plan = buildSessionVoiceContextPlan(session, messages) + const plan = buildSessionVoiceContextPlan(session, messages, getAgentLabel(session)) shownSessions.add(sessionId) return plan }, From edc3acc3e2f20ad6ec6dcde7f6ff2b3b237d4ab2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:27:35 +0100 Subject: [PATCH 03/14] fix(cursor): requeue user message on transient agent exit (auth, rate limit) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cursor): requeue user message on transient agent exit (auth, rate limit) cursorLegacyRemoteLauncher.runMainLoop popped a user message off the queue before spawning `agent` and silently discarded it whenever `agent` exited non-zero (auth expiry, rate limit, transient network). The wrapper logged the failure at debug level only, never surfaced it to the web UI, and emitted `ready` as if a normal turn had ended. Capture stderr from the spawned process; classify exit-1 with a transient signature (Authentication required, rate limit, ETIMEDOUT, ECONNRESET, EAI_AGAIN) as recoverable; re-head the message via `queue.unshift`, surface a friendly banner via `sendSessionEvent({type:'message',...})`, and backoff ~2s before the loop picks it up again. Cap at 5 consecutive transient failures, after which the message is dropped with a clear "resolve and resend" event so we never spin forever on a genuinely broken auth. Non-transient non-zero exits also surface the stderr to the UI now (instead of only the local ring buffer), so a real crash is visible to the operator. Backoff is overridable via CURSOR_LEGACY_TRANSIENT_BACKOFF_MS for tests. Tests cover: success path, transient auth requeue + banner, rate-limit banner, non-transient crash surfaced without requeue, and the 5-failure drop cap. Co-authored-by: Cursor * fix(cursor): preserve slash-command isolation on requeue; wait for stderr flush Two findings from the cold-review bot on the requeue path: 1. `enqueueCursorUserMessage` uses `pushIsolated` for pass-through slash commands (e.g. `/compress`) so they never batch with sibling prompts. The transient-requeue path used plain `unshift`, which dropped the isolate bit and allowed the next collected batch to merge the slash command with a sibling - changing command semantics. Add `MessageQueue2.unshiftIsolated` and use it when the popped batch was isolated or when `parseCursorSpecialCommand` recognises the message. 2. `runAgentProcess` resolved on `child.on('exit', ...)`. Node may emit `exit` while the stderr pipe is still draining, so a fast "auth required" error printed-and-exited could be classified as non-transient with empty stderr and silently drop the user message - the exact bug this PR was supposed to fix. Resolve on `close` instead, which waits for stdio streams to flush. Adds a unit test that requeues `/compress` after a transient auth failure and asserts the second spawn still receives the slash command alone (not batched with a sibling). Co-authored-by: Cursor * fix(cursor): restrict transient retry to exit code 1 only Upstream codex review #823 (Minor): the helper treated any non-zero exit with matching stderr as transient, which could requeue a signal-killed (SIGTERM 143, SIGKILL 137) or crashed (SIGABRT 134) process whose stderr happens to contain a keyword like "rate limit". Documented contract is exit-1-for-transient; tighten the classifier accordingly. Adds regression test covering exit 143 + rate-limit stderr → no retry. Co-authored-by: Cursor * fix(cursor): clean up transientBackoff abort listener on timer completion Upstream codex review #823 (Minor): transientBackoff added an abort listener with { once: true } but only removed it when the abort fired. Because the launcher reuses one AbortController, repeated transient retries accumulated stale listeners until the next abort. Switch to a single completion path that clears the timer AND removes the abort listener whichever side wins. Co-authored-by: Cursor * fix(cursor): cap in-memory stderr capture at 8 KB Upstream codex review #823 (Minor): runAgentProcess accumulated every stderr chunk for the full child lifetime. A noisy `agent` failure could grow CLI process memory without bound even though only the first 400 chars are ever displayed. Cap the retained copy at 8 KB; debug log of the full stream is unchanged. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../cursor/cursorLegacyRemoteLauncher.test.ts | 295 +++++++++++++++--- cli/src/cursor/cursorLegacyRemoteLauncher.ts | 158 +++++++++- cli/src/utils/MessageQueue2.ts | 36 +++ 3 files changed, 440 insertions(+), 49 deletions(-) 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/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. From cd99cfbc2510e03989369e22fa67b829b950a7a5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:28:00 +0100 Subject: [PATCH 04/14] fix(hub): preserve session metadata across archive transitions (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hub): preserve flavor session ids in metadata across archive transitions When a session ends (terminate, crash, local-launch failure, handoff), the runner's archive write replaces sessions.metadata wholesale. If the CLI's locally cached Metadata is null (e.g. Zod parse failed at bootstrap and api.ts nulled it out) or stale, the spread in archiveAndClose ships a sparse blob and the resume token (cursorSessionId, codexSessionId, claudeSessionId, etc.) gets cleared from the row even though the on-disk chat data is still intact. Fix at the hub layer because update-metadata is the single chokepoint for every metadata write surface (CLI, web, future): in the store-level updateSessionMetadata, read the prior row's metadata inside a transaction and carry forward a small allowlist of flavor resume tokens when the incoming write omits them. Explicit overwrites still win. The allowlist mirrors pickExistingSessionMetadata in sessionFactory.ts which already preserves the same fields on bootstrap. Closes tiann/hapi#820 Co-authored-by: Cursor * fix(hub): address cold-review findings on metadata merge Three bot findings on the initial patch: 1. (P1) Sparse archive payloads still resulted in metadata blobs that failed MetadataSchema parse downstream — required `path`/`host` were not in the carry-forward set, so even though the resume token survived, hub session cache and CLI getSession nulled-out the row and resume_unavailable came back. Add PARSE_IDENTITY_FIELDS = `path`, `host` to the carry-forward. 2. (P2) Preserving `cursorSessionProtocol` whenever it was omitted carried a stale protocol over to a freshly written `cursorSessionId`, misrouting a future remote resume. Pair-aware logic: drop the prior protocol when next sets a new id; preserve the protocol only when next is silent on both id and protocol. 3. (P2) The successful update-metadata broadcast emitted the pre-merge payload to other CLIs in the session room, so even though the DB row was preserved, peer caches diverged. Switch the broadcast value to `result.value` (the persisted merged value) so live caches stay in sync with the truth. Refactor preserveProtocolResumeFields into mergeSessionMetadata with two tiers (PARSE_IDENTITY_FIELDS + SIMPLE_RESUME_TOKENS) plus the cursor pair handler. 6 new tests cover the regressions; existing 16 still pass plus 1 new socket-level test for the broadcast. Co-authored-by: Cursor * fix(hub): preserve flavor + machineId across sparse metadata merges Bot P2 on the prior fix: PARSE_IDENTITY_FIELDS (path, host) made the blob parseable and SIMPLE_RESUME_TOKENS preserved the chat-id, but flavor and machineId were still being dropped by sparse archive payloads. Consequences: - flavor: hub/src/web/routes/sessions.ts and sync/syncEngine.ts read `metadata?.flavor ?? 'claude'` to pick which session id field to resume. With flavor missing, a Cursor/Codex/Gemini session was routed as Claude and the preserved cursorSessionId was ignored. - machineId: telegram/bot.ts and the CLI's resumable listing read `metadata?.machineId` to scope sessions to the current host. With machineId missing, the row dropped out of the resume picker. Add a third carry-forward tier ROUTING_FIELDS = [flavor, machineId] between PARSE_IDENTITY_FIELDS and SIMPLE_RESUME_TOKENS in mergeSessionMetadata. 3 new tests cover preservation, no-invention, and explicit override. Co-authored-by: Cursor * fix(hub,cli): support explicit-clear sentinel for carry-forward fields Upstream cold-review (Major): the carry-forward semantics introduced in the prior commits ("omit field → preserve from prior") collide with cli/src/codex/session.ts resetCodexThread(), which intentionally clears codexSessionId by deleting it from the metadata blob before calling updateMetadata. With omit-as-preserve, the cleared id was restored from the prior row and /clear on a Codex session no longer dropped the persisted thread. Add an explicit-clear sentinel: when next sets a carry-forward field to `null`, the merge drops the key entirely from the persisted blob (key removed; not stored as null since MetadataSchema fields are `string().optional()`). `undefined` (key missing from next) keeps its "carry forward" meaning. The two semantics now compose cleanly: - next.field = "x" → next wins (caller sets a new value) - next.field = null → drop the field (caller intentionally clears) - next omits field → carry forward prior (caller didn't touch it) Update resetCodexThread() to send `codexSessionId: null` so the reset actually drops the persisted thread under the new merge. 4 new hub tests cover: explicit clear of a single token, clear-one- preserve-others independence, no-op clear on a never-set field, and the success-ack value reflects the cleared blob. cli/src/codex tests (224/224) and hub suite (301/301) green; bun typecheck clean. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/codex/session.ts | 13 +- .../handlers/cli/sessionHandlers.test.ts | 64 +- .../socket/handlers/cli/sessionHandlers.ts | 8 +- hub/src/store/sessions.test.ts | 759 ++++++++++++++++++ hub/src/store/sessions.ts | 183 ++++- 5 files changed, 996 insertions(+), 31 deletions(-) create mode 100644 hub/src/store/sessions.test.ts 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/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/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( From cb72703649b43ac92c5d1e3bdd1f1a4272bf59a7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:29:31 +0100 Subject: [PATCH 05/14] feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows (#826) * feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows Archived sessions retain their full transcript and metadata in the DB, but today there is no path back to them from the web UI; the only way to revive one is shell access plus sqlite metadata patching plus a manual /resume call. This change adds a single one-click affordance: - Hub: new POST /api/sessions/:id/reopen route on the existing sessions router. The route delegates to a new engine method `reopenSession` that: - is idempotent (active session -> 200 with `resumed:false`), - validates Cursor sessions still have a `cursorSessionId` once they have any messages (otherwise we cannot resume the agent thread), - clears `lifecycleState='archived'`, `archivedBy`, `archiveReason` via a versioned metadata update, and stamps `lifecycleStateSince`, - defaults `cursorSessionProtocol='stream-json'` for pre-#799 Cursor sessions (sessions that have a `cursorSessionId` but no protocol set), so routing still reaches the legacy launcher; ACP sessions keep their explicit protocol, - forwards to the same `resumeSession` path the existing /resume route uses, including the `canFreshSpawnNeverStartedSession` fallback. 422 is returned with `{ missing: [...] }` when the agent metadata needed to resume is gone; other engine errors map to 404/409/503/500 with the existing shape (mirrors /resume). - Web: a "Reopen" entry in the SessionActionMenu that appears next to "Delete" on inactive sessions only. Wired into both the SessionList rows and the SessionHeader more-menu, with a small dismissable error dialog for the 422 missing-metadata case. - Tests: route-level coverage for the four response shapes (200 reopen, 200 idempotent, 404, 422) plus 409/503 error mappings; sessionCache tests for the archive-metadata clear (including the legacy Cursor protocol default); React component test for the menu item rendering on inactive vs active sessions; mutation hook test for the api wiring and the ApiError surface needed by the UI. Closes #819 Co-authored-by: Cursor * fix(reopen): address codex review findings on fork PR #33 Four P2 findings from the cold-review bot, three fixed and one explained: 1. Mutation now returns the reopen response so the UI can route to a possibly different sessionId. SyncEngine.resumeSession may merge the row into a freshly-spawned session id (matching the send-message resume flow); the chat view now navigates there, the row list calls onSelect on the new id. 2. reopenSession on the client now goes through `request()` instead of a hand-rolled fetch, so 401 + onUnauthorized refresh works the same as every other session action. `request()` now throws `ApiError` (with status/code/body) on non-401 errors - backward compatible because ApiError extends Error. 3. (Reply only) Pre-#799 Cursor protocol propagates correctly without the extra plumbing the bot suggested: `clearSessionArchiveMetadata` writes `cursorSessionProtocol='stream-json'` to the DB; the CLI's `bootstrapExistingSession` preserves it via `pickExistingSessionMetadata`; if it's still absent at the launcher, `isLegacyCursorSession` defaults to stream-json whenever `cursorSessionId` is present. 4. Archive metadata is now restored when resume fails. `reopenSession` captures a snapshot of `lifecycleState`/`archivedBy`/`archiveReason`/ `lifecycleStateSince` before the clear; if `resumeSession` returns an error (no machine online, spawn timeout, etc.), the snapshot is put back via the new `SessionCache.restoreSessionArchiveMetadata`. Engine test covers both the rollback and the no-rollback-on-success cases. Error rendering helper moved to `web/src/lib/reopenError.ts` so the chat header and the session row share one implementation, and gained a unit test. Co-authored-by: Cursor * fix(web): preserve engine error codes in ApiError.code on /reopen `/sessions/:id/reopen` returns `{ error, code }` where `code` is the stable taxonomy (`no_machine_online`, `resume_unavailable`, etc.) and `error` is the human-readable message. The generic `request()` error path was reading only `parsed.error`, so `ApiError.code` ended up being a message like "No machine online" rather than `no_machine_online`, breaking taxonomy-based branching in web callers. `parseErrorCode` now prefers `parsed.code` and falls back to `parsed.error` for legacy routes that only set `error`. Added api/client.test.ts covering the three response shapes /reopen actually emits (503 with code, 500 without code, 422 with missing[]). Addresses upstream codex-action review on tiann/hapi#826. Co-authored-by: Cursor * fix(reopen): restore archive metadata exactly on rollback (drop fresh lifecycleStateSince) For an archived session that predates `lifecycleStateSince` (the field is absent from its metadata), `clearSessionArchiveMetadata` stamps a fresh timestamp. If `resumeSession` then fails, the rollback was leaving that fresh timestamp in place, making the rolled-back row look like it was just archived rather than preserving the original lifecycle age. `restoreSessionArchiveMetadata` now does an EXACT restore: when a snapshot field is undefined the corresponding key on the metadata is deleted, not left alone. Applies symmetrically to lifecycleState / archivedBy / archiveReason / lifecycleStateSince. Test updated to assert the deletion of the fresh timestamp. Addresses upstream codex-action review on tiann/hapi#826. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- hub/src/sync/sessionCache.ts | 128 ++++++++ hub/src/sync/sessionModel.test.ts | 309 ++++++++++++++++++ hub/src/sync/syncEngine.ts | 105 ++++++ hub/src/web/routes/sessions.test.ts | 144 +++++++- hub/src/web/routes/sessions.ts | 36 ++ shared/src/apiTypes.ts | 16 + web/src/api/client.test.ts | 82 +++++ web/src/api/client.ts | 21 +- web/src/components/SessionActionMenu.test.tsx | 74 +++++ web/src/components/SessionActionMenu.tsx | 58 +++- web/src/components/SessionChat.tsx | 7 + web/src/components/SessionHeader.tsx | 33 +- web/src/components/SessionList.tsx | 32 +- .../mutations/useSessionActions.test.tsx | 98 ++++++ web/src/hooks/mutations/useSessionActions.ts | 14 + web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/reopenError.test.ts | 53 +++ web/src/lib/reopenError.ts | 35 ++ 19 files changed, 1237 insertions(+), 15 deletions(-) create mode 100644 web/src/api/client.test.ts create mode 100644 web/src/components/SessionActionMenu.test.tsx create mode 100644 web/src/hooks/mutations/useSessionActions.test.tsx create mode 100644 web/src/lib/reopenError.test.ts create mode 100644 web/src/lib/reopenError.ts 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..a61578211 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -60,6 +60,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' } @@ -744,6 +749,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/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..b1b5c003e 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -171,6 +171,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) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 2762a3732..e9ea6a1ff 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 }) 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..2a7bc9120 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,6 +33,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + ReopenSessionResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor } from '@hapi/protocol' @@ -46,12 +47,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 +135,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 +418,13 @@ export class ApiClient { }) } + async reopenSession(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/reopen`, + { method: 'POST', body: JSON.stringify({}) } + ) + } + async switchSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { method: 'POST', 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.tsx b/web/src/components/SessionChat.tsx index 12c86dbf7..254dfcfe1 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -709,6 +709,13 @@ 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 && ( 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.tsx b/web/src/components/SessionList.tsx index bc39cc4d7..2cf627ec0 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 @@ -567,11 +568,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 +677,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)} diff --git a/web/src/hooks/mutations/useSessionActions.test.tsx b/web/src/hooks/mutations/useSessionActions.test.tsx new file mode 100644 index 000000000..830eb00ab --- /dev/null +++ b/web/src/hooks/mutations/useSessionActions.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useSessionActions } from './useSessionActions' +import { ApiError, type ApiClient } from '@/api/client' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function createMockApi(reopenSession: (sessionId: string) => Promise<{ ok: true; sessionId: string; resumed: boolean }>): ApiClient { + return { reopenSession } as unknown as ApiClient +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useSessionActions - reopenSession', () => { + it('invokes api.reopenSession with the session id and forwards the response', async () => { + const reopen = vi.fn(async (_sessionId: string) => ({ + ok: true as const, + sessionId: 'session-A-spawned', + resumed: true + })) + const api = createMockApi(reopen) + + const { result } = renderHook( + () => useSessionActions(api, 'session-A', 'cursor'), + { wrapper: createWrapper() }, + ) + + let response: { ok: true; sessionId: string; resumed: boolean } | undefined + await act(async () => { + response = await result.current.reopenSession() + }) + + expect(reopen).toHaveBeenCalledWith('session-A') + // The mutation must propagate the response so the UI can navigate to the + // possibly-new spawn id when resumeSession merges the row. + expect(response).toEqual({ ok: true, sessionId: 'session-A-spawned', resumed: true }) + }) + + it('throws when api or sessionId is missing', async () => { + const { result } = renderHook( + () => useSessionActions(null, null, null), + { wrapper: createWrapper() }, + ) + + await expect(result.current.reopenSession()).rejects.toThrow('Session unavailable') + }) + + it('surfaces an ApiError so the UI can render the 422 missing-metadata payload', async () => { + const reopen = vi.fn(async () => { + throw new ApiError( + 'HTTP 422 Unprocessable Entity: {"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}', + 422, + 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + '{"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}' + ) + }) + const api = createMockApi(reopen as unknown as ApiClient['reopenSession']) + + const { result } = renderHook( + () => useSessionActions(api, 'session-X', 'cursor'), + { wrapper: createWrapper() }, + ) + + let captured: unknown + await act(async () => { + try { + await result.current.reopenSession() + } catch (error) { + captured = error + } + }) + + expect(captured).toBeInstanceOf(ApiError) + const apiError = captured as ApiError + expect(apiError.status).toBe(422) + expect(apiError.body).toContain('cursorSessionId') + + await waitFor(() => { + // The hook should not get stuck pending after the failure. + expect(result.current.isPending).toBe(false) + }) + }) +}) diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 5642ee7f4..fe96f37b9 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import type { CodexCollaborationMode, PermissionMode } from '@/types/api' +import type { ReopenSessionResponse } from '@hapi/protocol/apiTypes' import { queryKeys } from '@/lib/query-keys' import { clearMessageWindow } from '@/lib/message-window-store' import { isKnownFlavor } from '@hapi/protocol' @@ -14,6 +15,7 @@ export function useSessionActions( ): { abortSession: () => Promise archiveSession: () => Promise + reopenSession: () => Promise switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise @@ -58,6 +60,16 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const reopenMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + return await api.reopenSession(sessionId) + }, + onSuccess: () => void invalidateSession(), + }) + const switchMutation = useMutation({ mutationFn: async () => { if (!api || !sessionId) { @@ -166,6 +178,7 @@ export function useSessionActions( return { abortSession: abortMutation.mutateAsync, archiveSession: archiveMutation.mutateAsync, + reopenSession: reopenMutation.mutateAsync, switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, @@ -176,6 +189,7 @@ export function useSessionActions( deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending || archiveMutation.isPending + || reopenMutation.isPending || switchMutation.isPending || permissionMutation.isPending || collaborationMutation.isPending diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 44c585b97..812b4e008 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -133,6 +133,7 @@ export default { 'session.action.rename': 'Rename', 'session.action.export': 'Export conversation', 'session.action.archive': 'Archive', + 'session.action.reopen': 'Reopen', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', @@ -150,6 +151,8 @@ export default { 'dialog.archive.description': 'Are you sure you want to archive "{name}"? This will disconnect active session.', 'dialog.archive.confirm': 'Archive', 'dialog.archive.confirming': 'Archiving…', + 'dialog.reopen.errorTitle': 'Could not reopen session', + 'dialog.reopen.dismiss': 'Dismiss', 'dialog.delete.title': 'Delete Session', 'dialog.delete.description': 'Are you sure you want to delete "{name}"? This action cannot be undone.', 'dialog.delete.confirm': 'Delete', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2013fcea4..c91029f18 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -133,6 +133,7 @@ export default { 'session.action.rename': '重命名', 'session.action.export': '导出对话', 'session.action.archive': '归档', + 'session.action.reopen': '重新打开', 'session.action.delete': '删除', 'session.action.copy': '复制', @@ -152,6 +153,9 @@ export default { 'dialog.archive.confirm': '归档', 'dialog.archive.confirming': '归档中…', + 'dialog.reopen.errorTitle': '无法重新打开会话', + 'dialog.reopen.dismiss': '关闭', + 'dialog.delete.title': '删除会话', 'dialog.delete.description': '确定要删除 "{name}" 吗?此操作无法撤销。', 'dialog.delete.confirm': '删除', diff --git a/web/src/lib/reopenError.test.ts b/web/src/lib/reopenError.test.ts new file mode 100644 index 000000000..55d7ebdf9 --- /dev/null +++ b/web/src/lib/reopenError.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from '@/api/client' +import { formatReopenError } from './reopenError' + +describe('formatReopenError', () => { + it('renders error + missing fields from a 422 ApiError body', () => { + const error = new ApiError( + 'HTTP 422 Unprocessable Entity: {"error":"Cursor session id is missing","missing":["cursorSessionId"]}', + 422, + 'Cursor session id is missing', + '{"error":"Cursor session id is missing","missing":["cursorSessionId"]}' + ) + + expect(formatReopenError(error)).toBe( + 'Cursor session id is missing (missing: cursorSessionId)' + ) + }) + + it('renders error only when missing is empty', () => { + const error = new ApiError( + 'HTTP 503 Service Unavailable: {"error":"No machine online","code":"no_machine_online"}', + 503, + 'no_machine_online', + '{"error":"No machine online","code":"no_machine_online"}' + ) + + expect(formatReopenError(error)).toBe('No machine online') + }) + + it('falls back to Error.message when there is no JSON body to parse', () => { + expect(formatReopenError(new Error('boom'))).toBe('boom') + }) + + it('falls back to a generic message when the value is not an Error', () => { + expect(formatReopenError('plain string')).toBe('Failed to reopen session') + }) + + it('parses JSON embedded in plain Error messages when no ApiError body is set', () => { + // Older callers wrap the body in the Error message string. + const error = new Error( + 'HTTP 422: {"error":"Cursor session id is missing","missing":["cursorSessionId","cursorSessionProtocol"]}' + ) + + expect(formatReopenError(error)).toBe( + 'Cursor session id is missing (missing: cursorSessionId, cursorSessionProtocol)' + ) + }) + + it('falls back to the raw message when JSON cannot be parsed', () => { + const error = new Error('HTTP 500: not actually json {bad}') + expect(formatReopenError(error)).toBe('HTTP 500: not actually json {bad}') + }) +}) diff --git a/web/src/lib/reopenError.ts b/web/src/lib/reopenError.ts new file mode 100644 index 000000000..7259f6344 --- /dev/null +++ b/web/src/lib/reopenError.ts @@ -0,0 +1,35 @@ +import { ApiError } from '@/api/client' + +/** + * Extract a human-readable message from a Reopen failure. + * + * The hub returns `{ error, missing: [...] }` on 422 when required metadata + * is gone (e.g. Cursor session lacks `cursorSessionId`). For other errors the + * body is `{ error, code? }`. Both shapes are surfaced verbatim; we fall back + * to the raw `Error.message` when the body is unparseable or absent. + */ +export function formatReopenError(error: unknown): string { + const fallback = error instanceof Error ? error.message : 'Failed to reopen session' + + const body = error instanceof ApiError ? error.body : undefined + const source = body ?? extractJsonFromMessage(fallback) + if (!source) return fallback + + try { + const parsed = JSON.parse(source) as { error?: string; missing?: string[] } + if (parsed.error && Array.isArray(parsed.missing) && parsed.missing.length > 0) { + return `${parsed.error} (missing: ${parsed.missing.join(', ')})` + } + if (parsed.error) { + return parsed.error + } + } catch { + // body was not JSON; fall through + } + return fallback +} + +function extractJsonFromMessage(message: string): string | undefined { + const start = message.indexOf('{') + return start === -1 ? undefined : message.slice(start) +} From 6d2d0d47078b3ce0322d264fe63257f2f23c3d70 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:30:04 +0100 Subject: [PATCH 06/14] fix(cursor): trim #784 safety patch to marker-only on legacy stream-json path (closes #822) (#828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cursor): drop timing heuristic from #784 intercept; scan raw payload (#801 follow-up) PR #801 shipped a two-strategy intercept for the synthetic AskQuestion skip response in legacy stream-json mode. Real-traffic data from a post-merge run shows the marker-match strategy never fires (the converter's `extractToolResult` discards the marker for tool shapes it does not recognize, returning `{}`) and the timing-signature defense-in-depth strategy fires only on false positives - notably the Anthropic Vertex Claude tool calls cursor-agent surfaces in legacy sessions, which all land as `name=unknown` with the `{}` extracted result and frequently complete under the 500 ms threshold. Measured on a single legacy-resumed session (`7b769423`): 1,136 `name=unknown` tool calls, 16 rewritten as `no_input_surface`, zero actual marker strings stored anywhere in the session. The 16 rewrites were legitimate fast tool calls (Anthropic Vertex `toolu_vrtx_*` IDs) mischaracterized as fabricated skip responses. Changes: - Remove the timing-signature heuristic and its supporting state (started-at map, elapsed-ms calculation, latency threshold, test-only state reset). - Move the marker scan from the post-`extractToolResult` output to the raw `tool_call` payload, so it can see the marker on stream-json shapes the converter does not specifically recognize. Function-shaped tools exclude `function.arguments` from the scan to avoid matching agent-controlled input. Other shapes scan the full payload (no agent-input field exists at the top level). - Refresh tests: drop timing-based positive cases, add a marker-in-raw- payload positive case for `name=unknown` shapes, and add a regression that legitimate fast `name=unknown` tool calls without the marker pass through with `status: completed`. - Document scope: this intercept now lives only on the legacy stream- json path, which only resumed pre-ACP sessions hit. New cursor remote sessions go through `cursorAcpBackend` and the `cursor/ask_question` ACP extension method (#799) - immune to this bug. The intercept drains with the legacy session population. Tracking: #784. Builds on #801, complements #799. * fix(cursor): exclude agent input from marker scan; surface top-level Anthropic tool names (Codex P2) Codex flagged a false-positive case on the fork-stage review of this branch (heavygee/hapi#35, P2): an Anthropic tool_use shape with a top-level `name` (e.g. `{id, name: 'TodoWrite', input: { ... }}`) gets labelled `name=unknown` by the converter and passes the AskQuestion gate. If the agent's `input` quotes the synthetic-skip marker - which happens whenever an agent edits or documents this very bug - the intercept would rewrite a perfectly fine TodoWrite as a fabricated skip. Two-part fix: 1. `extractToolName` now reads the top-level `name` field as a final fallback. A real `TodoWrite` / `Bash` / `str_replace_based_edit_tool` surfaces with its actual name and is rejected by the AskQuestion gate before the marker scan runs. The original AskQuestion fabrication case still surfaces as `unknown` (per #784 issue body the name is stripped in the fabricated payload) and remains detectable. 2. Defense in depth: introduce `AGENT_INPUT_KEYS = {input, args, arguments}` and exclude these from the non-function shape's marker scan. Even if a tool reaches this code path with `name=unknown` and the marker buried in its `input`, the intercept won't fire on agent- controlled text. Two new regression tests: - Anthropic tool_use shape `{id, name: 'TodoWrite', input: {todos: [ marker]}}` → passes through with `status: 'completed'`. - `name=unknown` shape with marker only inside `input` → passes through with `status: 'completed'`. All 20/20 tests pass; typecheck clean (cli + web + hub). --- .../utils/cursorLegacyEventConverter.test.ts | 229 ++++++++++-------- .../utils/cursorLegacyEventConverter.ts | 212 ++++++++-------- docs/guide/cursor.md | 10 +- 3 files changed, 234 insertions(+), 217 deletions(-) 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/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). From ad038bbf2e0000f8128aab77ac431ebd25aa4231 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 8 Jun 2026 13:30:43 +0800 Subject: [PATCH 07/14] fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835) Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and replace single-pid ACP lock with cross-process refcount (#832). Web picker merges machine/session catalogs and waits for SKU readiness before showing variant labels. Fixes #831 Fixes #832 Co-authored-by: Cursor --- .../agent/backends/acp/agentCliGuard.test.ts | 73 +++++++- cli/src/agent/backends/acp/agentCliGuard.ts | 140 ++++++++++++++- cli/src/cursor/cursorAcpRemoteLauncher.ts | 6 +- cli/src/modules/common/cursorModels.test.ts | 144 +++++++++++++++ cli/src/modules/common/cursorModels.ts | 103 ++++++++--- .../common/cursorModelsPrewarm.test.ts | 39 ++++ cli/src/modules/common/cursorModelsPrewarm.ts | 9 + .../common/cursorModelsSharedCache.test.ts | 15 ++ cli/src/runner/run.ts | 2 + web/src/components/SessionChat.tsx | 83 ++++++--- web/src/lib/cursorCliSkusMerge.test.ts | 32 ++++ web/src/lib/cursorPickerState.ts | 19 ++ web/src/lib/sessionChatCursorCatalog.test.ts | 44 +++++ web/src/lib/sessionChatCursorModel.test.ts | 166 ++++++++++++++++++ web/src/lib/sessionChatCursorModel.ts | 79 +++++++++ 15 files changed, 890 insertions(+), 64 deletions(-) create mode 100644 cli/src/modules/common/cursorModelsPrewarm.test.ts create mode 100644 cli/src/modules/common/cursorModelsPrewarm.ts create mode 100644 web/src/lib/cursorCliSkusMerge.test.ts 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/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 3c1028769..ba6aef502 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 { @@ -442,8 +443,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/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/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 254dfcfe1..9694c5cbe 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -36,11 +36,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 @@ -225,39 +228,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) { @@ -816,7 +853,7 @@ export function SessionChat(props: { ? codexModelOptions : agentFlavor === 'cursor' ? ( - cursorModelsState.isLoading + cursorCatalogPending || !cursorPicker || cursorPicker.modelOptions.length === 0 ? undefined @@ -847,10 +884,13 @@ export function SessionChat(props: { : undefined } selectedModelVariant={ - agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined + agentFlavor === 'cursor' && !cursorCatalogPending + ? cursorVariantSelectValue + : undefined } modelEffortOptions={ agentFlavor === 'cursor' + && !cursorCatalogPending && cursorPicker?.mode === 'dual' && cursorModelEffortOptions && cursorModelEffortOptions.length > 1 @@ -863,7 +903,7 @@ export function SessionChat(props: { : agentFlavor === 'cursor' ? (props.session.active && !controlledByUser - && !cursorModelsState.isLoading + && !cursorCatalogPending && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 @@ -875,6 +915,7 @@ export function SessionChat(props: { agentFlavor === 'cursor' && props.session.active && !controlledByUser + && !cursorCatalogPending && !cursorModelsState.error ? handleCursorEffortChange : undefined diff --git a/web/src/lib/cursorCliSkusMerge.test.ts b/web/src/lib/cursorCliSkusMerge.test.ts new file mode 100644 index 000000000..f31ad216f --- /dev/null +++ b/web/src/lib/cursorCliSkusMerge.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState' + +describe('mergeCursorCliModelSkus', () => { + it('prefers the richer source and unions ids without duplicates', () => { + const partial = [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' } + ] + const full = [ + ...partial, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' } + ] + + expect(mergeCursorCliModelSkus(partial, full).map((row) => row.modelId)).toEqual([ + 'gpt-5.5-high-fast', + 'gpt-5.5-low', + 'gpt-5.5-medium', + 'gpt-5.5-high' + ]) + }) + + it('prefers the richer source name for duplicate model ids', () => { + const machine = [{ modelId: 'gpt-5.5-medium', name: 'From Machine' }] + const session = [{ modelId: 'gpt-5.5-medium', name: 'From Session' }] + + expect(mergeCursorCliModelSkus(machine, session)).toEqual([ + { modelId: 'gpt-5.5-medium', name: 'From Machine' } + ]) + }) +}) diff --git a/web/src/lib/cursorPickerState.ts b/web/src/lib/cursorPickerState.ts index 4416fee1a..aa8b0b3e6 100644 --- a/web/src/lib/cursorPickerState.ts +++ b/web/src/lib/cursorPickerState.ts @@ -13,6 +13,25 @@ import { type CursorModelOption } from '@/lib/cursorModelOptions' +export function mergeCursorCliModelSkus( + ...sources: readonly (readonly CursorModelSummary[])[] +): CursorModelSummary[] { + const sorted = [...sources].sort((a, b) => b.length - a.length); + const merged = new Map(); + for (const source of sorted) { + for (const entry of source) { + const modelId = entry.modelId.trim(); + if (!modelId) { + continue; + } + if (!merged.has(modelId)) { + merged.set(modelId, entry); + } + } + } + return [...merged.values()]; +} + export type CursorPickerMode = 'dual' | 'flat' export type CursorPickerOption = { value: string; label: string } diff --git a/web/src/lib/sessionChatCursorCatalog.test.ts b/web/src/lib/sessionChatCursorCatalog.test.ts index 5298ebdbd..9262aeedc 100644 --- a/web/src/lib/sessionChatCursorCatalog.test.ts +++ b/web/src/lib/sessionChatCursorCatalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions' +import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState' import { buildSessionCursorPickerState, resolveSessionCursorVariantSelectValue @@ -29,6 +30,49 @@ describe('in-session cursor catalog with CLI skus', () => { expect(catalog.variantsByBase.get('gpt-5.5')?.some((row) => row.wireId === 'gpt-5.5-high-fast')).toBe(true) }) + it('merges machine SKU catalog over partial session SKUs for friendly variant labels', () => { + const machineWires = [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' } + ] + const sessionSkus = [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' } + ] + const machineSkus = [ + ...sessionSkus, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' } + ] + const mergedSkus = mergeCursorCliModelSkus(machineSkus, sessionSkus) + const picker = buildSessionCursorPickerState({ + sessionModels: sessionWires, + machineModels: machineWires, + cliModelSkus: mergedSkus, + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + + expect(picker.mode).toBe('dual') + expect(picker.effortOptions.length).toBeGreaterThan(2) + expect(picker.effortOptions.every((row) => !row.label.includes('context=272k'))).toBe(true) + }) + + it('shows raw variant suffixes when dual catalog has wires but no CLI skus', () => { + const picker = buildSessionCursorPickerState({ + sessionModels: [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' } + ], + machineModels: [], + cliModelSkus: [], + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + + expect(picker.effortOptions.some((row) => row.label.includes('reasoning='))).toBe(true) + }) + it('highlights the matching CLI sku when session stores the ACP wire id', () => { const picker = buildSessionCursorPickerState({ sessionModels: sessionWires, diff --git a/web/src/lib/sessionChatCursorModel.test.ts b/web/src/lib/sessionChatCursorModel.test.ts index 98b32f5f9..8f3118cd4 100644 --- a/web/src/lib/sessionChatCursorModel.test.ts +++ b/web/src/lib/sessionChatCursorModel.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest' import { buildSessionCursorPickerState, isCursorEffortWireInCatalog, + isSessionCursorCatalogAwaitingSkus, + isSessionCursorCatalogLoading, + isSessionCursorCatalogPending, + isSessionCursorCatalogPendingWithTimeout, resolveSessionCursorBaseSelectValue, resolveSessionCursorModelChange } from '@/lib/sessionChatCursorModel' @@ -131,6 +135,168 @@ describe('CLI sku variants in session picker', () => { }) }) +describe('session cursor catalog readiness', () => { + const dualPicker = buildSessionCursorPickerState({ + sessionModels: [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' } + ], + machineModels: [], + cliModelSkus: [], + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + + it('waits for session and machine loading', () => { + expect(isSessionCursorCatalogLoading({ + sessionLoading: true, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null + })).toBe(true) + expect(isSessionCursorCatalogLoading({ + sessionLoading: false, + machineLoading: true, + hasMachineId: true, + sessionError: null, + machineError: null + })).toBe(true) + expect(isSessionCursorCatalogLoading({ + sessionLoading: false, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null + })).toBe(false) + }) + + it('does not wait for machine loading after machine error or without machine id', () => { + expect(isSessionCursorCatalogLoading({ + sessionLoading: false, + machineLoading: true, + hasMachineId: true, + sessionError: null, + machineError: 'boom' + })).toBe(false) + expect(isSessionCursorCatalogLoading({ + sessionLoading: false, + machineLoading: true, + hasMachineId: false, + sessionError: null, + machineError: null + })).toBe(false) + }) + + it('awaits SKUs for dual picker when merged catalog is still empty', () => { + expect(isSessionCursorCatalogAwaitingSkus({ + sessionLoading: false, + machineLoading: false, + sessionError: null, + machineError: null, + mergedSkus: [], + picker: dualPicker + })).toBe(true) + expect(isSessionCursorCatalogAwaitingSkus({ + sessionLoading: false, + machineLoading: false, + sessionError: null, + machineError: null, + mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }], + picker: dualPicker + })).toBe(false) + }) + + it('combines loading and SKU awaiting into pending state', () => { + expect(isSessionCursorCatalogPending({ + sessionLoading: false, + machineLoading: true, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [], + picker: dualPicker + })).toBe(true) + expect(isSessionCursorCatalogPending({ + sessionLoading: false, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [], + picker: dualPicker + })).toBe(true) + expect(isSessionCursorCatalogPending({ + sessionLoading: false, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }], + picker: dualPicker + })).toBe(false) + }) + + it('does not await SKUs for flat picker sessions', () => { + const flatPicker = buildSessionCursorPickerState({ + sessionModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + machineModels: [], + cliModelSkus: [], + sessionModel: 'composer-2.5[fast=true]', + sessionCurrentModelId: 'composer-2.5[fast=true]' + }) + + expect(isSessionCursorCatalogPending({ + sessionLoading: false, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [], + picker: flatPicker + })).toBe(false) + }) + + it('stays pending for loading even when SKU timeout has elapsed', () => { + expect(isSessionCursorCatalogPendingWithTimeout({ + sessionLoading: true, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [], + picker: dualPicker, + awaitingStartedAtMs: 0, + nowMs: 20_000, + timeoutMs: 15_000 + })).toBe(true) + }) + + it('degrades SKU awaiting after timeout while keeping loading pending', () => { + const startedAt = 1_000 + const args = { + sessionLoading: false, + machineLoading: false, + hasMachineId: true, + sessionError: null, + machineError: null, + mergedSkus: [] as const, + picker: dualPicker, + awaitingStartedAtMs: startedAt, + timeoutMs: 15_000 + } + + expect(isSessionCursorCatalogPendingWithTimeout({ + ...args, + nowMs: startedAt + 5_000 + })).toBe(true) + expect(isSessionCursorCatalogPendingWithTimeout({ + ...args, + nowMs: startedAt + 15_000 + })).toBe(false) + }) +}) + describe('isCursorEffortWireInCatalog', () => { it('checks wireToBase membership', () => { const picker = buildSessionCursorPickerState({ diff --git a/web/src/lib/sessionChatCursorModel.ts b/web/src/lib/sessionChatCursorModel.ts index 25eb0f5c9..cf3a24fda 100644 --- a/web/src/lib/sessionChatCursorModel.ts +++ b/web/src/lib/sessionChatCursorModel.ts @@ -112,6 +112,85 @@ export function resolveSessionCursorVariantSelectValue( return null } +export function isSessionCursorCatalogLoading(args: { + sessionLoading: boolean + machineLoading: boolean + hasMachineId: boolean + sessionError: string | null + machineError: string | null +}): boolean { + if (args.sessionLoading) { + return true + } + if (args.hasMachineId && args.machineLoading && !args.machineError) { + return true + } + return false +} + +export function isSessionCursorCatalogAwaitingSkus(args: { + sessionLoading: boolean + machineLoading: boolean + sessionError: string | null + machineError: string | null + mergedSkus: readonly CursorModelSummary[] + picker: CursorPickerState | null +}): boolean { + if (args.sessionLoading || args.machineLoading) { + return false + } + if (args.sessionError || args.machineError) { + return false + } + if (!args.picker || args.picker.mode !== 'dual') { + return false + } + if (args.mergedSkus.length > 0) { + return false + } + return args.picker.showEffortPicker +} + +export const SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS = 15_000 + +export function isSessionCursorCatalogPending(args: { + sessionLoading: boolean + machineLoading: boolean + hasMachineId: boolean + sessionError: string | null + machineError: string | null + mergedSkus: readonly CursorModelSummary[] + picker: CursorPickerState | null +}): boolean { + return isSessionCursorCatalogLoading(args) + || isSessionCursorCatalogAwaitingSkus(args) +} + +export function isSessionCursorCatalogPendingWithTimeout(args: { + sessionLoading: boolean + machineLoading: boolean + hasMachineId: boolean + sessionError: string | null + machineError: string | null + mergedSkus: readonly CursorModelSummary[] + picker: CursorPickerState | null + awaitingStartedAtMs: number | null + nowMs?: number + timeoutMs?: number +}): boolean { + if (!isSessionCursorCatalogPending(args)) { + return false + } + if (!isSessionCursorCatalogAwaitingSkus(args)) { + return true + } + if (args.awaitingStartedAtMs === null) { + return true + } + const elapsed = (args.nowMs ?? Date.now()) - args.awaitingStartedAtMs + return elapsed < (args.timeoutMs ?? SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS) +} + export function buildSessionCursorPickerState(args: { sessionModels: readonly CursorModelSummary[] machineModels: readonly CursorModelSummary[] From 8094b500f32dc24f374ea1bc7cd7e619794ec0af Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 8 Jun 2026 13:31:09 +0800 Subject: [PATCH 08/14] fix(web): hide sidebar fake sessions for Cursor resume/archive (#836) * fix(web): dedupe sidebar sessions by flavor resume id Wire deduplicateSessionsByAgentId into SessionList and resolve cursor threads via cursorSessionId so resume/archive no longer shows duplicate inactive rows for the same ACP session. Fixes #833 Co-authored-by: Cursor * fix(web): use SessionSummary.agentSessionId for sidebar dedup SessionList only receives SessionSummary from the API; native ids like cursorSessionId are already mapped into metadata.agentSessionId by toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck. Co-authored-by: Cursor * fix(web): hide inactive empty session stubs in sidebar Filter inactive rows with no agentSessionId and no title signal before grouping sessions, and expose lifecycleState on SessionSummary for future sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup. Fixes #833 Co-authored-by: Cursor * fix(web): scope sidebar dedup key by flavor Prevent cross-flavor collisions when flattened agentSessionId retains a stale native id. Add regression test and relax claudeRemote CI timeout. Fixes #833 Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/claude/claudeRemote.test.ts | 2 +- shared/src/sessionSummary.test.ts | 12 +++ shared/src/sessionSummary.ts | 4 +- web/src/components/SessionList.test.ts | 138 ++++++++++++++++++++++++- web/src/components/SessionList.tsx | 50 +++++++-- 5 files changed, 196 insertions(+), 10 deletions(-) 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/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/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 2cf627ec0..a173e7aeb 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -101,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]) } } @@ -134,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() @@ -771,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 @@ -920,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 })}
+ {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/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/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 9694c5cbe..3390ee7b0 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -19,12 +19,13 @@ 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' @@ -67,33 +68,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 ( - @@ -141,7 +244,7 @@ function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean { return false } -export function SessionChat(props: { +type SessionChatProps = { api: ApiClient session: Session messages: DecryptedMessage[] @@ -166,7 +269,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() @@ -180,6 +311,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 @@ -708,15 +912,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) { @@ -807,23 +1028,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}
diff --git a/web/src/hooks/mutations/useSendMessage.test.tsx b/web/src/hooks/mutations/useSendMessage.test.tsx index 20d57440e..9187bce44 100644 --- a/web/src/hooks/mutations/useSendMessage.test.tsx +++ b/web/src/hooks/mutations/useSendMessage.test.tsx @@ -9,6 +9,7 @@ vi.mock('@/lib/message-window-store', () => ({ appendOptimisticMessage: vi.fn(), getMessageWindowState: vi.fn(() => ({ messages: [], pending: [] })), updateMessageStatus: vi.fn(), + removeOptimisticMessage: vi.fn(), })) vi.mock('@/hooks/usePlatform', () => ({ @@ -101,6 +102,337 @@ describe('useSendMessage', () => { expect(onSuccess).not.toHaveBeenCalled() }) + // assistant-ui clears the composer eagerly when send is invoked, so to + // retain the typed text on failure we hand the original input back + // through the `onError` callback. The three branches below cover the + // acceptance criteria: 5xx/network, 4xx, and 2xx. + describe('composer text retention on send failure', () => { + it('5xx/network: onError fires with the original text so the composer can restore it', async () => { + const onError = vi.fn() + const onSuccess = vi.fn() + const api = createMockApi(async () => { + // request() throws plain Error for 5xx with this shape. + throw new Error('HTTP 503 Service Unavailable: hub down') + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError, onSuccess }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('keep this text on 503') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown } + expect(info.text).toBe('keep this text on 503') + expect(info.error).toBeInstanceOf(Error) + expect((info.error as Error).message).toContain('503') + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('network: onError fires with the original text on a fetch-level rejection', async () => { + const onError = vi.fn() + // Simulates a TypeError surfaced by fetch() when the hub socket + // dies mid-request (e.g. daily-rebuild restart blip). + const api = createMockApi(async () => { + throw new TypeError('Failed to fetch') + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('keep this on a dropped fetch') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown } + expect(info.text).toBe('keep this on a dropped fetch') + expect(info.error).toBeInstanceOf(TypeError) + }) + + it('4xx: onError fires with the original text so the inline affordance can render', async () => { + const onError = vi.fn() + const api = createMockApi(async () => { + // request() throws plain Error for 4xx (e.g. 400/403). + throw new Error('HTTP 400 Bad Request: invalid payload') + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('keep this text on 400') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown } + expect(info.text).toBe('keep this text on 400') + expect((info.error as Error).message).toContain('400') + }) + + it('2xx: onError is not called and onSuccess fires (composer clears as today)', async () => { + const onError = vi.fn() + const onSuccess = vi.fn() + const api = createMockApi() + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError, onSuccess }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('clean send') + }) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith('session-A') + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('non-Error throws still surface text; the consumer falls back to its default message', async () => { + const onError = vi.fn() + const api = createMockApi(async () => { + // Defensive case — some providers throw bare strings/objects. + // We must not swallow these or the composer would silently + // eat the user's text again. + throw 'opaque failure' + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('keep this on opaque failure') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown; scheduledAt: number | null } + expect(info.text).toBe('keep this on opaque failure') + expect(info.error).toBe('opaque failure') + expect(info.scheduledAt).toBeNull() + }) + + it('carries scheduledAt through onError so the composer can restore a failed scheduled send as scheduled', async () => { + // Without this, SessionChat clears pendingSchedule on accept and the + // subsequent failure's restore would silently downgrade a scheduled + // send to immediate -- the operator hits send again and the message + // dispatches now instead of at the chosen time. + const onError = vi.fn() + const api = createMockApi(async () => { + throw new Error('HTTP 503 Service Unavailable') + }) + const scheduledAt = Date.now() + 5 * 60 * 1000 + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('see you in 5', undefined, scheduledAt) + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; scheduledAt: number | null } + expect(info.text).toBe('see you in 5') + expect(info.scheduledAt).toBe(scheduledAt) + }) + + it('immediate send: scheduledAt is null in onError', async () => { + const onError = vi.fn() + const api = createMockApi(async () => { + throw new Error('boom') + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('immediate') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { scheduledAt: number | null } + expect(info.scheduledAt).toBeNull() + }) + + it('removes the optimistic row on failure so the composer-restore path is the single retry surface', async () => { + // Without this, the thread keeps a stale `failed` bubble next to + // the restored composer text, and the operator can stack a + // duplicate by retrying from either surface. + const onError = vi.fn() + const api = createMockApi(async () => { + throw new Error('HTTP 503') + }) + + const { removeOptimisticMessage, updateMessageStatus } = await import('@/lib/message-window-store') + const removeMock = vi.mocked(removeOptimisticMessage) + const updateMock = vi.mocked(updateMessageStatus) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + // The optimistic row is removed instead of being kept as failed. + expect(removeMock).toHaveBeenCalledWith('session-A', 'local-id-1') + // Defensive: nothing else should have transitioned the row to + // 'failed' on this path -- we removed it outright. + expect(updateMock.mock.calls.some((call) => call[2] === 'failed')).toBe(false) + }) + + it('carries sessionId through onError so a resumed-session POST that fails restores into the right composer', async () => { + // Inactive-session resume: useSendMessage resolves a target id, + // kicks off async navigation, and then the POST can fail. The + // route component keys sendError state by sessionId so the + // restore lands on the resumed session, not the old one whose + // composer the operator has already navigated away from. + const onError = vi.fn() + const api = createMockApi(async () => { + throw new Error('HTTP 500') + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-original', { + onError, + resolveSessionId: async () => 'session-resolved', + onSessionResolved: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hi from resumed') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { sessionId: string; text: string } + expect(info.sessionId).toBe('session-resolved') + expect(info.text).toBe('hi from resumed') + }) + + it('attachment send: keeps the failed row in the thread and skips composer-restore', async () => { + // The composer-restore path can't reinstate uploaded attachment + // metadata, so for sends with attachments we fall back to the + // legacy failed-bubble UX (operator retries via the in-thread + // retry button, which re-fires the send WITH attachments). + const onError = vi.fn() + const api = createMockApi(async () => { + throw new Error('HTTP 503') + }) + + const { removeOptimisticMessage, updateMessageStatus } = await import('@/lib/message-window-store') + const removeMock = vi.mocked(removeOptimisticMessage) + const updateMock = vi.mocked(updateMessageStatus) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('see this image', [ + { id: 'att-1', filename: 'x.png', mimeType: 'image/png', size: 1, path: '/x.png' } + ]) + }) + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith('session-A', 'local-id-1', 'failed') + }) + // No composer-restore: onError is NOT fired and the optimistic + // row is NOT removed -- both would destroy the attachment UX. + expect(onError).not.toHaveBeenCalled() + expect(removeMock).not.toHaveBeenCalled() + }) + + it('retryMessage: passes attachments through so failed-bubble retry of an attachment send keeps its files', async () => { + // Without this, the failed-bubble retry path silently drops the + // attachments and re-fires as a text-only send. + const sendMock = vi.fn<(...args: unknown[]) => Promise>(async () => {}) + const api = { sendMessage: sendMock } as unknown as ApiClient + + const { getMessageWindowState } = await import('@/lib/message-window-store') + const stateMock = vi.mocked(getMessageWindowState) + const failedAttachmentMessage = { + id: 'local-att-1', + seq: null, + localId: 'local-att-1', + content: { + role: 'user' as const, + content: { + type: 'text' as const, + text: 'photo + text', + attachments: [ + { id: 'att-1', filename: 'x.png', mimeType: 'image/png', size: 1, path: '/x.png' } + ] + } + }, + createdAt: 1000, + invokedAt: null, + scheduledAt: null, + status: 'failed' as const, + originalText: 'photo + text', + } + stateMock.mockReturnValue({ + messages: [failedAttachmentMessage], + pending: [] + } as unknown as ReturnType) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A'), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.retryMessage('local-att-1') + }) + + await waitFor(() => { + expect(sendMock).toHaveBeenCalled() + }) + const args = sendMock.mock.calls[0] + expect(args[0]).toBe('session-A') + expect(args[1]).toBe('photo + text') + expect(args[2]).toBe('local-att-1') + expect(args[3]).toEqual([ + { id: 'att-1', filename: 'x.png', mimeType: 'image/png', size: 1, path: '/x.png' } + ]) + }) + }) + it('does not call onSuccess when blocked', () => { const onSuccess = vi.fn() const onBlocked = vi.fn() diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index dc54c83f9..2f6c77ff4 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -6,6 +6,7 @@ import { makeClientSideId } from '@/lib/messages' import { appendOptimisticMessage, getMessageWindowState, + removeOptimisticMessage, updateMessageStatus, } from '@/lib/message-window-store' import { usePlatform } from '@/hooks/usePlatform' @@ -21,11 +22,49 @@ type SendMessageInput = { type BlockedReason = 'no-api' | 'no-session' | 'pending' +/** + * Information about a send that the underlying mutation rejected. + * + * Surfaced via the `onError` option so the consumer can keep the typed + * text in the composer (composer must NOT clear on 4xx/5xx or network + * failure) and render an inline affordance. + * + * - `sessionId` is the session the failed send was actually targeting + * (post-`resolveSessionId`). Inactive-session resume can resolve a + * target id, kick off async navigation, and then have the POST fail + * before navigation completes; without this id the consumer would + * restore the text into the wrong composer (the old session) and the + * sessionId-change effect would clear it again. + * - `text` is the original input the user typed, captured before the + * mutation cleared the composer. + * - `error` is the raw thrown value (typically `Error`) so the consumer + * can inspect status / message. + * - `scheduledAt` is the absolute epoch-ms the send was bound for, or + * null for an immediate send. Carried through so a failed scheduled + * send can be restored as a scheduled send instead of silently + * downgrading to immediate -- `SessionChat.handleSend` clears the + * pendingSchedule the moment the mutation is accepted, so without + * this the schedule is gone by the time onError fires. + * + * Only fired for text-only sends. Sends with attachments fall back to + * the legacy failed-bubble UX (the optimistic row stays as `failed` and + * the user retries via the in-thread retry button); the composer-restore + * path can't reinstate uploaded attachment metadata, so doing the swap + * for attachment sends would silently drop the attachments. + */ +export type SendErrorInfo = { + sessionId: string + text: string + error: unknown + scheduledAt: number | null +} + type UseSendMessageOptions = { resolveSessionId?: (sessionId: string) => Promise onSessionResolved?: (sessionId: string) => void onBlocked?: (reason: BlockedReason) => void onSuccess?: (sessionId: string) => void + onError?: (info: SendErrorInfo) => void isSessionThinking?: boolean } @@ -69,6 +108,30 @@ function findMessageByLocalId( return null } +/** Pull attachments off a stored optimistic user message. The schema types + * `content` as `unknown`, so this is a defensive narrow: we accept only the + * exact shape `createOptimisticMessage` produces (`role: 'user'`, text-typed + * content, attachments array) and return undefined otherwise. Used by + * retryMessage so an attachment send retried from the failed-bubble button + * re-fires with its attachments instead of becoming a text-only send. */ +function getMessageAttachments(message: DecryptedMessage): AttachmentMetadata[] | undefined { + const content = message.content as unknown + if ( + typeof content !== 'object' || + content === null + ) { + return undefined + } + const outer = content as { role?: unknown; content?: unknown } + if (outer.role !== 'user') return undefined + const inner = outer.content as { type?: unknown; attachments?: unknown } | null + if (!inner || inner.type !== 'text') return undefined + if (!Array.isArray(inner.attachments) || inner.attachments.length === 0) { + return undefined + } + return inner.attachments as AttachmentMetadata[] +} + export function useSendMessage( api: ApiClient | null, sessionId: string | null, @@ -111,9 +174,34 @@ export function useSendMessage( haptic.notification('success') options?.onSuccess?.(input.sessionId) }, - onError: (_, input) => { - updateMessageStatus(input.sessionId, input.localId, 'failed') + onError: (error, input) => { + // Attachment sends keep the legacy failed-bubble UX: the + // composer-restore path can only re-seat text + scheduledAt, + // not the uploaded attachment metadata. Removing the row + // would destroy the attachment preview AND leave the operator + // with no retry surface for it. Keep the row as `failed` so + // the in-thread retry button can re-fire the send (with + // attachments) via retryMessage. + if (input.attachments && input.attachments.length > 0) { + updateMessageStatus(input.sessionId, input.localId, 'failed') + haptic.notification('error') + return + } + // Text-only sends use the composer-restore path: drop the + // optimistic row from the thread (otherwise the failed bubble + // would visually duplicate the same text the composer is + // about to restore, and the operator could stack a stale + // failed turn next to a fresh send) and hand the text + + // scheduledAt + sessionId back so the route can put both + // back into the composer keyed to the right session. + removeOptimisticMessage(input.sessionId, input.localId) haptic.notification('error') + options?.onError?.({ + sessionId: input.sessionId, + text: input.text, + error, + scheduledAt: input.scheduledAt ?? null + }) }, }) @@ -190,6 +278,7 @@ export function useSendMessage( text: message.originalText, localId, createdAt: message.createdAt, + attachments: getMessageAttachments(message), scheduledAt: message.scheduledAt ?? null, }) return true diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 812b4e008..10e3abf30 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -231,6 +231,7 @@ export default { 'chat.settings': 'Settings', 'chat.terminal': 'Terminal', 'chat.switchRemote': 'Switch to remote mode', + 'chat.sendError.fallback': "Couldn't send your message. Edit and try again.", // Codex review 'codexReview.title': 'Codex review', @@ -404,6 +405,12 @@ export default { 'scratchlist.count.one': '1 item', 'scratchlist.count.other': '{n} items', 'scratchlist.emptyHint': 'Park notes, drafts, or ideas here. Nothing is sent until you promote it.', + 'scratchlist.drawerHint': 'Type below — Send adds the next message to the scratchlist instead of the chat. Click the note icon again to leave.', + 'scratchlist.toggleAriaLabel': 'Scratchlist drawer', + 'scratchlist.toggleTooltip': 'Scratchlist — park notes & drafts (Ctrl/Cmd+Shift+S)', + 'scratchlist.sendToScratchlist': 'Send to scratchlist', + 'scratchlist.fueTitle': 'New: Scratchlist', + 'scratchlist.fueBody': 'Park notes & drafts here without sending. The Send button glows amber while you stash; click the icon (or Ctrl/Cmd+Shift+S) again to leave.', 'scratchlist.addPlaceholder': 'Note, draft, or idea — Enter to add', 'scratchlist.addAriaLabel': 'Add scratchlist entry', 'scratchlist.add': 'Add', @@ -414,7 +421,12 @@ export default { 'scratchlist.action.moveDown': 'Move entry down', 'scratchlist.action.promoteToComposer': 'Copy into composer', 'scratchlist.action.promoteToQueue': 'Send to queue', + 'scratchlist.action.copy': 'Copy to clipboard', + 'scratchlist.action.copied': 'Copied!', 'scratchlist.action.delete': 'Delete entry', + 'fue.newFeatureDot': 'New feature available', + 'fue.gotIt': 'Got it', + 'fue.closeAriaLabel': 'Close explainer', 'composer.codexSlashUnsupported.title': 'Codex command unavailable', 'composer.codexSlashUnsupported.body': 'HAPI remote mode does not yet run built-in Codex slash commands like {command}. Use natural language instead, or run it in the local Codex TUI.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index c91029f18..7c52ba47c 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -234,6 +234,7 @@ export default { 'chat.settings': '设置', 'chat.terminal': '终端', 'chat.switchRemote': '切换到远程模式', + 'chat.sendError.fallback': '消息未能发送。请修改后重试。', // Codex review 'codexReview.title': 'Codex review', @@ -407,6 +408,12 @@ export default { 'scratchlist.count.one': '1 条', 'scratchlist.count.other': '{n} 条', 'scratchlist.emptyHint': '在此暂存笔记、草稿或想法。需点击发送或编辑后才会真正发出。', + 'scratchlist.drawerHint': '在下方输入 — 发送会把内容存入此暂存清单,而不是发到对话。再次点击便签图标可退出。', + 'scratchlist.toggleAriaLabel': '暂存清单抽屉', + 'scratchlist.toggleTooltip': '暂存清单 — 暂存笔记与草稿(Ctrl/Cmd+Shift+S)', + 'scratchlist.sendToScratchlist': '存入暂存清单', + 'scratchlist.fueTitle': '新功能:暂存清单', + 'scratchlist.fueBody': '在此暂存笔记和草稿,不会被发送。暂存模式下发送按钮会显示琥珀色;再次点击图标(或 Ctrl/Cmd+Shift+S)可退出。', 'scratchlist.addPlaceholder': '笔记、草稿或想法 — 回车键添加', 'scratchlist.addAriaLabel': '添加草稿夹条目', 'scratchlist.add': '添加', @@ -417,7 +424,12 @@ export default { 'scratchlist.action.moveDown': '下移', 'scratchlist.action.promoteToComposer': '复制到输入框', 'scratchlist.action.promoteToQueue': '加入发送队列', + 'scratchlist.action.copy': '复制到剪贴板', + 'scratchlist.action.copied': '已复制!', 'scratchlist.action.delete': '删除条目', + 'fue.newFeatureDot': '新功能可用', + 'fue.gotIt': '知道了', + 'fue.closeAriaLabel': '关闭说明', 'composer.codexSlashUnsupported.title': '无法执行 Codex 命令', 'composer.codexSlashUnsupported.body': 'HAPI 远程模式暂不支持 {command} 这类 Codex 内建 slash command,请改用自然语言,或在本地 Codex TUI 中执行。', diff --git a/web/src/lib/use-fue.test.ts b/web/src/lib/use-fue.test.ts new file mode 100644 index 000000000..2110b35a7 --- /dev/null +++ b/web/src/lib/use-fue.test.ts @@ -0,0 +1,103 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetFue, useFue } from './use-fue' + +const FEATURE = 'test-feature' +const STORAGE_KEY = `hapi.fue.v1.${FEATURE}` + +describe('useFue', () => { + beforeEach(() => { + localStorage.clear() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('starts unseen for new features', () => { + const { result } = renderHook(() => useFue(FEATURE)) + expect(result.current.status).toBe('unseen') + }) + + it('reads acknowledged state from localStorage on mount', () => { + localStorage.setItem(STORAGE_KEY, '1') + const { result } = renderHook(() => useFue(FEATURE)) + expect(result.current.status).toBe('acknowledged') + }) + + it('engage() flips status to engaging once', () => { + const { result } = renderHook(() => useFue(FEATURE)) + act(() => { + result.current.engage() + }) + expect(result.current.status).toBe('engaging') + // Re-engage is a no-op (does not flip back). + act(() => { + result.current.engage() + }) + expect(result.current.status).toBe('engaging') + }) + + it('does NOT auto-acknowledge — engaging persists until dismiss is called', () => { + const { result } = renderHook(() => useFue(FEATURE)) + act(() => { + result.current.engage() + }) + // Advance the clock far past any plausible timeout. + act(() => { + vi.advanceTimersByTime(60_000) + }) + expect(result.current.status).toBe('engaging') + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + }) + + it('dismiss() acknowledges and persists', () => { + const { result } = renderHook(() => useFue(FEATURE)) + act(() => { + result.current.engage() + }) + act(() => { + result.current.dismiss() + }) + expect(result.current.status).toBe('acknowledged') + expect(localStorage.getItem(STORAGE_KEY)).toBe('1') + }) + + it('dismiss() also works directly from unseen (caller may skip the engaging step)', () => { + const { result } = renderHook(() => useFue(FEATURE)) + act(() => { + result.current.dismiss() + }) + expect(result.current.status).toBe('acknowledged') + expect(localStorage.getItem(STORAGE_KEY)).toBe('1') + }) + + it('engage() is a no-op once acknowledged', () => { + localStorage.setItem(STORAGE_KEY, '1') + const { result } = renderHook(() => useFue(FEATURE)) + act(() => { + result.current.engage() + }) + expect(result.current.status).toBe('acknowledged') + }) + + it('resetFue() clears storage so the badge re-appears', () => { + localStorage.setItem(STORAGE_KEY, '1') + resetFue(FEATURE) + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + const { result } = renderHook(() => useFue(FEATURE)) + expect(result.current.status).toBe('unseen') + }) + + it('switches state when featureId changes', () => { + localStorage.setItem('hapi.fue.v1.feature-a', '1') + const { result, rerender } = renderHook( + ({ id }: { id: string }) => useFue(id), + { initialProps: { id: 'feature-a' } } + ) + expect(result.current.status).toBe('acknowledged') + rerender({ id: 'feature-b' }) + expect(result.current.status).toBe('unseen') + }) +}) diff --git a/web/src/lib/use-fue.ts b/web/src/lib/use-fue.ts new file mode 100644 index 000000000..8dee0ceb9 --- /dev/null +++ b/web/src/lib/use-fue.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * useFue — generic First-User-Experience badge / callout state machine. + * + * Goal: any new feature can advertise itself with a small dot on its + * affordance; the dot disappears for good once the user has engaged + * with it AND explicitly acknowledged the explainer. Hover surfaces a + * tooltip (caller's responsibility); click triggers `engage()`, which + * flips status to 'engaging' and renders the callout. Auto-timeout is + * deliberately not provided — reading speed varies, and a popover that + * disappears on its own undercuts the affirmative-action model. + * + * Storage: hapi.fue.v1. ('1' once acknowledged, absent otherwise) + * + * Status machine: + * unseen — initial. Badge visible, callout primed. + * engaging — operator has clicked the affordance for the first time. + * Callout is showing; awaiting explicit dismiss. + * acknowledged — terminal. Persisted to localStorage. Badge + callout + * suppressed forever (until storage is cleared). + * + * Independence from any upstream FUE: the storage namespace is + * `hapi.fue.v1.*` and feature IDs are caller-defined. If upstream/tiann + * adds a different onboarding flow that uses other keys / mechanisms, + * this system stays out of the way (caller decides whether to wrap a + * given affordance with FUE or not). + */ + +const STORAGE_PREFIX = 'hapi.fue.v1.' + +export type FueStatus = 'unseen' | 'engaging' | 'acknowledged' + +function readAcknowledged(featureId: string): boolean { + if (typeof window === 'undefined') return false + try { + return window.localStorage.getItem(STORAGE_PREFIX + featureId) === '1' + } catch { + return false + } +} + +function writeAcknowledged(featureId: string): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_PREFIX + featureId, '1') + } catch { + // localStorage may be unavailable (private mode, quota). Non-fatal: + // worst case the user sees the badge again next session. + } +} + +export function useFue(featureId: string): { + status: FueStatus + /** Call this on the first user-initiated engagement (typically the + * affordance's onClick). No-op if already engaging or acknowledged. */ + engage: () => void + /** Acknowledge permanently (call from the callout's "Got it" button or + * any other explicit affirmative action). */ + dismiss: () => void +} { + const [status, setStatus] = useState(() => + readAcknowledged(featureId) ? 'acknowledged' : 'unseen' + ) + + // Re-read on featureId change (different feature, different state). + useEffect(() => { + setStatus(readAcknowledged(featureId) ? 'acknowledged' : 'unseen') + }, [featureId]) + + const engage = useCallback(() => { + setStatus((prev) => (prev === 'unseen' ? 'engaging' : prev)) + }, []) + + const dismiss = useCallback(() => { + writeAcknowledged(featureId) + setStatus('acknowledged') + }, [featureId]) + + return { status, engage, dismiss } +} + +/** + * Test / dev-tool helper: clear acknowledgement for a feature so the FUE + * badge re-appears. Not used at runtime; expose via window for manual QA: + * + * localStorage.removeItem('hapi.fue.v1.scratchlist-toggle') + * + * Or call from a dev console: resetFue('scratchlist-toggle'). + */ +export function resetFue(featureId: string): void { + if (typeof window === 'undefined') return + try { + window.localStorage.removeItem(STORAGE_PREFIX + featureId) + } catch { + // ignore + } +} diff --git a/web/src/lib/use-scratchlist.test.ts b/web/src/lib/use-scratchlist.test.ts new file mode 100644 index 000000000..b6555ffba --- /dev/null +++ b/web/src/lib/use-scratchlist.test.ts @@ -0,0 +1,136 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { addScratchlistEntry, persistScratchlist, readScratchlist } from './scratchlist' +import { useScratchlist } from './use-scratchlist' + +const SESSION_A = 'session-a' +const SESSION_B = 'session-b' + +describe('useScratchlist', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('hydrates from localStorage on mount', () => { + const { entries: seeded } = addScratchlistEntry([], 'a-only', 1000) + persistScratchlist(SESSION_A, seeded) + const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), { + initialProps: { id: SESSION_A }, + }) + expect(result.current.entries.map((e) => e.text)).toEqual(['a-only']) + }) + + it('add() persists to the current sessions storage', () => { + const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), { + initialProps: { id: SESSION_A }, + }) + act(() => { + result.current.add('first') + }) + expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['first']) + expect(readScratchlist(SESSION_B)).toEqual([]) + }) + + it('switching sessions does NOT overwrite the new sessions storage with stale entries', () => { + // Regression test for the cross-session leak found by upstream review on PR #798. + // Seed both sessions distinctly; mount with A; rerender with B. + // The persist effect must not write A's entries into B's localStorage + // key during the brief render where the prop has changed but the + // rehydrate effect hasn't run yet. + const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000) + const { entries: bEntries } = addScratchlistEntry([], 'b-original', 2000) + persistScratchlist(SESSION_A, aEntries) + persistScratchlist(SESSION_B, bEntries) + + const { rerender } = renderHook(({ id }: { id: string }) => useScratchlist(id), { + initialProps: { id: SESSION_A }, + }) + + rerender({ id: SESSION_B }) + + // After the session switch, B's storage must still contain B's + // entry (not A's). Reading from disk because that's what the next + // mount of any other component would see. + expect(readScratchlist(SESSION_B).map((e) => e.text)).toEqual(['b-original']) + // A's storage stays intact too. + expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['a-original']) + }) + + it('after switching sessions, add() targets the new session', () => { + const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000) + persistScratchlist(SESSION_A, aEntries) + + const { result, rerender } = renderHook( + ({ id }: { id: string }) => useScratchlist(id), + { initialProps: { id: SESSION_A } } + ) + rerender({ id: SESSION_B }) + act(() => { + result.current.add('b-only') + }) + expect(readScratchlist(SESSION_B).map((e) => e.text)).toEqual(['b-only']) + expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['a-original']) + }) + + it('switching sessions never writes the previous sessions entries to the new sessions storage key', () => { + // The bot's review on PR #798 specifically called out the write + // window: between commit-with-new-id and the rehydrate effect + // running, the persist effect can fire one corrupting write + // (sessionId=B, entries=A's). That write self-heals on the next + // render once the rehydrate completes, so a "read after rerender" + // assertion would falsely pass. This test inspects every setItem + // call that happens during the rerender lifecycle and asserts no + // call wrote A's entries to B's storage key. + const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000) + const { entries: bEntries } = addScratchlistEntry([], 'b-original', 2000) + persistScratchlist(SESSION_A, aEntries) + persistScratchlist(SESSION_B, bEntries) + + const { rerender } = renderHook(({ id }: { id: string }) => useScratchlist(id), { + initialProps: { id: SESSION_A }, + }) + + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem') + rerender({ id: SESSION_B }) + + // Storage format is a top-level array of entries (see writeScratchlist + // in scratchlist.ts), so unpack and inspect each entry directly. + const corruptingWrites = setItemSpy.mock.calls.filter(([key, value]) => { + if (typeof key !== 'string' || typeof value !== 'string') return false + if (!key.endsWith(SESSION_B)) return false + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return false + return parsed.some( + (e: { text?: string }) => e?.text === 'a-original' + ) + } catch { + return false + } + }) + setItemSpy.mockRestore() + + expect(corruptingWrites).toEqual([]) + }) + + it('remove() and move() use the loaded sessionId', () => { + const { entries: seeded } = addScratchlistEntry([], 'first', 1000) + const { entries: seeded2 } = addScratchlistEntry(seeded, 'second', 2000) + persistScratchlist(SESSION_A, seeded2) + + const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), { + initialProps: { id: SESSION_A }, + }) + + const firstId = result.current.entries[0]!.id + act(() => { + result.current.remove(firstId) + }) + expect(result.current.entries.map((e) => e.text)).toEqual(['first']) + expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['first']) + }) +}) diff --git a/web/src/lib/use-scratchlist.ts b/web/src/lib/use-scratchlist.ts new file mode 100644 index 000000000..9ce375901 --- /dev/null +++ b/web/src/lib/use-scratchlist.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react' +import { + addScratchlistEntry, + deleteScratchlistEntry, + moveScratchlistEntry, + persistScratchlist, + readScratchlist, + type ScratchlistEntry, +} from '@/lib/scratchlist' + +/** + * useScratchlist - per-session scratchlist state hook. + * + * Originally the entries lived inside ScratchlistPanel's useState. The + * composer-controlled drawer (v1.1) needs the same data exposed in two + * places (the drawer + the composer-toolbar counter), so the state is + * lifted here. localStorage stays the source of truth; this hook is the + * React mirror. + * + * Cross-session race protection + * ----------------------------- + * The naive shape (entries: useState, sessionId: prop, two useEffects) + * leaks across session navigation: + * + * 1. Mount with sessionId=A → entries = readScratchlist(A) = [a1, a2] + * 2. Parent rerenders with sessionId=B (same component instance — the + * v1 panel sidestepped this with key={props.session.id}; the v1.1 + * lifted hook can't, because its parent SessionChat *isn't* + * remounted on session switch). + * 3. React commits with sessionId=B but `entries` is still A's data. + * 4. Persist effect fires: persistScratchlist(B, [a1, a2]) — + * OVERWRITES B's storage with A's entries before the rehydrate + * effect has a chance to run. + * + * Fix (per upstream review on PR #798): keep the loaded sessionId in + * state alongside the entries so they can swap atomically, and persist + * against the LOADED sessionId, not the current prop. After step 2 the + * loaded sessionId is still A (until the rehydrate effect runs), so a + * spurious persist re-writes A's storage with A's entries — a no-op + * instead of a corruption. + */ +export function useScratchlist(sessionId: string) { + const [{ sessionId: loadedSessionId, entries }, setScratchlist] = useState<{ + sessionId: string + entries: ScratchlistEntry[] + }>(() => ({ sessionId, entries: readScratchlist(sessionId) })) + + // Rehydrate when the parent navigates to a different session. This + // atomically swaps both the loaded sessionId and the entries, so the + // persist effect below sees a consistent (sessionId, entries) pair. + useEffect(() => { + setScratchlist({ sessionId, entries: readScratchlist(sessionId) }) + }, [sessionId]) + + // Persist using the LOADED sessionId, not the prop. If the prop has + // moved ahead of the rehydrate effect, this still writes back to the + // session whose entries we currently hold — no cross-session leak. + useEffect(() => { + persistScratchlist(loadedSessionId, entries) + }, [loadedSessionId, entries]) + + const add = useCallback((rawText: string): boolean => { + const result = addScratchlistEntry(entries, rawText) + if (result.entries === entries) return false + setScratchlist({ sessionId: loadedSessionId, entries: result.entries }) + return true + }, [entries, loadedSessionId]) + + const remove = useCallback((id: string) => { + setScratchlist((prev) => ({ + sessionId: prev.sessionId, + entries: deleteScratchlistEntry(prev.entries, id), + })) + }, []) + + const move = useCallback((id: string, direction: 'up' | 'down') => { + setScratchlist((prev) => ({ + sessionId: prev.sessionId, + entries: moveScratchlistEntry(prev.entries, id, direction), + })) + }, []) + + return { entries, add, remove, move } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 64da9fbfd..84e030a53 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Navigate, @@ -30,7 +30,8 @@ import { useSession } from '@/hooks/queries/useSession' import { useSessions } from '@/hooks/queries/useSessions' import { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' -import { useSendMessage } from '@/hooks/mutations/useSendMessage' +import { useSendMessage, type SendErrorInfo } from '@/hooks/mutations/useSendMessage' +import type { ComposerSendError } from '@/components/AssistantChat/HappyComposer' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' @@ -572,6 +573,23 @@ function SessionsIndexPage() { return null } +/** + * Extract a user-facing message from a thrown send error. + * `request` in the api client throws plain `Error` for !res.ok, with the + * format `"HTTP : "` -- we surface the message as + * a single line and fall back to a localized default when nothing usable is + * present (e.g. an aborted fetch that resolved with no message). + */ +function deriveSendErrorMessage( + error: unknown, + t: (key: string) => string, +): string { + if (error instanceof Error && error.message) { + return error.message + } + return t('chat.sendError.fallback') +} + function SessionPage() { const { api } = useAppContext() const { t } = useTranslation() @@ -599,6 +617,30 @@ function SessionPage() { flushPending, setAtBottom, } = useMessages(api, sessionId) + + // Tracks the most recent send the hub rejected (4xx/5xx/network), keyed + // by the session the failed POST actually targeted (post-resolveSessionId). + // assistant-ui clears the composer eagerly when a send is invoked, so to + // retain the typed text on error we keep it here and hand it back to the + // composer for restore + visual error affordance. Keying by sessionId + // covers the inactive-session resume race: useSendMessage can resolve + // the target id, kick off async navigation to it, and then have the POST + // fail before navigation completes. Without keying, we'd restore the + // text into the OLD session's composer and the next render would clear + // it. The bumped `id` still lets the composer dedupe restorations of + // identical text. + const [sendErrors, setSendErrors] = useState>({}) + const sendErrorIdRef = useRef(0) + const sendError = sendErrors[sessionId] ?? null + const clearSendError = useCallback(() => { + setSendErrors((prev) => { + if (!(sessionId in prev)) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + }, [sessionId]) + const { sendMessage, retryMessage, @@ -607,8 +649,28 @@ function SessionPage() { isSessionThinking: session?.thinking ?? false, onSuccess: (sentSessionId) => { clearDraftsAfterSend(sentSessionId, sessionId) - // 中文注释:一旦用户已经在 Hapi 内继续这个 Codex 会话,就清除“刚从 Codex 导入”的标记。 + // 中文注释:一旦用户已经在 Hapi 内继续这个 Codex 会话,就清除"刚从 Codex 导入"的标记。 clearCodexImportedSession(session?.metadata?.codexSessionId) + // A successful send supersedes any previously-rendered error + // for that session. Other sessions' errors stay put. + setSendErrors((prev) => { + if (!(sentSessionId in prev)) return prev + const next = { ...prev } + delete next[sentSessionId] + return next + }) + }, + onError: (info: SendErrorInfo) => { + sendErrorIdRef.current += 1 + setSendErrors((prev) => ({ + ...prev, + [info.sessionId]: { + id: sendErrorIdRef.current, + text: info.text, + message: deriveSendErrorMessage(info.error, t), + scheduledAt: info.scheduledAt + } + })) }, resolveSessionId: async (currentSessionId) => { if (!api || !session || session.active) { @@ -746,6 +808,8 @@ function SessionPage() { onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} availableSlashCommands={slashCommands} + sendError={sendError} + onClearSendError={clearSendError} /> ) } From 1f92a31b12412ef36ea466eb692afd8650a28b0f Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 8 Jun 2026 13:56:44 +0800 Subject: [PATCH 12/14] Release version 0.20.1 --- bun.lock | 22 ++++++++++------------ cli/package.json | 12 ++++++------ shared/src/buildInfo.ts | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 14b926e3c..94c5fb529 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": { @@ -1066,15 +1066,13 @@ "@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-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-VWPCKdAgwfUNBRI9Xy14CKjx1d7JS1irOja5l6zufpaTi139jc51gyDcWFfygMwttQlNimmh2qHTfaFqqvcdNg=="], "@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/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' From c1c1a0b64f744c23f057a35d33aad46dda3b05e5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:43:37 +0100 Subject: [PATCH 13/14] feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP Closes #824 When the operator reopens a legacy stream-json Cursor session in HAPI, the hub now transparently transplants its `~/.cursor/chats///store.db` into `~/.cursor/acp-sessions//`, verifies it loads via `agent acp`, flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy source - all before `resumeSession` returns. Subsequent opens are pure ACP. The primary justification is safety, not feature parity. #784 (`cursor-agent` fabricates `Questions skipped by the user` responses in legacy stream-json mode) still fires regularly in dogfood despite #801's mitigation: the agent ships destructive side effects against fabricated consent. Migration to ACP closes the protocol-level door because the `AskQuestion` tool does not exist on the ACP side, so there is nothing to fabricate. working. That tradeoff was reasonable at the time. The accumulated #784 evidence makes legacy sessions actively unsafe; this PR makes the upgrade path invisible enough that users stop avoiding it. A pre-PR spike established that legacy and ACP `store.db` files use the identical SQLite schema; only the directory layout differs. The migrator therefore: 1. Sanity-checks the source store and pre-flips state (`session.active`, `lifecycleState`, on-disk presence, target collision) 2. Optionally archives a stale-running row (`forceArchiveRunning: true` is the default for the auto-migrate path because the caller already verified `session.active === false`) 3. Atomically creates `~/.cursor/acp-sessions//` with mode `0o700` 4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening) 5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional `title`) with mode `0o600` 6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session loads via `session/load`. On long histories the verify also drives a trivial single-turn prompt; on short ones load-only is enough 7. Flips `cursorSessionProtocol = 'acp'` AND clears the `cursorMigrationState` banner flag in a SINGLE metadata write 8. Removes the legacy source store (only after verify succeeded and the protocol flip committed). The legacy `~/.cursor/chats` parent dir is left as-is Every failure leaves the legacy state intact. No `rm` fires without a verify success AND a committed protocol flip. The transplant takes 15-20s on long histories (copy a multi-hundred-MB store, spawn `agent acp`, replay thousands of notifications, tear down the probe). Without a progress indicator the wait reads as "broken" to a fresh reviewer. A minimal banner ships alongside the migrator: - Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the long-running transplant. The session-cache refresh emits the existing `session-updated` SSE event (no new event type), so the web client picks it up in milliseconds. No client-side polling needed. - Hub clears the flag in the SAME metadata write that flips `cursorSessionProtocol` to `'acp'` on success, so the banner disappears in the same render tick the chat re-renders as ACP - no flicker window. - Hub clears the flag explicitly in the auto-migrate helper's `finally` on failure/exception, so the banner never gets stuck if migration falls back to the legacy launcher. - Web renders an accessible (role=status, aria-live=polite) banner with an indeterminate spinner. Deliberately no fake percentage - we do not have phase data and a fake progress bar would lie. This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs (merged today as ad038bbf, 8094b500, fa363c2f), all of which are prerequisite for safe concurrent ACP launches. The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the verify pass), so it never touches `/locks/agent-acp-active/` at all. Per swear01's #835 design note, the post-flip ACP launcher claims the lock through the standard `registerActiveAcpTransport` entry and behaves like any other concurrent ACP start. The migrator itself never writes `pid` or `count` files directly. The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions keep running through the existing stream-json launcher). Default is on. A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows explicit migration of a single session outside the sync-on-open path (e.g. for a specific cold archived session a user wants to re-engage). Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint, candidate-listing API) were deliberately stripped per reviewer feedback; per-session sync-on-open + this escape hatch are the only two paths. - 343 hub unit tests (4 new for the migration banner flag transitions, 53 for the migrator core, 32 for the verify probe, 15 for the auto- migrate helper guard matrix, plus existing suites) - 3 integration tests against a real `agent acp` (skipped by default unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`) - 10 web unit tests for the banner component (visibility paths + a11y) - Typecheck clean across cli, web, hub Co-authored-by: Cursor --- bun.lock | 2 + hub/src/cursor/acpVerifyProbe.test.ts | 358 ++++++ hub/src/cursor/acpVerifyProbe.ts | 671 +++++++++++ hub/src/cursor/cursorLegacyMigrator.test.ts | 1047 +++++++++++++++++ hub/src/cursor/cursorLegacyMigrator.ts | 1033 ++++++++++++++++ .../cursorLegacyMigratorIntegration.test.ts | 277 +++++ .../fixtures/buildSyntheticLegacyStore.ts | 68 ++ hub/src/store/index.ts | 17 +- hub/src/sync/syncEngine.ts | 341 +++++- hub/src/sync/syncEngineAutoMigrate.test.ts | 304 +++++ hub/src/web/routes/cli.ts | 39 + hub/src/web/routes/sessions.ts | 49 + shared/src/apiTypes.ts | 34 + shared/src/schemas.ts | 1 + web/src/api/client.ts | 50 + .../components/CursorMigrationBanner.test.tsx | 86 ++ web/src/components/CursorMigrationBanner.tsx | 58 + web/src/components/SessionChat.tsx | 3 + web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 3 + web/src/types/api.ts | 1 + 21 files changed, 4437 insertions(+), 7 deletions(-) create mode 100644 hub/src/cursor/acpVerifyProbe.test.ts create mode 100644 hub/src/cursor/acpVerifyProbe.ts create mode 100644 hub/src/cursor/cursorLegacyMigrator.test.ts create mode 100644 hub/src/cursor/cursorLegacyMigrator.ts create mode 100644 hub/src/cursor/cursorLegacyMigratorIntegration.test.ts create mode 100644 hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts create mode 100644 hub/src/sync/syncEngineAutoMigrate.test.ts create mode 100644 web/src/components/CursorMigrationBanner.test.tsx create mode 100644 web/src/components/CursorMigrationBanner.tsx diff --git a/bun.lock b/bun.lock index 94c5fb529..3a34ccfe8 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,8 @@ "@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.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/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..c2ad69af0 --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.test.ts @@ -0,0 +1,1047 @@ +/** + * 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('--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..f421d6ad4 --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.ts @@ -0,0 +1,1033 @@ +/** + * 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. */ + createProbe?: (env: NodeJS.ProcessEnv) => 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) => new AcpVerifyProbe({ + env, + skipLockAcquire: true, + // Codex #34 P2 (round 13): in service-account-hub deployments + // where process.env.HOME differs from the human user who + // installed Cursor, the verify probe needs the recorded + // session-owner home to resolve $HOME/.local/bin/agent on + // PATH. Thread the migrator's homeDir dep through so the + // probe doesn't fall back to process.env.HOME when a more + // specific lookup home is available. + agentLookupHome: this.deps.homeDir() + })), + // 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) + 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/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/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a61578211..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' @@ -428,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) } @@ -631,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 @@ -653,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 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.ts b/hub/src/web/routes/sessions.ts index b1b5c003e..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, @@ -303,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 e9ea6a1ff..48e933023 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -147,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/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/web/src/api/client.ts b/web/src/api/client.ts index 2a7bc9120..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, @@ -425,6 +427,54 @@ export class ApiClient { ) } + /** + * 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/CursorMigrationBanner.test.tsx b/web/src/components/CursorMigrationBanner.test.tsx new file mode 100644 index 000000000..2a32eabb8 --- /dev/null +++ b/web/src/components/CursorMigrationBanner.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { CursorMigrationBanner, isCursorMigrationInProgress } from './CursorMigrationBanner' +import type { Metadata } from '@/types/api' + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}) +} + +afterEach(() => { + cleanup() +}) + +function metadata(partial: Partial = {}): Metadata { + return { + path: '/tmp/x', + host: 'localhost', + ...partial + } as Metadata +} + +describe('isCursorMigrationInProgress', () => { + it('returns true when flag is in_progress', () => { + expect(isCursorMigrationInProgress(metadata({ cursorMigrationState: 'in_progress' }))).toBe(true) + }) + it('returns false when flag is undefined', () => { + expect(isCursorMigrationInProgress(metadata())).toBe(false) + }) + it('returns false when metadata is null', () => { + expect(isCursorMigrationInProgress(null)).toBe(false) + }) + it('returns false when metadata is undefined', () => { + expect(isCursorMigrationInProgress(undefined)).toBe(false) + }) +}) + +describe('CursorMigrationBanner', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0 + }, + configurable: true + }) + }) + + it('renders the banner when cursorMigrationState is in_progress', () => { + renderWithProviders() + expect(screen.getByTestId('cursor-migration-banner')).toBeInTheDocument() + expect(screen.getByText('Upgrading Cursor session')).toBeInTheDocument() + expect(screen.getByText(/safer ACP protocol/)).toBeInTheDocument() + }) + + it('does not render when cursorMigrationState is undefined', () => { + renderWithProviders() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('does not render when metadata is null', () => { + renderWithProviders() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('does not render when metadata is undefined', () => { + renderWithProviders() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('does not render when migration is complete (cursorSessionProtocol is acp but no in_progress flag)', () => { + renderWithProviders() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('uses role=status and aria-live=polite for screen-reader accessibility', () => { + renderWithProviders() + const status = screen.getByRole('status') + expect(status).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/web/src/components/CursorMigrationBanner.tsx b/web/src/components/CursorMigrationBanner.tsx new file mode 100644 index 000000000..a5d5cc668 --- /dev/null +++ b/web/src/components/CursorMigrationBanner.tsx @@ -0,0 +1,58 @@ +/** + * CursorMigrationBanner + * + * Surfaces the in-progress automatic legacy-stream-json → ACP migration to + * the user, so the 15-20s "dark wait" while the migrator transplants the + * store.db, spawns `agent acp`, replays notifications, and tears down the + * verify probe doesn't read as "broken / nothing is happening". + * + * Visibility contract: + * - Renders when `session.metadata.cursorMigrationState === 'in_progress'` + * - Hub flips the flag → SSE `session-updated` → React Query cache → this + * re-renders within milliseconds (no client-side polling needed; the + * hub's session-updated channel is already real-time). + * - Hub clears the flag in the SAME metadata write that flips + * `cursorSessionProtocol` to 'acp' on success, so the banner disappears + * in the same render tick the chat re-renders as ACP — no flicker. + * - On failure / exception the hub clears the flag explicitly in the + * auto-migrate helper's finally, so the banner never gets stuck. + * + * Deliberately minimal — no fake progress bar (we don't have phase data and + * a fake percentage would lie); just an indeterminate spinner + a short + * explanation. UX A++ design notes are in PR #34's body. + */ + +import type { Metadata } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' + +export function isCursorMigrationInProgress(metadata: Metadata | undefined | null): boolean { + if (!metadata) return false + return metadata.cursorMigrationState === 'in_progress' +} + +export function CursorMigrationBanner({ metadata }: { metadata: Metadata | undefined | null }) { + const { t } = useTranslation() + if (!isCursorMigrationInProgress(metadata)) { + return null + } + return ( +
+
+
+
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3390ee7b0..913f10c65 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -30,6 +30,7 @@ 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' @@ -976,6 +977,8 @@ function SessionChatInner(props: SessionChatProps) { }} /> + + {props.session.teamState && ( )} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 10e3abf30..ea1d2c0f0 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -118,6 +118,8 @@ export default { 'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago', 'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago', 'session.time.importedFromCodex.daysAgo': 'imported from Codex {n}d ago', + 'session.cursorMigration.banner.title': 'Upgrading Cursor session', + 'session.cursorMigration.banner.body': 'Switching this legacy chat to the safer ACP protocol. This takes 15-20 seconds for sessions with long history; your conversation will resume automatically and any draft text will survive.', // Session header 'session.title': 'Files', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7c52ba47c..27c978155 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -118,6 +118,8 @@ export default { 'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入', 'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入', 'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入', + 'session.cursorMigration.banner.title': '正在升级 Cursor 会话', + 'session.cursorMigration.banner.body': '正在将此旧版会话切换到更安全的 ACP 协议。历史较长的会话需要 15-20 秒;对话会自动恢复,已输入但未发送的草稿不会丢失。', // Session header 'session.title': '文件', @@ -160,6 +162,7 @@ export default { 'dialog.delete.description': '确定要删除 "{name}" 吗?此操作无法撤销。', 'dialog.delete.confirm': '删除', 'dialog.delete.confirming': '删除中…', + 'dialog.error.default': '操作失败,请重试。', // Session export diff --git a/web/src/types/api.ts b/web/src/types/api.ts index f1b7c5fc1..b9f5d0f76 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,7 @@ export type { AgentState, AttachmentMetadata, CodexCollaborationMode, + Metadata, PermissionMode, Machine, RunnerState, From e3e1c0b3b4f46e968ad44a01b8ccd88332016c53 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:15:25 +0100 Subject: [PATCH 14/14] fix(cursor): address upstream Codex review findings on #844 (2 majors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir` in service-account hub deployments. `migrateOne` resolved the legacy store under `metadata.homeDir` (the recorded session-owner home) but the default createProbe factory still set `agentLookupHome` from `this.deps.homeDir()` (the hub user's home). On a service-account hub, the store lookup succeeded but `agent acp` discovery fell back to the hub user's `~/.local/bin`, so verify silently failed and sync-on-open quietly fell back to legacy. Fix: - Widen `CursorLegacyMigratorDeps.createProbe` signature from `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe` - Default factory uses the passed `agentLookupHome` - `verifyInTempHome` threads `opts.sourceHome` (already the resolved session-owner home) through as the 2nd arg 2 new regression tests pin the contract: - service-account case (metadata.homeDir != deps.homeDir()): captured agentLookupHome MUST equal metadata.homeDir - legacy session record (no metadata.homeDir): falls back to deps.homeDir() correctly Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while `cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 → v0.20.1 release commit landing in upstream/main between the original spike and the rebase). Frozen-install Windows users would either get the wrong native binary or have the lock rejected. Fix: regenerated bun.lock so the entry resolves `@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now passes clean. Test budget: - 391 hub unit tests pass (2 new for createProbe agentLookupHome contract, +0 regressions) - Typecheck clean across cli + web + hub - `bun install --frozen-lockfile` clean Co-authored-by: Cursor --- bun.lock | 2 +- hub/src/cursor/cursorLegacyMigrator.test.ts | 92 +++++++++++++++++++++ hub/src/cursor/cursorLegacyMigrator.ts | 34 +++++--- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index 3a34ccfe8..0bcd2d047 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,7 +1074,7 @@ "@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.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], + "@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/hub/src/cursor/cursorLegacyMigrator.test.ts b/hub/src/cursor/cursorLegacyMigrator.test.ts index c2ad69af0..70f262af2 100644 --- a/hub/src/cursor/cursorLegacyMigrator.test.ts +++ b/hub/src/cursor/cursorLegacyMigrator.test.ts @@ -506,6 +506,98 @@ describe('CursorLegacyMigrator.migrateOne — happy path', () => { } }) + 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) diff --git a/hub/src/cursor/cursorLegacyMigrator.ts b/hub/src/cursor/cursorLegacyMigrator.ts index f421d6ad4..829a52081 100644 --- a/hub/src/cursor/cursorLegacyMigrator.ts +++ b/hub/src/cursor/cursorLegacyMigrator.ts @@ -77,8 +77,15 @@ export interface CursorLegacyMigratorDeps { * review #34 P2. */ hostName?: () => string - /** Spawn factory for the verify probe. Override in tests to inject a mock probe. */ - createProbe?: (env: NodeJS.ProcessEnv) => AcpVerifyProbe + /** + * 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 @@ -306,17 +313,20 @@ export class CursorLegacyMigrator { this.deps = { homeDir: deps.homeDir ?? (() => homedir()), hostName: deps.hostName ?? (() => process.env.HAPI_HOSTNAME?.trim() || hostname()), - createProbe: deps.createProbe ?? ((env) => new AcpVerifyProbe({ + createProbe: deps.createProbe ?? ((env, agentLookupHome) => new AcpVerifyProbe({ env, skipLockAcquire: true, - // Codex #34 P2 (round 13): in service-account-hub deployments - // where process.env.HOME differs from the human user who - // installed Cursor, the verify probe needs the recorded - // session-owner home to resolve $HOME/.local/bin/agent on - // PATH. Thread the migrator's homeDir dep through so the - // probe doesn't fall back to process.env.HOME when a more - // specific lookup home is available. - agentLookupHome: this.deps.homeDir() + // 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 @@ -816,7 +826,7 @@ export class CursorLegacyMigrator { env.HOMEPATH = fakeHome } } - const probe = this.deps.createProbe(env) + const probe = this.deps.createProbe(env, opts.sourceHome) const verifyStart = this.deps.now() const verifyDeadline = verifyStart + this.opts.verifyTimeoutMs try {