diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index df1aa03e685aaa..a9285eba71b70c 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -1576,6 +1576,8 @@ export abstract class ToolCallingLoop = []; let compaction: OpenAIContextManagementResponse | undefined; markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillFetch); const fetchOptions: ToolCallingLoopFetchOptions = { @@ -1601,6 +1603,14 @@ export abstract class ToolCallingLoop): void { 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..ecac5f35e1adff 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,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 0", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -49,6 +51,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 1", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -69,6 +73,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 2", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -110,6 +116,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 3", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -130,6 +138,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 4", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -150,6 +160,8 @@ exports[`defaultIntentRequestHandler > ChatResult metadata after multiple turns "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 5", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -234,6 +246,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 0", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -254,6 +268,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 1", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -274,6 +290,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 2", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -294,6 +312,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 3", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -358,6 +378,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 4", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -378,6 +400,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 5", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -398,6 +422,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 6", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -418,6 +444,8 @@ exports[`defaultIntentRequestHandler > confirms on max tool call iterations, and "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "response number 7", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2571,6 +2599,8 @@ exports[`defaultIntentRequestHandler > makes a successful request with a single "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "some response here :)", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2907,6 +2937,8 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "modelId": "gpt-4.1-2025-04-14", "phase": undefined, "response": "some response here :)", + "responseOutputIndex": undefined, + "responseOutputItems": undefined, "statefulMarker": undefined, "statefulMarkerSummarizedAtRoundId": undefined, "summary": undefined, @@ -2927,6 +2959,8 @@ exports[`defaultIntentRequestHandler > makes a tool call turn 1`] = ` "modelId": "gpt-4.1-2025-04-14", "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 658aa81b5ee2e2..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 @@ -3,19 +3,23 @@ * 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 { 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'; @@ -222,6 +226,80 @@ class ParallelAwareToolsService implements IToolsService { } describe('ChatToolCalls (toolCalling.tsx)', () => { + test('preserves interleaved Responses API output item 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\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: [], + }; + 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:${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([ + 'phase:commentary', + 'message:commentary', + 'reasoning', + 'phase:final_answer', + 'message:final answer', + 'compaction', + ]); + }); + 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..b54fb26f8224cf 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -139,13 +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 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} - {thinking} - {phase} - {compaction} - {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 428bddabaa6a88..8392ea38c319a0 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,53 +629,72 @@ 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[] = []; + let assistantContent: ResponseInputAssistantTextContentPart[] = []; + let assistantPhase: string | undefined; + + const flushAssistantContent = () => { + if (!assistantContent.length) { + return; } - })); -} + const assistantMessage: ResponseInputAssistantMessageWithPhase = { + role: 'assistant', + content: assistantContent, + type: 'message', + 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; + }; -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; - } + const assistantPart = rawContentToResponsesAssistantContent(part); + if (assistantPart) { + assistantContent.push(assistantPart); + continue; + } + if (part.type !== Raw.ChatCompletionContentPartKind.Opaque) { + continue; } - } - 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; - } + const phase = rawPartAsPhaseData(part); + if (phase !== undefined) { + flushAssistantContent(); + assistantPhase = phase; + 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; } /** @@ -1144,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; @@ -1152,11 +1159,7 @@ export class OpenAIResponsesProcessor { onProgress({ text: '', - contextManagement: { - type: openAIContextManagementCompactionType, - id: item.id, - encrypted_content: item.encrypted_content, - } + contextManagement: compactionItem, }); } @@ -1273,12 +1276,23 @@ 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') { + 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; @@ -1303,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 bf2d09b24c606f..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,6 +392,54 @@ describe('createResponsesRequestBody', () => { services.dispose(); }); + it('preserves interleaved messages and 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' }, + }, + }, + { type: Raw.ChatCompletionContentPartKind.Text, text: 'final answer' }, + ], + 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', 'message', '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', + }); + expect(body.input?.[2]).toMatchObject({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'final answer' }], + }); + + 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 +1774,8 @@ describe('phase commentary followed by phase final_answer', () => { const telemetryService = new SpyingTelemetryService(); 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.'; @@ -1799,6 +1849,12 @@ describe('phase commentary followed by phase final_answer', () => { if (delta.phase) { phases.push(delta.phase); } + if (delta.responseOutputIndex !== undefined) { + responseOutputIndices.push(delta.responseOutputIndex); + } + if (delta.responseOutputItem) { + responseOutputItems.push(delta.responseOutputItem); + } return undefined; }, telemetryData, @@ -1809,6 +1865,11 @@ 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 8d9a8a2c4622a9..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; @@ -146,6 +153,8 @@ export interface IResponseDelta { copilotConfirmation?: ICopilotConfirmation; 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; } 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