fix(cursor): intercept fabricated Questions skipped AskQuestion result in headless mode (#784) (#801)

* fix(cursor): intercept fabricated 'Questions skipped' AskQuestion result in headless mode (#784)

When cursor-agent runs under `--print --output-format stream-json` (HAPI's
current Cursor remote launcher), the CLI returns a synthetic
`Questions skipped by the user, continue with the information you already have`
response for the `AskQuestion` tool in ~zero seconds with no error flag,
because there is no IDE surface to render the question. The underlying
model can interpret this as legitimate user consent and act on it.

This patch intercepts the synthetic result in
`cli/src/cursor/utils/cursorEventConverter.ts` and rewrites the
`tool_call`/completed event to a structured `no_input_surface` failure
(`status: 'failed'`, which downstream becomes `is_error: true`).

Detection has two strategies:

1. String match - any `tool_call`/completed payload whose serialized form
   contains the synthetic-skip marker is rewritten. This is robust to
   wherever cursor-agent stuffs the marker inside the `tool_call` object.
2. Timing + name heuristic (defense in depth) - any completion that arrives
   within 500 ms of its 'started' event with a trivial result, for a tool
   call named `AskQuestion`, `askQuestion`, `ask_question`, or the
   converter's `unknown` fallback, is also rewritten. This catches the case
   where cursor-agent changes the synthetic-string text in a future release.

The converter tracks per-call timestamps in a bounded `Map` (`<= 1024`
entries, oldest evicted on overflow) and clears entries when the
corresponding 'completed' event arrives. A small test-only reset hook
isolates state between Vitest cases.

This is a transitional safety patch. It auto-deletes when #781's ACP
launcher replaces the stream-json launcher and `cursor/ask_question`
becomes a proper bidirectional ACP method where fabrication is
structurally impossible.

Scope is intentionally tiny: only `cli/src/cursor/utils/cursorEventConverter.ts`,
its colocated Vitest file, and a section in `docs/guide/cursor.md`. No
changes to `cursorRemoteLauncher.ts`, ACP code, web normalizer, or
permission UI.

Refs: tiann/hapi#781 (long-term resolution via ACP migration)
Closes: tiann/hapi#784

* fix(cursor): gate AskQuestion intercept on tool name (#784 PR #801 review)

Address regression flagged by the HAPI auto-review bot on #801:

`containsSyntheticSkipMarker` previously stringified the entire `tool_call`
payload and matched the literal marker substring. Because this PR also adds
that exact marker to `docs/guide/cursor.md` (to document the intercept), a
Cursor `read_file` of that documentation page would surface the marker
inside `readToolCall.result.content` and be rewritten as a
`no_input_surface` failure, corrupting an unrelated, legitimate result.

The intercept is now gated on the tool name resolving to an
AskQuestion-shaped call (`AskQuestion`, `askQuestion`, `ask_question`, or
the converter's `unknown` fallback for unnamed function-shaped tools).
`read_file` / `write_file` tool calls - which have explicit `read_file`
and `write_file` names from `extractToolName` - no longer fall under the
intercept, regardless of what their payload contains.

The marker check itself now walks values recursively (string / array /
object), guarded by a `WeakSet` against cycles, instead of relying on
`JSON.stringify`. Slightly tidier; behaviour is otherwise unchanged for
the AskQuestion path.

Regression tests added:

- `read_file` result whose `content` contains the marker -> passes
  through with `status: 'completed'` and no `no_input_surface`.
- `write_file` whose serialized `args` contain the marker -> same.
- A non-AskQuestion function tool (`MyCustomTool`) whose result quotes
  the marker -> same.

All 846 cli tests pass (17 in this file). `bun run typecheck` exits 0.

* fix(cursor): scope synthetic-skip check to extracted result (#784 PR #801 review-2)

Address second Major finding from the HAPI auto-review bot on #801:

After the previous fix gated the intercept on the tool name, the marker
check still recursed into the entire `tool_call` object - which includes
`function.arguments`, the agent's own prompt text. A legitimate
AskQuestion whose prompt quotes the synthetic-skip marker (e.g. an agent
debugging this exact bug, or any prompt that pastes the marker verbatim)
would have been rewritten as `no_input_surface` even when the operator
actually answered.

Changes:

1. `extractToolResult` now extracts the cursor-side response from
   function-shaped tool calls. Previously it returned `{}` for anything
   that wasn't `readToolCall` or `writeToolCall`. It now returns
   `function.result` when present, otherwise every field of `function`
   except `name` and `arguments`. This excludes the agent's input from
   what downstream sees as the tool result, and as a side effect surfaces
   the actual cursor response for function-shaped tools (which was
   previously lost - see the #784 incident note about HAPI storing
   `output: {}` for AskQuestion in the message DB).

2. `shouldRewriteAsNoInputSurface` now searches only the extracted
   `result`, not the whole `tool_call`. The bot's exact recommendation.

3. Test added: an AskQuestion whose `arguments` quote the marker but
   whose `result` is a real user answer, with elapsed time past the
   500 ms threshold so the timing heuristic does not apply. Asserts the
   tool_result passes through with `status: 'completed'` and the
   operator's actual answer.

All 847 cli tests pass (18 in `cursorEventConverter.test.ts`).
`bun run typecheck` exits 0.

The widened `extractToolResult` scope is necessary for the marker check
to actually find the synthetic string (it lives inside `function.result`
or a sibling field), and is the bot's explicit recommendation. It also
removes the long-standing data-loss bug where AskQuestion responses were
surfaced to the message DB as opaque `{}` - regardless of fabrication.
This commit is contained in:
HeavyGee
2026-06-05 21:44:37 +08:00
committed by GitHub
parent a812a51dd7
commit dc0d21e05b
3 changed files with 504 additions and 1 deletions
+10
View File
@@ -48,6 +48,16 @@ Set mode via `--mode` flag or change from the web UI during a session.
- **Tool approval** - In remote mode, `--trust` is used; tools run without per-request approval. Use `--yolo` for full bypass.
- **Session resume** - Pass `--resume <chatId>` or `--continue` to resume. Use `agent ls` to list previous chats and get chat IDs.
### Headless 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.
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.
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.
Tracking issue: [tiann/hapi#784](https://github.com/tiann/hapi/issues/784).
## Integration
Once running, your Cursor session appears in the HAPI web app and Telegram Mini App. You can: