Skip to content

Commit 932dd11

Browse files
authored
Fix tool_search bookkeeping when resuming from stateful marker (#314217)
Pre-scan the full message history before applying the previous_response_id slice, so tool_search_call ids and loaded tool names are remembered even when the assistant message that emitted the tool_search call carries the stateful marker (and is therefore dropped from the post-marker slice). Without this, the subsequent tool result was serialized as a plain function_call_output instead of tool_search_output, leaving deferred MCP tool definitions unloaded on the server and the model unable to invoke them. Fixes #313899
1 parent cc8cfa8 commit 932dd11

2 files changed

Lines changed: 86 additions & 5 deletions

File tree

extensions/copilot/src/platform/endpoint/node/responsesApi.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,32 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe
330330
markerIndex = undefined;
331331
}
332332

333+
const toolSearchCallIds = new Set<string>();
334+
const toolSearchLoadedTools = new Set<string>();
335+
// Only pre-scan when history will be sliced (matches the slicing block below);
336+
// otherwise the serialization loop visits each tool_search_call before its
337+
// result and populates these sets in order on its own.
338+
const willSliceHistory = markerIndex !== undefined || latestCompactionMessageIndex !== undefined;
339+
if (willSliceHistory) {
340+
for (const message of messages) {
341+
if (message.role === Raw.ChatRole.Assistant && message.toolCalls) {
342+
for (const toolCall of message.toolCalls) {
343+
if (toolCall.function.name === CUSTOM_TOOL_SEARCH_NAME) {
344+
toolSearchCallIds.add(toolCall.id);
345+
}
346+
}
347+
} else if (message.role === Raw.ChatRole.Tool && message.toolCallId && toolSearchCallIds.has(message.toolCallId) && toolsMap) {
348+
const resultText = message.content
349+
.filter(c => c.type === Raw.ChatCompletionContentPartKind.Text)
350+
.map(c => c.text)
351+
.join('');
352+
for (const t of buildToolSearchOutputTools(resultText, toolsMap, shouldLoadToolFromToolSearch)) {
353+
toolSearchLoadedTools.add(t.name);
354+
}
355+
}
356+
}
357+
}
358+
333359
if (markerIndex !== undefined) {
334360
// Requests that resume from previous_response_id send only post-marker history,
335361
// but they still need the latest compaction item even when that item predates
@@ -346,11 +372,6 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe
346372
messages = messages.slice(latestCompactionMessageIndex);
347373
}
348374

349-
// Track which call_ids are tool_search_calls (from client-executed tool search)
350-
const toolSearchCallIds = new Set<string>();
351-
// Track tool names loaded via tool_search_output — these need a namespace field on function_call
352-
const toolSearchLoadedTools = new Set<string>();
353-
354375
const input: OpenAI.Responses.ResponseInputItem[] = [];
355376
for (const message of messages) {
356377
switch (message.role) {

extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,66 @@ describe('createResponsesRequestBody tools', () => {
379379

380380
expect(toolSearchOutput?.tools?.map(t => t.name)).toEqual([]);
381381
});
382+
383+
it('still emits tool_search_output and namespaces deferred-tool calls when the stateful marker drops the tool_search_call from the post-marker slice (issue #313899)', () => {
384+
// Repro for https://github.com/microsoft/vscode/issues/313899: when the Responses API
385+
// resumes from a previous_response_id, the assistant message carrying the marker (and
386+
// the tool_search_call it emitted) is sliced out of the input. Without scanning the
387+
// full history first, the tool_search bookkeeping would be empty and the subsequent
388+
// tool result would be incorrectly serialized as `function_call_output` instead of
389+
// `tool_search_output`, leaving the deferred MCP tool definitions unloaded on the
390+
// server and the model unable to invoke the tool it just discovered.
391+
const modelId = 'gpt-5.4';
392+
const statefulMarker = 'marker-abc';
393+
const messages: Raw.ChatMessage[] = [
394+
{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Use the MCP tool' }] },
395+
{
396+
role: Raw.ChatRole.Assistant,
397+
// Marker lives on the same assistant turn that emitted the tool_search call.
398+
content: [{
399+
type: Raw.ChatCompletionContentPartKind.Opaque,
400+
value: { type: 'stateful_marker', value: { modelId, marker: statefulMarker } },
401+
}],
402+
toolCalls: [{ id: 'call_ts_resume', type: 'function', function: { name: 'tool_search', arguments: '{"query":"mcp"}' } }],
403+
},
404+
{
405+
role: Raw.ChatRole.Tool,
406+
toolCallId: 'call_ts_resume',
407+
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["some_mcp_tool"]' }],
408+
},
409+
{
410+
role: Raw.ChatRole.Assistant,
411+
content: [],
412+
toolCalls: [{ id: 'call_mcp_resume', type: 'function', function: { name: 'some_mcp_tool', arguments: '{"input":"x"}' } }],
413+
},
414+
];
415+
416+
const body = createToolSearchScenario(messages);
417+
418+
const input = body.input as Array<{ type?: string; name?: string; namespace?: string; call_id?: string; tools?: Array<{ name: string }> }>;
419+
420+
expect({
421+
previous_response_id: body.previous_response_id,
422+
// The tool result must round-trip as a tool_search_output (not function_call_output)
423+
toolSearchOutput: input.find(i => i.type === 'tool_search_output'),
424+
// Any function_call_output for the tool_search call_id would be the bug
425+
badFunctionCallOutput: input.find((i: any) => i.type === 'function_call_output' && i.call_id === 'call_ts_resume'),
426+
// The follow-up MCP tool call must carry the namespace so the server can match
427+
// it against the deferred tool loaded via tool_search_output.
428+
mcpToolNamespace: input.find(i => i.type === 'function_call' && i.name === 'some_mcp_tool')?.namespace,
429+
}).toEqual({
430+
previous_response_id: statefulMarker,
431+
toolSearchOutput: {
432+
type: 'tool_search_output',
433+
execution: 'client',
434+
call_id: 'call_ts_resume',
435+
status: 'completed',
436+
tools: [expect.objectContaining({ name: 'some_mcp_tool', defer_loading: true })],
437+
},
438+
badFunctionCallOutput: undefined,
439+
mcpToolNamespace: 'some_mcp_tool',
440+
});
441+
});
382442
});
383443

384444
describe('OpenAIResponsesProcessor tool search events', () => {

0 commit comments

Comments
 (0)