diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f69017c66..16b9c2577 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Modified tool result handling for aborted assistant messages to preserve existing tool results when already recorded, instead of always replacing them with synthetic 'aborted' results ## [13.13.0] - 2026-03-18 ### Changed diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 45c4f43a3..609ae88c3 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -124,101 +124,105 @@ export function transformMessages( }); // Second pass: insert synthetic empty tool results for orphaned tool calls - // This preserves thinking signatures and satisfies API requirements + // and preserve aborted/errored tool results when they were already persisted. const result: Message[] = []; let pendingToolCalls: ToolCall[] = []; - // Track tool call status: whether resolved (has result) or aborted (skip real results) + let pendingAbortedToolCalls = new Map(); + let pendingAbortedTimestamp: number | undefined; + // Track tool call status: whether resolved (has result) or aborted (synthetic result injected, skip later real results) const toolCallStatus = new Map(); + const flushPendingToolCalls = (timestamp: number): void => { + if (pendingToolCalls.length === 0) return; + for (const tc of pendingToolCalls) { + if (!toolCallStatus.has(tc.id)) { + result.push({ + role: "toolResult", + toolCallId: tc.id, + toolName: tc.name, + content: [{ type: "text", text: "No result provided" }], + isError: true, + timestamp, + } as ToolResultMessage); + toolCallStatus.set(tc.id, ToolCallStatus.Resolved); + } + } + pendingToolCalls = []; + }; + + const flushPendingAbortedToolCalls = (): void => { + if (pendingAbortedTimestamp === undefined) return; + for (const tc of pendingAbortedToolCalls.values()) { + if (!toolCallStatus.has(tc.id)) { + result.push({ + role: "toolResult", + toolCallId: tc.id, + toolName: tc.name, + content: [{ type: "text", text: "aborted" }], + isError: true, + timestamp: pendingAbortedTimestamp, + } as ToolResultMessage); + toolCallStatus.set(tc.id, ToolCallStatus.Aborted); + } + } + result.push({ + role: "developer", + content: turnAbortedGuidance, + timestamp: pendingAbortedTimestamp + 1, + } as DeveloperMessage); + pendingAbortedToolCalls = new Map(); + pendingAbortedTimestamp = undefined; + }; + for (let i = 0; i < transformed.length; i++) { const msg = transformed[i]; + const messageTimestamp = "timestamp" in msg && typeof msg.timestamp === "number" ? msg.timestamp : Date.now(); if (msg.role === "assistant") { - // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now - if (pendingToolCalls.length > 0) { - for (const tc of pendingToolCalls) { - if (!toolCallStatus.has(tc.id)) { - result.push({ - role: "toolResult", - toolCallId: tc.id, - toolName: tc.name, - content: [{ type: "text", text: "No result provided" }], - isError: true, - timestamp: Date.now(), - } as ToolResultMessage); - toolCallStatus.set(tc.id, ToolCallStatus.Resolved); - } - } - pendingToolCalls = []; - } + flushPendingToolCalls(messageTimestamp); + flushPendingAbortedToolCalls(); - // For errored/aborted assistant messages: keep tool calls intact, - // inject synthetic "aborted" results, and add guidance marker. - // This preserves structure so the model knows what was attempted. const assistantMsg = msg as AssistantMessage; const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[]; if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { - // Push the assistant message with tool calls intact + // Keep the assistant message with tool calls intact. If real tool results follow, preserve them; + // otherwise synthesize aborted results before the next turn boundary. result.push(msg); - - // Inject synthetic "aborted" results for each tool call - for (const tc of toolCalls) { - toolCallStatus.set(tc.id, ToolCallStatus.Aborted); - result.push({ - role: "toolResult", - toolCallId: tc.id, - toolName: tc.name, - content: [{ type: "text", text: "aborted" }], - isError: true, - timestamp: assistantMsg.timestamp, - } as ToolResultMessage); - } - - // Inject turn-aborted guidance marker as developer message - result.push({ - role: "developer", - content: turnAbortedGuidance, - timestamp: assistantMsg.timestamp + 1, - } as DeveloperMessage); - + pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const)); + pendingAbortedTimestamp = assistantMsg.timestamp; continue; } - // Track tool calls from this normal assistant message if (toolCalls.length > 0) { pendingToolCalls = toolCalls; } result.push(msg); } else if (msg.role === "toolResult") { - // Skip tool results for aborted tool calls (we already injected synthetic ones) + if (pendingAbortedToolCalls.has(msg.toolCallId)) { + pendingAbortedToolCalls.delete(msg.toolCallId); + toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved); + result.push(msg); + continue; + } + if (toolCallStatus.get(msg.toolCallId) === ToolCallStatus.Aborted) continue; toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved); result.push(msg); } else if (msg.role === "user" || msg.role === "developer") { - // User/developer message interrupts tool flow - insert synthetic results for orphaned calls - if (pendingToolCalls.length > 0) { - for (const tc of pendingToolCalls) { - if (!toolCallStatus.has(tc.id)) { - result.push({ - role: "toolResult", - toolCallId: tc.id, - toolName: tc.name, - content: [{ type: "text", text: "No result provided" }], - isError: true, - timestamp: Date.now(), - } as ToolResultMessage); - toolCallStatus.set(tc.id, ToolCallStatus.Resolved); - } - } - pendingToolCalls = []; - } + flushPendingToolCalls(messageTimestamp); + flushPendingAbortedToolCalls(); result.push(msg); } else { + flushPendingToolCalls(messageTimestamp); + flushPendingAbortedToolCalls(); result.push(msg); } } + flushPendingToolCalls(Date.now()); + flushPendingAbortedToolCalls(); + return result; } diff --git a/packages/ai/test/duplicate-tool-results.test.ts b/packages/ai/test/duplicate-tool-results.test.ts index 86da9f105..fcb9af4a9 100644 --- a/packages/ai/test/duplicate-tool-results.test.ts +++ b/packages/ai/test/duplicate-tool-results.test.ts @@ -386,7 +386,7 @@ describe("Codex-style Abort Handling", () => { expect(toolResult.content).toEqual([{ type: "text", text: "aborted" }]); }); - it("should skip existing tool results and use synthetic ones for aborted messages", () => { + it("should preserve existing tool results for aborted messages when they were already recorded", () => { const toolCallId = "toolu_skip_existing"; const assistantMessage: AssistantMessage = { @@ -407,7 +407,6 @@ describe("Codex-style Abort Handling", () => { timestamp: Date.now(), }; - // Existing result with different content (e.g., partial execution) const existingToolResult: ToolResultMessage = { role: "toolResult", toolCallId: toolCallId, @@ -425,14 +424,12 @@ describe("Codex-style Abort Handling", () => { const transformed = transformMessages(messages, model); - // Should have exactly one tool result with "aborted" content const toolResults = transformed.filter( m => m.role === "toolResult" && (m as ToolResultMessage).toolCallId === toolCallId, ) as ToolResultMessage[]; expect(toolResults.length).toBe(1); - // The synthetic one should win, not the existing one - expect(toolResults[0].content).toEqual([{ type: "text", text: "aborted" }]); - expect(toolResults[0].isError).toBe(true); + expect(toolResults[0].content).toEqual([{ type: "text", text: "Partial file content..." }]); + expect(toolResults[0].isError).toBe(false); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 67f507c42..333b4f9ba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added `mcpServerName` and `mcpToolName` optional properties to custom tools for MCP server discovery and search metadata ## [13.13.1] - 2026-03-18 ### Added diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index f776c5747..60f34d684 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -184,6 +184,10 @@ export interface CustomTool { hidden?: boolean; /** If true, tool may stage deferred changes that require explicit resolve/discard. */ deferrable?: boolean; + /** MCP server name for discovery/search metadata when this tool fronts an MCP server. */ + mcpServerName?: string; + /** Original MCP tool name for discovery/search metadata. */ + mcpToolName?: string; /** * Execute the tool. * @param toolCallId - Unique ID for this tool call diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index e0cf825a0..418c0c37f 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -300,6 +300,10 @@ export interface ToolDefinition tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal), onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,