mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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).
This commit is contained in:
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, unknown>): string {
|
||||
const fn = toolCall.function as Record<string, unknown>;
|
||||
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<string, unknown>): unknown {
|
||||
}
|
||||
if (toolCall.function && typeof toolCall.function === 'object') {
|
||||
const fn = toolCall.function as Record<string, unknown>;
|
||||
// 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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(fn)) {
|
||||
@@ -105,20 +115,41 @@ function extractToolResult(toolCall: Record<string, unknown>): 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<string, number>();
|
||||
|
||||
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<object> = new WeakSet()): boolean {
|
||||
if (typeof value === 'string') {
|
||||
@@ -197,53 +188,56 @@ function containsSyntheticSkipMarker(value: unknown, seen: WeakSet<object> = 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<string, unknown>).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<string, unknown>): boolean {
|
||||
if (toolCall.function && typeof toolCall.function === 'object') {
|
||||
const fn = toolCall.function as Record<string, unknown>;
|
||||
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<string, unknown>): 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,
|
||||
|
||||
Reference in New Issue
Block a user