pi-cursor-sdk 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -1
  3. package/docs/cursor-live-smoke-checklist.md +22 -2
  4. package/docs/cursor-model-ux-spec.md +5 -4
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +428 -0
  7. package/package.json +11 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/isolated-cursor-smoke.sh +226 -0
  11. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  12. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  13. package/scripts/validate-smoke-jsonl.mjs +86 -7
  14. package/src/context.ts +45 -32
  15. package/src/cursor-agent-message-web-tools.ts +172 -0
  16. package/src/cursor-agents-context.ts +176 -0
  17. package/src/cursor-context-tools.ts +6 -0
  18. package/src/cursor-display-text.ts +10 -0
  19. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  20. package/src/cursor-live-run-coordinator.ts +18 -7
  21. package/src/cursor-model.ts +12 -0
  22. package/src/cursor-native-replay-routing.ts +48 -0
  23. package/src/cursor-native-replay-trace.ts +29 -0
  24. package/src/cursor-native-tool-display-registration.ts +14 -7
  25. package/src/cursor-native-tool-display-replay.ts +63 -5
  26. package/src/cursor-native-tool-display-tools.ts +20 -0
  27. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  28. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  29. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  30. package/src/cursor-provider-errors.ts +96 -0
  31. package/src/cursor-provider-live-run-drain.ts +208 -63
  32. package/src/cursor-provider-turn-coordinator.ts +217 -47
  33. package/src/cursor-provider.ts +275 -83
  34. package/src/cursor-question-tool.ts +10 -5
  35. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  36. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  37. package/src/cursor-sdk-event-debug-session.ts +163 -0
  38. package/src/cursor-sdk-event-debug.ts +597 -0
  39. package/src/cursor-sensitive-text.ts +27 -7
  40. package/src/cursor-session-agent.ts +25 -3
  41. package/src/cursor-session-send-policy.ts +43 -0
  42. package/src/cursor-setting-sources.ts +29 -0
  43. package/src/cursor-state.ts +1 -5
  44. package/src/cursor-tool-lifecycle.ts +111 -0
  45. package/src/cursor-tool-names.ts +12 -0
  46. package/src/cursor-tool-transcript.ts +4 -2
  47. package/src/cursor-transcript-tool-formatters.ts +228 -5
  48. package/src/cursor-transcript-tool-specs.ts +113 -14
  49. package/src/cursor-transcript-utils.ts +12 -0
  50. package/src/cursor-web-tool-activity.ts +84 -0
  51. package/src/index.ts +4 -1
@@ -0,0 +1,86 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export const CURSOR_SDK_STARTUP_NOISE_PATTERNS = [
4
+ "[hooks]",
5
+ "managed_skills.",
6
+ "CursorPluginsAgentSkillsService load completed",
7
+ "LocalCursorRulesService load completed",
8
+ "AgentSkillsCursorRulesService load completed",
9
+ "Error initializing ignore mapping for",
10
+ "Ripgrep path not configured. Call configureRipgrepPath() at startup.",
11
+ ];
12
+
13
+ const cursorSdkOutputSuppression = new AsyncLocalStorage();
14
+
15
+ export function isCursorSdkOutputSuppressed() {
16
+ return cursorSdkOutputSuppression.getStore() === true;
17
+ }
18
+
19
+ export function suppressCursorSdkOutput(operation) {
20
+ return cursorSdkOutputSuppression.run(true, operation);
21
+ }
22
+
23
+ export function isCursorSdkStartupNoise(text) {
24
+ return CURSOR_SDK_STARTUP_NOISE_PATTERNS.some((pattern) => text.includes(pattern));
25
+ }
26
+
27
+ function createFilteredProcessWrite(write, stream) {
28
+ return (chunk, encodingOrCallback, callback) => {
29
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
30
+ if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) {
31
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
32
+ done?.();
33
+ return true;
34
+ }
35
+ return write.call(stream, chunk, encodingOrCallback, callback);
36
+ };
37
+ }
38
+
39
+ function createFilteredConsoleMethod(method) {
40
+ return (...args) => {
41
+ const text = args.map((arg) => (typeof arg === "string" ? arg : String(arg))).join(" ");
42
+ if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) return;
43
+ method(...args);
44
+ };
45
+ }
46
+
47
+ let activeOutputFilterInstalls = 0;
48
+ let outputFilterOriginals;
49
+
50
+ export function installCursorSdkOutputFilter() {
51
+ if (activeOutputFilterInstalls === 0) {
52
+ outputFilterOriginals = {
53
+ stdoutWrite: process.stdout.write,
54
+ stderrWrite: process.stderr.write,
55
+ consoleLog: console.log,
56
+ consoleInfo: console.info,
57
+ consoleWarn: console.warn,
58
+ consoleError: console.error,
59
+ consoleDebug: console.debug,
60
+ };
61
+ process.stdout.write = createFilteredProcessWrite(outputFilterOriginals.stdoutWrite, process.stdout);
62
+ process.stderr.write = createFilteredProcessWrite(outputFilterOriginals.stderrWrite, process.stderr);
63
+ console.log = createFilteredConsoleMethod(outputFilterOriginals.consoleLog);
64
+ console.info = createFilteredConsoleMethod(outputFilterOriginals.consoleInfo);
65
+ console.warn = createFilteredConsoleMethod(outputFilterOriginals.consoleWarn);
66
+ console.error = createFilteredConsoleMethod(outputFilterOriginals.consoleError);
67
+ console.debug = createFilteredConsoleMethod(outputFilterOriginals.consoleDebug);
68
+ }
69
+ activeOutputFilterInstalls += 1;
70
+
71
+ let restored = false;
72
+ return () => {
73
+ if (restored) return;
74
+ restored = true;
75
+ activeOutputFilterInstalls = Math.max(activeOutputFilterInstalls - 1, 0);
76
+ if (activeOutputFilterInstalls > 0 || !outputFilterOriginals) return;
77
+ process.stdout.write = outputFilterOriginals.stdoutWrite;
78
+ process.stderr.write = outputFilterOriginals.stderrWrite;
79
+ console.log = outputFilterOriginals.consoleLog;
80
+ console.info = outputFilterOriginals.consoleInfo;
81
+ console.warn = outputFilterOriginals.consoleWarn;
82
+ console.error = outputFilterOriginals.consoleError;
83
+ console.debug = outputFilterOriginals.consoleDebug;
84
+ outputFilterOriginals = undefined;
85
+ };
86
+ }
@@ -5,6 +5,13 @@
5
5
  import { readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import { join, relative } from "node:path";
7
7
 
8
+ const REPLAY_TOOL_NOT_FOUND = [
9
+ "Tool grep not found",
10
+ "Tool cursor not found",
11
+ "Tool find not found",
12
+ "Tool ls not found",
13
+ ];
14
+
8
15
  function printHelp() {
9
16
  console.log(`Validate assistant presence and usage metadata in pi smoke session JSONL files.
10
17
 
@@ -18,21 +25,27 @@ Arguments:
18
25
 
19
26
  Options:
20
27
  -h, --help Show this help.
28
+ --replay-errors Also fail when JSONL contains native replay "Tool * not found" errors.
29
+ --replay-errors-only Scan only for native replay "Tool * not found" errors (skip usage checks).
21
30
 
22
31
  Exit codes:
23
- 0 every scanned JSONL file has at least one assistant message and valid assistant usage metadata
24
- 1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files, or usage validation failures
32
+ 0 every enforced invariant passed for the selected mode(s)
33
+ 1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files, usage validation failures, or replay tool errors
25
34
  2 no JSONL files found under the smoke directory
26
35
 
27
- Enforced invariants:
36
+ Enforced invariants (default mode):
28
37
  - each scanned JSONL file contains parseable JSONL records
29
38
  - each scanned JSONL file contains at least one persisted assistant message
30
39
  - every persisted assistant message has usage metadata
31
40
  - assistant usage input/output/totalTokens are non-negative numbers
32
41
  - assistant usage cacheRead/cacheWrite are exactly 0
33
42
 
43
+ Replay error scan (--replay-errors / --replay-errors-only):
44
+ - no persisted error toolResult or error assistant message contains "Tool grep/cursor/find/ls not found"
45
+ - successful tool/file reads that mention those strings in docs are ignored
46
+
34
47
  Notes:
35
- - Prints one JSON summary line per scanned session file.
48
+ - Prints one JSON summary line per scanned session file (usage mode) or one replay summary line (replay-only mode).
36
49
  - Does not print session message contents or secrets.`);
37
50
  }
38
51
 
@@ -88,6 +101,42 @@ function parseJsonlFile(file) {
88
101
  return { lineCount: lines.length, records, parseErrorCount };
89
102
  }
90
103
 
104
+ function getMessageText(message) {
105
+ if (!message || typeof message !== "object") return "";
106
+ const parts = [];
107
+ if (typeof message.errorMessage === "string") parts.push(message.errorMessage);
108
+ if (Array.isArray(message.content)) {
109
+ for (const block of message.content) {
110
+ if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
111
+ }
112
+ }
113
+ return parts.join("\n");
114
+ }
115
+
116
+ function isReplayErrorMessage(message, needle) {
117
+ const text = getMessageText(message);
118
+ if (!text.includes(needle)) return false;
119
+ if (message.role === "toolResult" && message.isError === true) return true;
120
+ if (message.role === "assistant" && (message.stopReason === "error" || typeof message.errorMessage === "string")) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ function scanReplayErrors(file, records) {
127
+ const hits = [];
128
+ for (const [index, record] of records.entries()) {
129
+ const message = record?.type === "message" ? record.message : undefined;
130
+ if (!message) continue;
131
+ for (const needle of REPLAY_TOOL_NOT_FOUND) {
132
+ if (isReplayErrorMessage(message, needle)) {
133
+ hits.push({ line: index + 1, needle });
134
+ }
135
+ }
136
+ }
137
+ return hits;
138
+ }
139
+
91
140
  function main() {
92
141
  const args = process.argv.slice(2);
93
142
  if (args.includes("-h") || args.includes("--help")) {
@@ -95,11 +144,15 @@ function main() {
95
144
  return;
96
145
  }
97
146
 
98
- if (args.length > 1) {
147
+ const replayErrorsOnly = args.includes("--replay-errors-only");
148
+ const replayErrors = replayErrorsOnly || args.includes("--replay-errors");
149
+ const positional = args.filter((arg) => !arg.startsWith("-"));
150
+
151
+ if (positional.length > 1) {
99
152
  fail("too many arguments; pass only the smoke directory");
100
153
  }
101
154
 
102
- const smokeDir = args[0] ?? process.env.SMOKE_DIR;
155
+ const smokeDir = positional[0] ?? process.env.SMOKE_DIR;
103
156
  if (!smokeDir) {
104
157
  fail("missing smoke directory; pass a path or set SMOKE_DIR");
105
158
  }
@@ -117,6 +170,24 @@ function main() {
117
170
  }
118
171
 
119
172
  let failures = 0;
173
+ if (replayErrorsOnly) {
174
+ let replayHitCount = 0;
175
+ for (const file of files) {
176
+ const { records } = parseJsonlFile(file);
177
+ const hits = scanReplayErrors(file, records);
178
+ replayHitCount += hits.length;
179
+ if (hits.length > 0) failures += 1;
180
+ console.log(
181
+ JSON.stringify({
182
+ file: relative(smokeDir, file),
183
+ replayErrorCount: hits.length,
184
+ replayErrors: hits.slice(0, 5),
185
+ }),
186
+ );
187
+ }
188
+ process.exit(failures === 0 ? 0 : 1);
189
+ }
190
+
120
191
  for (const file of files) {
121
192
  let summary;
122
193
  try {
@@ -125,7 +196,14 @@ function main() {
125
196
  const assistants = messages.filter((message) => message?.role === "assistant");
126
197
  const usage = assistants.map((message) => message.usage).filter(Boolean);
127
198
  const badUsage = assistants.map((message) => message.usage).filter(isBadUsage);
128
- const fileFailure = lineCount === 0 || parseErrorCount > 0 || assistants.length === 0 || usage.length !== assistants.length || badUsage.length > 0;
199
+ const replayHits = replayErrors ? scanReplayErrors(file, records) : [];
200
+ const fileFailure =
201
+ lineCount === 0 ||
202
+ parseErrorCount > 0 ||
203
+ assistants.length === 0 ||
204
+ usage.length !== assistants.length ||
205
+ badUsage.length > 0 ||
206
+ replayHits.length > 0;
129
207
  if (fileFailure) failures += 1;
130
208
  summary = {
131
209
  file: relative(smokeDir, file),
@@ -135,6 +213,7 @@ function main() {
135
213
  assistantCount: assistants.length,
136
214
  usageCount: usage.length,
137
215
  badUsageCount: badUsage.length,
216
+ replayErrorCount: replayHits.length,
138
217
  };
139
218
  } catch (error) {
140
219
  failures += 1;
package/src/context.ts CHANGED
@@ -20,6 +20,33 @@ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
20
20
  export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
21
21
  const SECTION_SEPARATOR = "\n\n";
22
22
 
23
+ export function getCursorToolTailGuardText(): string {
24
+ return "Tool boundary reminder: If a tool is needed, call an available Cursor SDK/MCP tool. Never print a tool card (for example Tool call/Shell/command) as assistant text.";
25
+ }
26
+
27
+ function getCursorToolBoundaryText(): string {
28
+ return [
29
+ "Cursor SDK tool boundary:",
30
+ "You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
31
+ getCursorPiBridgeContractText(),
32
+ "If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
33
+ "Use pi__cursor_ask_question for material choices if exposed.",
34
+ "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
35
+ "Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
36
+ "Images: only latest user images are sent; ask to reattach or describe prior images.",
37
+ ].join("\n");
38
+ }
39
+
40
+ function getCursorBootstrapTailSections(): string[] {
41
+ return [
42
+ [
43
+ "Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
44
+ "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
45
+ ].join("\n"),
46
+ getCursorToolTailGuardText(),
47
+ ];
48
+ }
49
+
23
50
  function normalizePiContextMessages(messages: Context["messages"]): Message[] {
24
51
  return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
25
52
  }
@@ -281,7 +308,7 @@ export function computeCursorContextFingerprint(context: Context): string {
281
308
  return JSON.stringify(payload);
282
309
  }
283
310
 
284
- export function shouldBootstrapCursorSend(
311
+ export function shouldBootstrapCursorContext(
285
312
  sendState: { bootstrapped: boolean; contextFingerprint: string },
286
313
  context: Context,
287
314
  ): boolean {
@@ -304,6 +331,14 @@ export function shouldBootstrapCursorSend(
304
331
  return false;
305
332
  }
306
333
 
334
+ /** @deprecated Use planCursorSessionSend() for send mode and shouldBootstrapCursorContext() for context-only checks. */
335
+ export function shouldBootstrapCursorSend(
336
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
337
+ context: Context,
338
+ ): boolean {
339
+ return shouldBootstrapCursorContext(sendState, context);
340
+ }
341
+
307
342
  export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
308
343
  // Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
309
344
  const messages = normalizePiContextMessages(context.messages);
@@ -324,35 +359,18 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
324
359
  options.maxInputTokens === undefined
325
360
  ? options
326
361
  : { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
327
- const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
362
+ const parts = applyPromptBudget(
363
+ sectionsBeforeMessages,
364
+ latestUserMessageSections,
365
+ [getCursorToolTailGuardText()],
366
+ latestUserMessageIndex,
367
+ budgetOptions,
368
+ );
328
369
  return { text: parts.join(SECTION_SEPARATOR), images };
329
370
  }
330
371
 
331
- export function buildCursorSendPrompt(
332
- context: Context,
333
- options: CursorPromptOptions,
334
- sendState: { bootstrapped: boolean; contextFingerprint: string },
335
- ): { prompt: CursorPrompt; bootstrap: boolean } {
336
- const bootstrap = shouldBootstrapCursorSend(sendState, context);
337
- if (bootstrap) {
338
- return { prompt: buildCursorPrompt(context, options), bootstrap: true };
339
- }
340
- return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
341
- }
342
-
343
372
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
344
- const sectionsBeforeMessages: string[] = [
345
- [
346
- "Cursor SDK tool boundary:",
347
- "You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
348
- getCursorPiBridgeContractText(),
349
- "If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
350
- "Use pi__cursor_ask_question for material choices if exposed.",
351
- "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
352
- "Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
353
- "Images: only latest user images are sent; ask to reattach or describe prior images.",
354
- ].join("\n"),
355
- ];
373
+ const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText()];
356
374
 
357
375
  if (context.systemPrompt) {
358
376
  sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
@@ -365,12 +383,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
365
383
  return text ? { index, text } : undefined;
366
384
  })
367
385
  .filter((section): section is { index: number; text: string } => section !== undefined);
368
- const sectionsAfterMessages = [
369
- [
370
- "Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
371
- "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
372
- ].join("\n"),
373
- ];
386
+ const sectionsAfterMessages = getCursorBootstrapTailSections();
374
387
  const images = extractLatestImages(messages);
375
388
  const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
376
389
  const budgetOptions =
@@ -0,0 +1,172 @@
1
+ import { Agent, type AgentMessage } from "@cursor/sdk";
2
+ import { asRecord, getArray, getString, stringifyUnknown } from "./cursor-transcript-utils.js";
3
+
4
+ const CURSOR_AGENT_MESSAGE_PAGE_LIMIT = 8;
5
+
6
+ export interface CursorTranscriptCompletedToolCall {
7
+ identity: string;
8
+ toolCall: unknown;
9
+ }
10
+
11
+ interface CursorTranscriptWebToolPayload {
12
+ kind: "webSearch" | "webFetch";
13
+ payload: unknown;
14
+ }
15
+
16
+ function getOneofCaseValue(value: unknown, caseName: string): unknown {
17
+ const record = asRecord(value);
18
+ if (!record) return undefined;
19
+ if (record.case === caseName) return record.value;
20
+ return record[caseName];
21
+ }
22
+
23
+ async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number): Promise<boolean> {
24
+ const messages = await Agent.messages.list(agentId, { runtime: "local", cwd, limit: 1, offset });
25
+ return messages.length > 0;
26
+ }
27
+
28
+ export async function countCursorAgentMessages(agentId: string, cwd: string): Promise<number> {
29
+ let high = 1;
30
+ while (await hasCursorAgentMessageAt(agentId, cwd, high)) {
31
+ high *= 2;
32
+ }
33
+
34
+ let low = 0;
35
+ while (low < high) {
36
+ const mid = Math.floor((low + high) / 2);
37
+ if (await hasCursorAgentMessageAt(agentId, cwd, mid)) low = mid + 1;
38
+ else high = mid;
39
+ }
40
+ return low;
41
+ }
42
+
43
+ export async function loadCursorTranscriptWebToolCallsAfterOffset(options: {
44
+ agentId: string;
45
+ cwd: string;
46
+ offset: number | undefined;
47
+ }): Promise<CursorTranscriptCompletedToolCall[]> {
48
+ if (options.offset === undefined) return [];
49
+ const messages = await Agent.messages.list(options.agentId, {
50
+ runtime: "local",
51
+ cwd: options.cwd,
52
+ limit: CURSOR_AGENT_MESSAGE_PAGE_LIMIT,
53
+ offset: options.offset,
54
+ });
55
+ return collectCursorTranscriptWebToolCalls(messages);
56
+ }
57
+
58
+ export function collectCursorTranscriptWebToolCalls(messages: readonly AgentMessage[]): CursorTranscriptCompletedToolCall[] {
59
+ const toolCalls: CursorTranscriptCompletedToolCall[] = [];
60
+ for (const [messageIndex, message] of messages.entries()) {
61
+ const messageId = message.uuid || `${message.agent_id || "cursor-agent"}:${messageIndex}`;
62
+ const steps = getAgentConversationSteps(message.message);
63
+ for (const [stepIndex, step] of steps.entries()) {
64
+ const webTool = getStepWebToolPayload(step);
65
+ if (!webTool) continue;
66
+ const converted = convertCursorTranscriptWebTool(webTool);
67
+ if (!converted) continue;
68
+ const args = asRecord(converted.args);
69
+ const toolCallId = getString(args, "toolCallId") ?? getString(args, "tool_call_id") ?? `${stepIndex}`;
70
+ toolCalls.push({
71
+ identity: `cursor-transcript:${messageId}:${webTool.kind}:${toolCallId}`,
72
+ toolCall: converted,
73
+ });
74
+ }
75
+ }
76
+ return toolCalls;
77
+ }
78
+
79
+ function getAgentConversationSteps(message: unknown): unknown[] {
80
+ const record = asRecord(message);
81
+ const turn = getOneofCaseValue(record?.turn, "agentConversationTurn") ?? record?.agentConversationTurn;
82
+ return getArray(asRecord(turn), "steps") ?? [];
83
+ }
84
+
85
+ function getStepToolCall(step: unknown): unknown {
86
+ const stepRecord = asRecord(step);
87
+ const message = asRecord(stepRecord?.message);
88
+ return getOneofCaseValue(message, "toolCall") ?? stepRecord?.toolCall ?? message?.toolCall;
89
+ }
90
+
91
+ function getStepWebToolPayload(step: unknown): CursorTranscriptWebToolPayload | undefined {
92
+ const toolCall = getStepToolCall(step);
93
+ const toolCallRecord = asRecord(toolCall);
94
+ const tool = toolCallRecord?.tool;
95
+ const webSearchPayload =
96
+ getOneofCaseValue(tool, "webSearchToolCall") ??
97
+ getOneofCaseValue(toolCall, "webSearchToolCall") ??
98
+ toolCallRecord?.webSearchToolCall;
99
+ if (webSearchPayload) return { kind: "webSearch", payload: webSearchPayload };
100
+
101
+ const webFetchPayload =
102
+ getOneofCaseValue(tool, "webFetchToolCall") ??
103
+ getOneofCaseValue(toolCall, "webFetchToolCall") ??
104
+ toolCallRecord?.webFetchToolCall;
105
+ if (webFetchPayload) return { kind: "webFetch", payload: webFetchPayload };
106
+ return undefined;
107
+ }
108
+
109
+ function convertCursorTranscriptWebTool(webTool: CursorTranscriptWebToolPayload): { name: string; args: Record<string, unknown>; result: unknown } | undefined {
110
+ const payload = asRecord(webTool.payload);
111
+ if (!payload) return undefined;
112
+ const rawArgs = asRecord(payload.args) ?? {};
113
+ const args = normalizeWebToolArgs(webTool.kind, rawArgs);
114
+ const result = normalizeWebToolResult(payload.result);
115
+ if (!result) return undefined;
116
+ return {
117
+ name: webTool.kind,
118
+ args,
119
+ result,
120
+ };
121
+ }
122
+
123
+ function normalizeWebToolArgs(kind: "webSearch" | "webFetch", rawArgs: Record<string, unknown>): Record<string, unknown> {
124
+ const args = { ...rawArgs };
125
+ if (kind === "webSearch") {
126
+ const query = getString(args, "searchTerm") ?? getString(args, "search_term") ?? getString(args, "query") ?? getString(args, "q");
127
+ if (query && !args.searchTerm) args.searchTerm = query;
128
+ return args;
129
+ }
130
+ const url = getString(args, "url") ?? getString(args, "uri") ?? getString(args, "href");
131
+ if (url && !args.url) args.url = url;
132
+ return args;
133
+ }
134
+
135
+ function normalizeWebToolResult(result: unknown): unknown | undefined {
136
+ if (result === undefined) return undefined;
137
+ const success = getTranscriptResultCase(result, "success");
138
+ if (success !== undefined) {
139
+ return {
140
+ status: "success",
141
+ value: { content: [{ type: "text", text: transcriptWebSuccessText(success) }] },
142
+ };
143
+ }
144
+
145
+ const error = getTranscriptResultCase(result, "error");
146
+ if (error !== undefined) return { status: "error", error };
147
+
148
+ return {
149
+ status: "success",
150
+ value: { content: [{ type: "text", text: transcriptWebSuccessText(result) }] },
151
+ };
152
+ }
153
+
154
+ function getTranscriptResultCase(result: unknown, caseName: "success" | "error"): unknown {
155
+ const record = asRecord(result);
156
+ return getOneofCaseValue(record?.result, caseName) ?? record?.[caseName];
157
+ }
158
+
159
+ function transcriptWebSuccessText(success: unknown): string {
160
+ const successRecord = asRecord(success);
161
+ const references = getArray(successRecord, "references");
162
+ const chunks = references
163
+ ?.map((reference) => getString(asRecord(reference), "chunk"))
164
+ .filter((chunk): chunk is string => Boolean(chunk?.trim()));
165
+ if (chunks && chunks.length > 0) return chunks.join("\n\n");
166
+ const content = getArray(successRecord, "content");
167
+ const text = content
168
+ ?.map((entry) => getString(asRecord(entry), "text"))
169
+ .filter((entry): entry is string => Boolean(entry?.trim()));
170
+ if (text && text.length > 0) return text.join("\n");
171
+ return stringifyUnknown(success).trim() || "Cursor web activity completed.";
172
+ }