feat(ai): added MCP server discovery properties and refactored tool call handling

- Added mcpServerName and mcpToolName optional properties to tool definitions for MCP server discovery and tool name tracking.
- Changed aborted tool call handling to preserve existing tool results instead of replacing with synthetic 'aborted' results at turn boundaries.
- Extracted flushPendingToolCalls() and flushPendingAbortedToolCalls() helpers to consolidate orphaned tool call handling logic.
- Updated tool result handling to use message timestamps instead of Date.now() for synthetic results consistency.
This commit is contained in:
can1357
2026-03-18 23:12:22 +01:00
parent 51fb0ade87
commit 09b0bad87d
7 changed files with 88 additions and 69 deletions
+3
View File
@@ -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
+67 -63
View File
@@ -124,101 +124,105 @@ export function transformMessages<TApi extends Api>(
});
// 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<string, ToolCall>();
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<string, ToolCallStatus>();
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;
}
@@ -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);
});
});
+3
View File
@@ -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
@@ -184,6 +184,10 @@ export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
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
@@ -300,6 +300,10 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
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. */
execute(
toolCallId: string,
+4
View File
@@ -422,6 +422,10 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
label: tool.label,
description: tool.description,
parameters: tool.parameters,
hidden: tool.hidden,
deferrable: tool.deferrable,
mcpServerName: tool.mcpServerName,
mcpToolName: tool.mcpToolName,
execute: (toolCallId, params, signal, onUpdate, ctx) =>
tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,