diff --git a/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts b/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts index a4d7840c..1f89a922 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 = { - type: 'tool_call', - subtype: 'started', - call_id: 'q5', - session_id: 's1', - tool_call: { function: { name: 'AskQuestion', arguments: '{}' } } - } as CursorStreamEvent; - convertCursorEventToAgentMessage(startedEvent); - - await new Promise((resolve) => setTimeout(resolve, 550)); - + // 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: 'completed', - call_id: 'q5', + call_id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', + session_id: 's1', + tool_call: { + id: 'toolu_vrtx_01ALz3pUoYRHEi8jg4hxurGp', + name: 'TodoWrite', + input: { todos: [] } + } + } as CursorStreamEvent; + 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' + }); + }); + + // 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: '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 3876ebeb..12516587 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 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. + * 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 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. + * 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. * - * 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. + * 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. + * + * 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. + * + * 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']); +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; -} - -/** - * 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; -} - -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)) { - 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; - } - 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']); /** - * Test-only hook to reset the timing tracker between test cases. Not exported - * from the package surface; consumed by the colocated test file. + * 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. */ -export function __resetCursorEventConverterStateForTests(): void { - toolCallStartedAt.clear(); +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; + } + for (const [k, v] of Object.entries(toolCall)) { + if (AGENT_INPUT_KEYS.has(k)) continue; + if (containsSyntheticSkipMarker(v)) return true; + } + return false; +} + +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 ca919d51..c7ab1238 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).