From 90ddc18537f787f37bd8daae56199958b622b6dd Mon Sep 17 00:00:00 2001 From: Dileep Yavanamandha Date: Fri, 17 Jul 2026 14:19:54 -0700 Subject: [PATCH 1/3] Preserve Responses API output item order --- .../extension/intents/node/toolCallingLoop.ts | 7 ++ .../src/extension/prompt/common/intents.ts | 2 + .../extension/prompt/common/toolCallRound.ts | 6 + .../node/panel/test/toolCalling.spec.ts | 53 +++++++++ .../prompts/node/panel/toolCalling.tsx | 9 +- .../platform/endpoint/node/responsesApi.ts | 110 ++++++++++-------- .../endpoint/node/test/responsesApi.spec.ts | 47 ++++++++ .../src/platform/networking/common/fetch.ts | 1 + .../src/platform/thinking/common/thinking.ts | 5 + 9 files changed, 189 insertions(+), 51 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index df1aa03e685aaa..d148aef7dc772e 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -1576,6 +1576,7 @@ export abstract class ToolCallingLoop): void { diff --git a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts index 658aa81b5ee2e2..2533c7cc201bb7 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Raw } from '@vscode/prompt-tsx'; import { describe, expect, test } from 'vitest'; import type * as vscode from 'vscode'; import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; +import { rawPartAsThinkingData } from '../../../../../platform/endpoint/common/thinkingDataContainer'; import type { IChatEndpoint } from '../../../../../platform/networking/common/networking'; import { DeferredPromise } from '../../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; @@ -222,6 +224,57 @@ class ParallelAwareToolsService implements IToolsService { } describe('ChatToolCalls (toolCalling.tsx)', () => { + test('preserves commentary before reasoning from Responses API output order', async () => { + const testingServiceCollection = createExtensionUnitTestingServices(); + const accessor = testingServiceCollection.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const endpointProvider = accessor.get(IEndpointProvider); + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + const round: IToolCallRound = { + id: 'round-1', + modelId: endpoint.model, + response: 'commentary', + responseOutputIndex: 0, + thinking: { + id: 'rs_after_commentary', + text: '', + encrypted: 'enc_reasoning', + outputIndex: 1, + }, + toolInputRetry: 0, + toolCalls: [], + }; + const promptContext: IBuildPromptContext = { + query: 'test', + history: [], + chatVariables: new ChatVariablesCollection(), + conversation: { sessionId: 'session-order' } as unknown as Conversation, + request: {} as vscode.ChatRequest, + tools: { + toolReferences: [], + toolInvocationToken: {} as vscode.ChatParticipantToolToken, + availableTools: [], + }, + }; + + const { messages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, { + promptContext, + toolCallRounds: [round], + toolCallResults: undefined, + }); + const assistantMessage = messages.find(message => message.role === Raw.ChatRole.Assistant); + + expect(assistantMessage?.content.map(part => { + if (part.type === Raw.ChatCompletionContentPartKind.Text) { + return 'message'; + } + if (part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsThinkingData(part)) { + return 'reasoning'; + } + return 'other'; + })).toEqual(['message', 'reasoning']); + }); + test('starts multiple sub-agent tool calls in parallel', async () => { const toolName = ToolName.CoreRunSubagent; const firstCallId = 'subagent-call-1'; diff --git a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx index e0c6b6fc5239b9..02d7ac8c7672aa 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -139,13 +139,18 @@ export class ChatToolCalls extends PromptElement { const thinking = includeThinking && round.thinking && ; const phase = (round.phase && roundModelId === this.promptEndpoint.model) ? : undefined; const compaction = round.compaction && ; + const responsePrecedesThinking = round.responseOutputIndex !== undefined + && round.thinking?.outputIndex !== undefined + && round.responseOutputIndex < round.thinking.outputIndex; children.push( {statefulMarker} + {responsePrecedesThinking ? phase : undefined} + {responsePrecedesThinking ? round.response : undefined} {thinking} - {phase} + {responsePrecedesThinking ? undefined : phase} {compaction} - {round.response} + {responsePrecedesThinking ? undefined : round.response} ); // Tool call elements should be rendered with the later elements first, allowed to grow to fill the available space diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 428bddabaa6a88..fa7db08a19c3a5 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -379,20 +379,7 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe switch (message.role) { case Raw.ChatRole.Assistant: if (message.content.length) { - input.push(...extractCompactionData(message.content)); - input.push(...extractThinkingData(message.content)); - const asstContent = message.content.map(rawContentToResponsesAssistantContent).filter(isDefined); - if (asstContent.length) { - const assistantMessage: ResponseInputAssistantMessageWithPhase = { - role: 'assistant', - content: asstContent, - type: 'message', - phase: extractPhaseData(message.content), - }; - // The Responses API expects previous assistant message content as output_text/refusal, - // but the SDK's ResponseOutputMessage type requires response-only id/status fields. - input.push(assistantMessage as OpenAI.Responses.ResponseInputItem); - } + input.push(...extractAssistantResponseItems(message.content)); } if (message.toolCalls) { for (const toolCall of message.toolCalls) { @@ -642,22 +629,64 @@ function isResponsesReasoningId(id: string | undefined): boolean { return typeof id === 'string' && id.startsWith('rs'); } -function extractThinkingData(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseReasoningItem[] { - return coalesce(content.map(part => { - if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { - const thinkingData = rawPartAsThinkingData(part); - // Only round-trip genuine Responses API reasoning items. A foreign id (or a thinking - // block with no encrypted payload) would otherwise 400 the whole request. - if (thinkingData && thinkingData.encrypted && isResponsesReasoningId(thinkingData.id)) { - return { - type: 'reasoning', - id: thinkingData.id, - summary: [], - encrypted_content: thinkingData.encrypted, - } satisfies OpenAI.Responses.ResponseReasoningItem; - } +function extractAssistantResponseItems(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseInputItem[] { + const input: OpenAI.Responses.ResponseInputItem[] = []; + const phase = extractPhaseData(content); + let assistantContent: ResponseInputAssistantTextContentPart[] = []; + + const flushAssistantContent = () => { + if (!assistantContent.length) { + return; } - })); + const assistantMessage: ResponseInputAssistantMessageWithPhase = { + role: 'assistant', + content: assistantContent, + type: 'message', + phase, + }; + // The Responses API expects previous assistant message content as output_text/refusal, + // but the SDK's ResponseOutputMessage type requires response-only id/status fields. + input.push(assistantMessage as OpenAI.Responses.ResponseInputItem); + assistantContent = []; + }; + + for (const part of content) { + const assistantPart = rawContentToResponsesAssistantContent(part); + if (assistantPart) { + assistantContent.push(assistantPart); + continue; + } + if (part.type !== Raw.ChatCompletionContentPartKind.Opaque) { + continue; + } + + const compaction = rawPartAsCompactionData(part); + if (compaction) { + flushAssistantContent(); + input.push({ + type: openAIContextManagementCompactionType, + id: compaction.id, + encrypted_content: compaction.encrypted_content, + } as unknown as OpenAI.Responses.ResponseInputItem); + continue; + } + + const thinkingData = rawPartAsThinkingData(part); + // Only round-trip genuine Responses API reasoning items. A foreign id (or a thinking + // block with no encrypted payload) would otherwise 400 the whole request. + if (thinkingData?.encrypted && isResponsesReasoningId(thinkingData.id)) { + flushAssistantContent(); + input.push({ + type: 'reasoning', + id: thinkingData.id, + summary: [], + encrypted_content: thinkingData.encrypted, + } satisfies OpenAI.Responses.ResponseReasoningItem); + } + } + + flushAssistantContent(); + return input; } function extractPhaseData(content: Raw.ChatCompletionContentPart[]): string | undefined { @@ -672,25 +701,6 @@ function extractPhaseData(content: Raw.ChatCompletionContentPart[]): string | un return undefined; } -/** - * Extracts compaction data from opaque content parts and converts them to - * Responses API input items for round-tripping. - */ -function extractCompactionData(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseInputItem[] { - return coalesce(content.map(part => { - if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { - const compaction = rawPartAsCompactionData(part); - if (compaction) { - return { - type: openAIContextManagementCompactionType, - id: compaction.id, - encrypted_content: compaction.encrypted_content, - } as unknown as OpenAI.Responses.ResponseInputItem; - } - } - })); -} - /** * This is an approximate responses input -> raw messages helper, should be used for logging only */ @@ -1273,12 +1283,14 @@ export class OpenAIResponsesProcessor { undefined : chunk.item.summary.map(s => s.text), encrypted: chunk.item.encrypted_content, + outputIndex: chunk.output_index, } : undefined }); } else if (chunk.item.type === 'message') { onProgress({ text: '', - phase: (chunk.item as ResponseOutputItemWithPhase).phase + phase: (chunk.item as ResponseOutputItemWithPhase).phase, + responseOutputIndex: chunk.output_index, }); } return; diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index bf2d09b24c606f..3e305e89b5daa8 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -392,6 +392,48 @@ describe('createResponsesRequestBody', () => { services.dispose(); }); + it('preserves commentary before reasoning in stateless request history', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.Assistant, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'commentary' }, + { + type: Raw.ChatCompletionContentPartKind.Opaque, + value: { + type: CustomDataPartMimeTypes.ThinkingData, + thinking: { id: 'rs_after_commentary', text: '', encrypted: 'enc_reasoning' }, + }, + }, + ], + toolCalls: [{ + id: 'call_after_reasoning', + type: 'function', + function: { name: 'grep_search', arguments: '{}' }, + }], + }]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint)); + + expect(body.input?.map(item => item.type)).toEqual(['message', 'reasoning', 'function_call']); + expect(body.input?.[0]).toMatchObject({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'commentary' }], + }); + expect(body.input?.[1]).toEqual({ + type: 'reasoning', + id: 'rs_after_commentary', + summary: [], + encrypted_content: 'enc_reasoning', + }); + + accessor.dispose(); + services.dispose(); + }); + it('drops foreign thinking (Messages API "thinking_N" id) so it cannot 400 the Responses request', () => { // Reproduces "400 invalid_request_body: Invalid 'input[N].id': 'thinking_0'. Expected an // ID that begins with 'rs'." Anthropic Messages-API thinking leaks into a Responses @@ -1726,6 +1768,7 @@ describe('phase commentary followed by phase final_answer', () => { const telemetryService = new SpyingTelemetryService(); const accumulatedTexts: string[] = []; const phases: string[] = []; + const responseOutputIndices: number[] = []; const commentaryText = 'Responding directly in commentary as requested. My name is GitHub Copilot.'; const finalText = 'My name is GitHub Copilot.'; @@ -1799,6 +1842,9 @@ describe('phase commentary followed by phase final_answer', () => { if (delta.phase) { phases.push(delta.phase); } + if (delta.responseOutputIndex !== undefined) { + responseOutputIndices.push(delta.responseOutputIndex); + } return undefined; }, telemetryData, @@ -1809,6 +1855,7 @@ describe('phase commentary followed by phase final_answer', () => { } expect(phases).toEqual(['commentary', 'final_answer']); + expect(responseOutputIndices).toEqual([0, 1]); // The accumulated text must separate commentary and final_answer text const finalAccumulatedText = accumulatedTexts[accumulatedTexts.length - 1]; diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index 8d9a8a2c4622a9..dd57b46f7da41d 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -146,6 +146,7 @@ export interface IResponseDelta { copilotConfirmation?: ICopilotConfirmation; thinking?: ThinkingDelta | EncryptedThinkingDelta; phase?: string; + responseOutputIndex?: number; retryReason?: FilterReason | 'network_error' | 'server_error'; /** Marker for the current response, which should be presented in `IMakeChatRequestOptions` on the next call */ statefulMarker?: string; diff --git a/extensions/copilot/src/platform/thinking/common/thinking.ts b/extensions/copilot/src/platform/thinking/common/thinking.ts index 9b40805cbfe692..9953d783f1df22 100644 --- a/extensions/copilot/src/platform/thinking/common/thinking.ts +++ b/extensions/copilot/src/platform/thinking/common/thinking.ts @@ -41,21 +41,25 @@ export type ThinkingDelta = { text?: string | string[]; id: string; metadata?: { readonly [key: string]: any }; + outputIndex?: number; } | { text?: string | string[]; id?: string; metadata: { readonly [key: string]: any }; + outputIndex?: number; } | { text: string | string[]; id?: string; metadata?: { readonly [key: string]: any }; + outputIndex?: number; }; export type EncryptedThinkingDelta = { id: string; text?: string; encrypted: string; + outputIndex?: number; /** * True only for genuine Anthropic `redacted_thinking` blocks, where `encrypted` * holds the opaque `data` blob. For regular thinking blocks `encrypted` holds the @@ -75,6 +79,7 @@ export interface ThinkingData { metadata?: { [key: string]: any }; tokens?: number; encrypted?: string; + outputIndex?: number; /** * True only for genuine Anthropic `redacted_thinking` blocks, where `encrypted` * holds the opaque `data` blob. For regular thinking blocks `encrypted` holds the From 6ac8a16200a8f0a2d1948271f072b1350861af5b Mon Sep 17 00:00:00 2001 From: Dileep Yavanamandha Date: Fri, 17 Jul 2026 14:59:01 -0700 Subject: [PATCH 2/3] Update tool call round snapshots --- .../defaultIntentRequestHandler.spec.ts.snap | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/extensions/copilot/src/extension/prompt/node/test/__snapshots__/defaultIntentRequestHandler.spec.ts.snap b/extensions/copilot/src/extension/prompt/node/test/__snapshots__/defaultIntentRequestHandler.spec.ts.snap index a46e210476abe5..cff7e7fe52a7d7 100644 --- a/extensions/copilot/src/extension/prompt/node/test/__snapshots__/defaultIntentRequestHandler.spec.ts.snap +++ b/extensions/copilot/src/extension/prompt/node/test/__snapshots__/defaultIntentRequestHandler.spec.ts.snap @@ -29,6 +29,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 0", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -49,6 +50,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 1", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -69,6 +71,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 2", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -110,6 +113,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 3", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -130,6 +134,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 4", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -150,6 +155,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 5", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -234,6 +240,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 0", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -254,6 +261,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 1", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -274,6 +282,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 2", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -294,6 +303,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 3", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -358,6 +368,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 4", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -378,6 +389,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 5", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -398,6 +410,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 6", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -418,6 +431,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 7", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2571,6 +2585,7 @@ exports[`defaultIntentRequestHandler > makes a successful request with a single "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "some response here :)", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2907,6 +2922,7 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "some response here :)", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2927,6 +2943,7 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response to tool call", + "responseOutputIndex": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, From 9b5afe9bb20b848c798e75be1734652d56d02ebc Mon Sep 17 00:00:00 2001 From: Dileep Yavanamandha Date: Fri, 17 Jul 2026 17:50:15 -0700 Subject: [PATCH 3/3] changes --- .../extension/intents/node/toolCallingLoop.ts | 5 ++ .../src/extension/prompt/common/intents.ts | 3 ++ .../extension/prompt/common/toolCallRound.ts | 2 + .../defaultIntentRequestHandler.spec.ts.snap | 17 +++++++ .../node/panel/test/toolCalling.spec.ts | 37 +++++++++++--- .../prompts/node/panel/toolCalling.tsx | 30 +++++++---- .../platform/endpoint/node/responsesApi.ts | 50 ++++++++++--------- .../endpoint/node/test/responsesApi.spec.ts | 20 ++++++-- .../src/platform/networking/common/fetch.ts | 8 +++ .../src/platform/networking/common/openai.ts | 1 + 10 files changed, 132 insertions(+), 41 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index d148aef7dc772e..a9285eba71b70c 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -1577,6 +1577,7 @@ export abstract class ToolCallingLoop = []; let compaction: OpenAIContextManagementResponse | undefined; markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillFetch); const fetchOptions: ToolCallingLoopFetchOptions = { @@ -1607,6 +1608,9 @@ export abstract class ToolCallingLoop ChatResult metadata after multiple turns "phase": undefined, "response": "response number 0", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -51,6 +52,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "phase": undefined, "response": "response number 1", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -72,6 +74,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "phase": undefined, "response": "response number 2", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -114,6 +117,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "phase": undefined, "response": "response number 3", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -135,6 +139,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "phase": undefined, "response": "response number 4", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -156,6 +161,7 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "phase": undefined, "response": "response number 5", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -241,6 +247,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 0", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -262,6 +269,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 1", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -283,6 +291,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 2", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -304,6 +313,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 3", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -369,6 +379,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 4", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -390,6 +401,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 5", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -411,6 +423,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 6", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -432,6 +445,7 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "phase": undefined, "response": "response number 7", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2586,6 +2600,7 @@ exports[`defaultIntentRequestHandler > makes a successful request with a single "phase": undefined, "response": "some response here :)", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2923,6 +2938,7 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "phase": undefined, "response": "some response here :)", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2944,6 +2960,7 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "phase": undefined, "response": "response to tool call", "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, diff --git a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts index 2533c7cc201bb7..4300186a87dd54 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts @@ -8,16 +8,18 @@ import { describe, expect, test } from 'vitest'; import type * as vscode from 'vscode'; import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; +import { rawPartAsCompactionData } from '../../../../../platform/endpoint/common/compactionDataContainer'; import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; +import { rawPartAsPhaseData } from '../../../../../platform/endpoint/common/phaseDataContainer'; import { rawPartAsThinkingData } from '../../../../../platform/endpoint/common/thinkingDataContainer'; import type { IChatEndpoint } from '../../../../../platform/networking/common/networking'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry'; +import { SpyingTelemetryService } from '../../../../../platform/telemetry/node/spyingTelemetryService'; import { DeferredPromise } from '../../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; import { Event } from '../../../../../util/vs/base/common/event'; import { constObservable } from '../../../../../util/vs/base/common/observable'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry'; -import { SpyingTelemetryService } from '../../../../../platform/telemetry/node/spyingTelemetryService'; import { LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes'; import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection'; import type { Conversation } from '../../../../prompt/common/conversation'; @@ -224,7 +226,7 @@ class ParallelAwareToolsService implements IToolsService { } describe('ChatToolCalls (toolCalling.tsx)', () => { - test('preserves commentary before reasoning from Responses API output order', async () => { + test('preserves interleaved Responses API output item order', async () => { const testingServiceCollection = createExtensionUnitTestingServices(); const accessor = testingServiceCollection.createTestingAccessor(); const instantiationService = accessor.get(IInstantiationService); @@ -233,14 +235,24 @@ describe('ChatToolCalls (toolCalling.tsx)', () => { const round: IToolCallRound = { id: 'round-1', modelId: endpoint.model, - response: 'commentary', + response: 'commentary\n\nfinal answer', responseOutputIndex: 0, + responseOutputItems: [ + { text: 'commentary', phase: 'commentary', outputIndex: 0 }, + { text: 'final answer', phase: 'final_answer', outputIndex: 2 }, + ], thinking: { id: 'rs_after_commentary', text: '', encrypted: 'enc_reasoning', outputIndex: 1, }, + compaction: { + type: 'compaction', + id: 'cmp_after_messages', + encrypted_content: 'enc_compaction', + outputIndex: 3, + }, toolInputRetry: 0, toolCalls: [], }; @@ -266,13 +278,26 @@ describe('ChatToolCalls (toolCalling.tsx)', () => { expect(assistantMessage?.content.map(part => { if (part.type === Raw.ChatCompletionContentPartKind.Text) { - return 'message'; + return `message:${part.text}`; } if (part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsThinkingData(part)) { return 'reasoning'; } + if (part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsPhaseData(part)) { + return `phase:${rawPartAsPhaseData(part)}`; + } + if (part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsCompactionData(part)) { + return 'compaction'; + } return 'other'; - })).toEqual(['message', 'reasoning']); + })).toEqual([ + 'phase:commentary', + 'message:commentary', + 'reasoning', + 'phase:final_answer', + 'message:final answer', + 'compaction', + ]); }); test('starts multiple sub-agent tool calls in parallel', async () => { diff --git a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx index 02d7ac8c7672aa..b54fb26f8224cf 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -139,18 +139,30 @@ export class ChatToolCalls extends PromptElement { const thinking = includeThinking && round.thinking && ; const phase = (round.phase && roundModelId === this.promptEndpoint.model) ? : undefined; const compaction = round.compaction && ; - const responsePrecedesThinking = round.responseOutputIndex !== undefined - && round.thinking?.outputIndex !== undefined - && round.responseOutputIndex < round.thinking.outputIndex; + const responseItems = round.responseOutputItems?.length + ? round.responseOutputItems.map(item => ({ + outputIndex: item.outputIndex, + legacyOrder: 2, + content: <>{item.phase && sameModelAsEndpoint ? : undefined}{item.text}, + })) + : [{ outputIndex: round.responseOutputIndex, legacyOrder: 2, content: <>{phase}{round.response} }]; + const replayItems = [ + ...responseItems, + { outputIndex: round.thinking?.outputIndex, legacyOrder: 0, content: thinking }, + { outputIndex: round.compaction?.outputIndex, legacyOrder: 1, content: compaction }, + ].filter(item => item.content).sort((a, b) => { + if (a.outputIndex !== undefined && b.outputIndex !== undefined) { + return a.outputIndex - b.outputIndex; + } + if (a.outputIndex !== undefined || b.outputIndex !== undefined) { + return a.outputIndex !== undefined ? -1 : 1; + } + return a.legacyOrder - b.legacyOrder; + }); children.push( {statefulMarker} - {responsePrecedesThinking ? phase : undefined} - {responsePrecedesThinking ? round.response : undefined} - {thinking} - {responsePrecedesThinking ? undefined : phase} - {compaction} - {responsePrecedesThinking ? undefined : round.response} + {replayItems.map(item => item.content)} ); // Tool call elements should be rendered with the later elements first, allowed to grow to fill the available space diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index fa7db08a19c3a5..8392ea38c319a0 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -631,8 +631,8 @@ function isResponsesReasoningId(id: string | undefined): boolean { function extractAssistantResponseItems(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseInputItem[] { const input: OpenAI.Responses.ResponseInputItem[] = []; - const phase = extractPhaseData(content); let assistantContent: ResponseInputAssistantTextContentPart[] = []; + let assistantPhase: string | undefined; const flushAssistantContent = () => { if (!assistantContent.length) { @@ -642,12 +642,13 @@ function extractAssistantResponseItems(content: Raw.ChatCompletionContentPart[]) role: 'assistant', content: assistantContent, type: 'message', - phase, + phase: assistantPhase, }; // The Responses API expects previous assistant message content as output_text/refusal, // but the SDK's ResponseOutputMessage type requires response-only id/status fields. input.push(assistantMessage as OpenAI.Responses.ResponseInputItem); assistantContent = []; + assistantPhase = undefined; }; for (const part of content) { @@ -660,6 +661,13 @@ function extractAssistantResponseItems(content: Raw.ChatCompletionContentPart[]) continue; } + const phase = rawPartAsPhaseData(part); + if (phase !== undefined) { + flushAssistantContent(); + assistantPhase = phase; + continue; + } + const compaction = rawPartAsCompactionData(part); if (compaction) { flushAssistantContent(); @@ -689,18 +697,6 @@ function extractAssistantResponseItems(content: Raw.ChatCompletionContentPart[]) return input; } -function extractPhaseData(content: Raw.ChatCompletionContentPart[]): string | undefined { - for (const part of content) { - if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { - const phase = rawPartAsPhaseData(part); - if (phase) { - return phase; - } - } - } - return undefined; -} - /** * This is an approximate responses input -> raw messages helper, should be used for logging only */ @@ -1154,7 +1150,8 @@ export class OpenAIResponsesProcessor { const previousCompactionItem = this.latestCompactionItem; this.sawCompactionMessage = true; this.latestCompactionOutputIndex = outputIndex ?? this.latestCompactionOutputIndex; - this.latestCompactionItem = item; + const compactionItem = { ...item, outputIndex }; + this.latestCompactionItem = compactionItem; if (previousCompactionItem?.id === item.id && previousCompactionItem.encrypted_content === item.encrypted_content) { return; @@ -1162,11 +1159,7 @@ export class OpenAIResponsesProcessor { onProgress({ text: '', - contextManagement: { - type: openAIContextManagementCompactionType, - id: item.id, - encrypted_content: item.encrypted_content, - } + contextManagement: compactionItem, }); } @@ -1287,10 +1280,19 @@ export class OpenAIResponsesProcessor { } : undefined }); } else if (chunk.item.type === 'message') { + const phase = (chunk.item as ResponseOutputItemWithPhase).phase; onProgress({ text: '', - phase: (chunk.item as ResponseOutputItemWithPhase).phase, + phase, responseOutputIndex: chunk.output_index, + responseOutputItem: { + text: chunk.item.content + .filter(part => part.type === 'output_text') + .map(part => part.text) + .join(''), + phase, + outputIndex: chunk.output_index, + }, }); } return; @@ -1315,11 +1317,13 @@ export class OpenAIResponsesProcessor { const capiChunk = chunk as CapiResponseCompletedEvent; const normalizedOutput = keepLatestCompactionOutput(capiChunk.response.output, this.latestCompactionOutputIndex); const latestCompactionOutput = getLatestCompactionOutput(normalizedOutput, this.latestCompactionOutputIndex); - const latestCompactionItem = latestCompactionOutput?.item; + const latestCompactionItem = latestCompactionOutput + ? { ...latestCompactionOutput.item, outputIndex: latestCompactionOutput.outputIndex } + : undefined; const previousCompactionItem = this.latestCompactionItem; if (latestCompactionItem) { this.sawCompactionMessage = true; - this.latestCompactionOutputIndex = latestCompactionOutput.outputIndex; + this.latestCompactionOutputIndex = latestCompactionItem.outputIndex; } const shouldEmitResolvedCompaction = latestCompactionItem && ( diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index 3e305e89b5daa8..9b24d70ce0f500 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { ChatLocation } from '../../../chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; import { ILogService } from '../../../log/common/logService'; -import { isOpenAIContextManagementResponse } from '../../../networking/common/fetch'; +import { IResponseOutputItem, isOpenAIContextManagementResponse } from '../../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; import { ChatCompletion, FilterReason, FinishedCompletionReason, openAIContextManagementCompactionType, OpenAIContextManagementResponse } from '../../../networking/common/openai'; import { IToolDeferralService } from '../../../networking/common/toolDeferralService'; @@ -392,7 +392,7 @@ describe('createResponsesRequestBody', () => { services.dispose(); }); - it('preserves commentary before reasoning in stateless request history', () => { + it('preserves interleaved messages and reasoning in stateless request history', () => { const services = createPlatformServices(); const accessor = services.createTestingAccessor(); const instantiationService = accessor.get(IInstantiationService); @@ -407,6 +407,7 @@ describe('createResponsesRequestBody', () => { thinking: { id: 'rs_after_commentary', text: '', encrypted: 'enc_reasoning' }, }, }, + { type: Raw.ChatCompletionContentPartKind.Text, text: 'final answer' }, ], toolCalls: [{ id: 'call_after_reasoning', @@ -417,7 +418,7 @@ describe('createResponsesRequestBody', () => { const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint)); - expect(body.input?.map(item => item.type)).toEqual(['message', 'reasoning', 'function_call']); + expect(body.input?.map(item => item.type)).toEqual(['message', 'reasoning', 'message', 'function_call']); expect(body.input?.[0]).toMatchObject({ type: 'message', role: 'assistant', @@ -429,6 +430,11 @@ describe('createResponsesRequestBody', () => { summary: [], encrypted_content: 'enc_reasoning', }); + expect(body.input?.[2]).toMatchObject({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'final answer' }], + }); accessor.dispose(); services.dispose(); @@ -1769,6 +1775,7 @@ describe('phase commentary followed by phase final_answer', () => { const accumulatedTexts: string[] = []; const phases: string[] = []; const responseOutputIndices: number[] = []; + const responseOutputItems: IResponseOutputItem[] = []; const commentaryText = 'Responding directly in commentary as requested. My name is GitHub Copilot.'; const finalText = 'My name is GitHub Copilot.'; @@ -1845,6 +1852,9 @@ describe('phase commentary followed by phase final_answer', () => { if (delta.responseOutputIndex !== undefined) { responseOutputIndices.push(delta.responseOutputIndex); } + if (delta.responseOutputItem) { + responseOutputItems.push(delta.responseOutputItem); + } return undefined; }, telemetryData, @@ -1856,6 +1866,10 @@ describe('phase commentary followed by phase final_answer', () => { expect(phases).toEqual(['commentary', 'final_answer']); expect(responseOutputIndices).toEqual([0, 1]); + expect(responseOutputItems).toEqual([ + { text: commentaryText, phase: 'commentary', outputIndex: 0 }, + { text: finalText, phase: 'final_answer', outputIndex: 1 }, + ]); // The accumulated text must separate commentary and final_answer text const finalAccumulatedText = accumulatedTexts[accumulatedTexts.length - 1]; diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index dd57b46f7da41d..5e490060d60b24 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -132,6 +132,13 @@ export interface ICopilotConfirmation { confirmation: any; } +/** A completed assistant message output item from the Responses API. */ +export interface IResponseOutputItem { + text: string; + phase?: string; + outputIndex: number; +} + export interface IResponseDelta { text: string; logprobs?: ChoiceLogProbs; @@ -147,6 +154,7 @@ export interface IResponseDelta { thinking?: ThinkingDelta | EncryptedThinkingDelta; phase?: string; responseOutputIndex?: number; + responseOutputItem?: IResponseOutputItem; retryReason?: FilterReason | 'network_error' | 'server_error'; /** Marker for the current response, which should be presented in `IMakeChatRequestOptions` on the next call */ statefulMarker?: string; diff --git a/extensions/copilot/src/platform/networking/common/openai.ts b/extensions/copilot/src/platform/networking/common/openai.ts index 33faf49031931a..af2718a4828521 100644 --- a/extensions/copilot/src/platform/networking/common/openai.ts +++ b/extensions/copilot/src/platform/networking/common/openai.ts @@ -131,6 +131,7 @@ export interface OpenAIContextManagementResponse { encrypted_content: string; type: typeof openAIContextManagementCompactionType; id: string; + outputIndex?: number; }