豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 0c3d4f8

Browse files
authored
refactor: improve handling of tool_calls and add unit tests for session options normalization (#4779)
1 parent a82f30b commit 0c3d4f8

File tree

3 files changed

+269
-15
lines changed

3 files changed

+269
-15
lines changed

src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,13 +222,14 @@ export class ChatSessionContentBuilder {
222222
): string {
223223
let currentResponseContent = '';
224224
if (delta.role === 'assistant') {
225+
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : undefined;
225226
// Handle special case for run_custom_setup_step
226227
if (
227228
choice.finish_reason === 'tool_calls' &&
228-
delta.tool_calls?.length &&
229-
(delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup')
229+
toolCalls?.length &&
230+
(toolCalls[0].function.name === 'run_custom_setup_step' || toolCalls[0].function.name === 'run_setup')
230231
) {
231-
const toolCall = delta.tool_calls[0];
232+
const toolCall = toolCalls[0];
232233
let args: { name?: string } = {};
233234
try {
234235
args = JSON.parse(toolCall.function.arguments);
@@ -254,14 +255,14 @@ export class ChatSessionContentBuilder {
254255
}
255256

256257
const isError = delta.content?.startsWith('<error>');
257-
if (delta.tool_calls) {
258+
if (toolCalls) {
258259
// Add any accumulated content as markdown first
259260
if (currentResponseContent.trim()) {
260261
responseParts.push(new ChatResponseMarkdownPart(currentResponseContent.trim()));
261262
currentResponseContent = '';
262263
}
263264

264-
for (const toolCall of delta.tool_calls) {
265+
for (const toolCall of toolCalls) {
265266
const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || '');
266267
if (toolPart) {
267268
responseParts.push(toolPart);

src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { isUntitledSessionId } from '../common/utils';
2727
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
2828
import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils';
2929
import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager';
30-
import { ChatSessionContentBuilder } from './copilotCloudSessionContentBuilder';
30+
import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder';
3131
import { IPullRequestFileChangesService } from './pullRequestFileChangesService';
3232
import MarkdownIt = require('markdown-it');
3333

@@ -39,6 +39,11 @@ interface ConfirmationMetadata {
3939
chatContext: vscode.ChatContext;
4040
}
4141

42+
type InitialSessionOption = {
43+
readonly optionId: string;
44+
readonly value: string | vscode.ChatSessionProviderOptionItem;
45+
};
46+
4247
function validateMetadata(metadata: unknown): asserts metadata is ConfirmationMetadata {
4348
if (typeof metadata !== 'object') {
4449
throw new Error('Invalid confirmation metadata: not an object.');
@@ -54,6 +59,82 @@ function validateMetadata(metadata: unknown): asserts metadata is ConfirmationMe
5459
}
5560
}
5661

62+
function describeRuntimeValue(value: unknown): string {
63+
if (Array.isArray(value)) {
64+
return `array(length=${value.length})`;
65+
}
66+
67+
if (value === null) {
68+
return 'null';
69+
}
70+
71+
if (value === undefined) {
72+
return 'undefined';
73+
}
74+
75+
if (typeof value === 'object') {
76+
const keys = Object.keys(value);
77+
return `object(keys=${keys.slice(0, 5).join(',')}${keys.length > 5 ? ',…' : ''})`;
78+
}
79+
80+
return typeof value;
81+
}
82+
83+
function isOptionItemValue(value: unknown): value is vscode.ChatSessionProviderOptionItem {
84+
return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';
85+
}
86+
87+
function isInitialSessionOption(value: unknown): value is InitialSessionOption {
88+
if (typeof value !== 'object' || value === null || !('optionId' in value) || typeof value.optionId !== 'string' || !('value' in value)) {
89+
return false;
90+
}
91+
92+
return typeof value.value === 'string' || isOptionItemValue(value.value);
93+
}
94+
95+
export function normalizeInitialSessionOptions(initialOptions: unknown, logService?: ILogService, chatResource?: vscode.Uri): readonly InitialSessionOption[] {
96+
if (!initialOptions) {
97+
return [];
98+
}
99+
100+
if (Array.isArray(initialOptions)) {
101+
const normalized = initialOptions.filter(isInitialSessionOption);
102+
if (logService && normalized.length !== initialOptions.length) {
103+
logService.warn(`[chatParticipantImpl] Ignoring ${initialOptions.length - normalized.length} malformed initialSessionOptions entries for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);
104+
}
105+
106+
return normalized;
107+
}
108+
109+
if (typeof initialOptions === 'object') {
110+
const normalized: InitialSessionOption[] = [];
111+
for (const [optionId, value] of Object.entries(initialOptions)) {
112+
if (isInitialSessionOption(value)) {
113+
normalized.push(value);
114+
} else if (typeof value === 'string' || isOptionItemValue(value)) {
115+
normalized.push({ optionId, value });
116+
}
117+
}
118+
119+
if (normalized.length > 0) {
120+
logService?.warn(`[chatParticipantImpl] Coerced object-shaped initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)} and recovered ${normalized.length} entries.`);
121+
return normalized;
122+
}
123+
}
124+
125+
logService?.warn(`[chatParticipantImpl] Ignoring unsupported initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);
126+
return [];
127+
}
128+
129+
export function parseSessionLogChunksSafely(rawText: string, logService: ILogService, parser: (value: string) => SessionResponseLogChunk[]): SessionResponseLogChunk[] {
130+
try {
131+
return parser(rawText);
132+
} catch (error) {
133+
logService.error(error instanceof Error ? error : new Error(String(error)), `[streamNewLogContent] Failed to parse streamed log content (${rawText.length} chars).`);
134+
return [];
135+
}
136+
}
137+
57138
const CUSTOM_AGENTS_OPTION_GROUP_ID = 'customAgents';
58139
const MODELS_OPTION_GROUP_ID = 'models';
59140
const PARTNER_AGENTS_OPTION_GROUP_ID = 'partnerAgents';
@@ -1841,8 +1922,11 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
18411922
const chatResource = context.chatSessionContext?.chatSessionItem?.resource;
18421923

18431924
const initialOptions = context.chatSessionContext?.initialSessionOptions;
1844-
if (chatResource && initialOptions) {
1845-
for (const opt of initialOptions) {
1925+
if (chatResource) {
1926+
this.logService.trace(`[chatParticipantImpl] initialSessionOptions for ${chatResource.toString()}: ${describeRuntimeValue(initialOptions)}`);
1927+
}
1928+
if (chatResource) {
1929+
for (const opt of normalizeInitialSessionOptions(initialOptions, this.logService, chatResource)) {
18461930
const value = typeof opt.value === 'string' ? opt.value : opt.value.id;
18471931
if (opt.optionId === CUSTOM_AGENTS_OPTION_GROUP_ID) {
18481932
this.sessionCustomAgentMap.set(chatResource, value);
@@ -2152,19 +2236,32 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
21522236

21532237
// Parse the new log content
21542238
const contentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService);
2155-
2156-
const logChunks = contentBuilder.parseSessionLogs(newLogContent);
2239+
const logChunks = parseSessionLogChunksSafely(newLogContent, this.logService, value => contentBuilder.parseSessionLogs(value));
21572240
let hasStreamedContent = false;
21582241
let hasSetupStepProgress = false;
21592242

2160-
for (const chunk of logChunks) {
2243+
for (const [chunkIndex, chunk] of logChunks.entries()) {
2244+
if (!Array.isArray(chunk.choices)) {
2245+
this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with non-array choices for PR #${pullRequest.number}.`);
2246+
continue;
2247+
}
2248+
21612249
for (const choice of chunk.choices) {
2250+
if (!choice?.delta) {
2251+
this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with missing delta for PR #${pullRequest.number}.`);
2252+
continue;
2253+
}
2254+
21622255
const delta = choice.delta;
2256+
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : undefined;
2257+
if (delta.tool_calls && !toolCalls) {
2258+
this.logService.warn(`[streamNewLogContent] Ignoring non-array tool_calls for PR #${pullRequest.number}.`);
2259+
}
21632260

21642261
if (delta.role === 'assistant') {
21652262
// Handle special case for run_custom_setup_step/run_setup
2166-
if (choice.finish_reason === 'tool_calls' && delta.tool_calls?.length && (delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup')) {
2167-
const toolCall = delta.tool_calls[0];
2263+
if (choice.finish_reason === 'tool_calls' && toolCalls?.length && (toolCalls[0].function.name === 'run_custom_setup_step' || toolCalls[0].function.name === 'run_setup')) {
2264+
const toolCall = toolCalls[0];
21682265
let args: any = {};
21692266
try {
21702267
args = JSON.parse(toolCall.function.arguments);
@@ -2195,8 +2292,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
21952292
}
21962293
}
21972294

2198-
if (delta.tool_calls) {
2199-
for (const toolCall of delta.tool_calls) {
2295+
if (toolCalls) {
2296+
for (const toolCall of toolCalls) {
22002297
const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, delta.content || '');
22012298
if (toolPart) {
22022299
stream.push(toolPart);
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { describe, expect, it, vi } from 'vitest';
7+
import * as vscode from 'vscode';
8+
import { IGitService } from '../../../../platform/git/common/gitService';
9+
import { PullRequestSearchItem, SessionInfo } from '../../../../platform/github/common/githubAPI';
10+
import { TestLogService } from '../../../../platform/testing/common/testLogService';
11+
import { mock } from '../../../../util/common/test/simpleMock';
12+
import { ChatResponseMarkdownPart, ChatResponseTurn2 } from '../../../../vscodeTypes';
13+
import { ChatSessionContentBuilder } from '../copilotCloudSessionContentBuilder';
14+
import { normalizeInitialSessionOptions, parseSessionLogChunksSafely } from '../copilotCloudSessionsProvider';
15+
16+
vi.mock('vscode', async () => {
17+
const actual = await import('../../../../vscodeTypes');
18+
return {
19+
...actual,
20+
workspace: {
21+
workspaceFolders: [],
22+
},
23+
};
24+
});
25+
26+
class RecordingLogService extends TestLogService {
27+
override readonly trace = vi.fn();
28+
override readonly warn = vi.fn();
29+
override readonly error = vi.fn();
30+
}
31+
32+
class TestGitService extends mock<IGitService>() {
33+
declare readonly _serviceBrand: undefined;
34+
override activeRepository = { get: () => undefined } as IGitService['activeRepository'];
35+
override initialize = vi.fn(async () => { });
36+
override repositories = [];
37+
}
38+
39+
function createPullRequest(): PullRequestSearchItem {
40+
return {
41+
id: 'pr-1',
42+
number: 1,
43+
title: 'Test PR',
44+
state: 'OPEN',
45+
url: 'https://example.com/pr/1',
46+
createdAt: '2026-03-27T00:00:00Z',
47+
updatedAt: '2026-03-27T00:00:00Z',
48+
author: { login: 'octocat' },
49+
repository: {
50+
owner: { login: 'microsoft' },
51+
name: 'vscode',
52+
},
53+
additions: 1,
54+
deletions: 0,
55+
files: { totalCount: 1 },
56+
fullDatabaseId: 1,
57+
headRefOid: 'abc123',
58+
body: 'Body',
59+
};
60+
}
61+
62+
function createSession(state: SessionInfo['state'] = 'completed'): SessionInfo {
63+
return {
64+
id: 'session-1',
65+
name: 'Cloud session',
66+
user_id: 1,
67+
agent_id: 1,
68+
logs: '',
69+
logs_blob_id: 'blob-1',
70+
state,
71+
owner_id: 1,
72+
repo_id: 1,
73+
resource_type: 'pull_request',
74+
resource_id: 1,
75+
last_updated_at: '2026-03-27T00:00:00Z',
76+
created_at: '2026-03-27T00:00:00Z',
77+
completed_at: '2026-03-27T00:00:00Z',
78+
event_type: 'pull_request',
79+
workflow_run_id: 1,
80+
premium_requests: 0,
81+
error: null,
82+
resource_global_id: 'global-1',
83+
};
84+
}
85+
86+
describe('copilotCloudSessionsProvider helpers', () => {
87+
it('coerces object-shaped initialSessionOptions into option entries', () => {
88+
const logService = new RecordingLogService();
89+
const sessionResource = vscode.Uri.parse('copilot-cloud-agent:/1');
90+
91+
const result = normalizeInitialSessionOptions({
92+
models: { id: 'gpt-4.1', name: 'GPT-4.1' },
93+
repositories: 'microsoft/vscode',
94+
}, logService, sessionResource);
95+
96+
expect(result).toEqual([
97+
{ optionId: 'models', value: { id: 'gpt-4.1', name: 'GPT-4.1' } },
98+
{ optionId: 'repositories', value: 'microsoft/vscode' },
99+
]);
100+
expect(logService.warn).toHaveBeenCalledWith(expect.stringContaining('Coerced object-shaped initialSessionOptions'));
101+
});
102+
103+
it('ignores unsupported initialSessionOptions payloads and logs a warning', () => {
104+
const logService = new RecordingLogService();
105+
106+
const result = normalizeInitialSessionOptions({
107+
models: { foo: 'bar' },
108+
}, logService);
109+
110+
expect(result).toEqual([]);
111+
expect(logService.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring unsupported initialSessionOptions'));
112+
});
113+
114+
it('logs parse failures when streamed log content is malformed', () => {
115+
const logService = new RecordingLogService();
116+
117+
const result = parseSessionLogChunksSafely('data: {not-json}', logService, () => {
118+
throw new SyntaxError('Unexpected token');
119+
});
120+
121+
expect(result).toEqual([]);
122+
expect(logService.error).toHaveBeenCalledWith(expect.any(SyntaxError), expect.stringContaining('Failed to parse streamed log content'));
123+
});
124+
});
125+
126+
describe('ChatSessionContentBuilder', () => {
127+
it('ignores malformed tool_calls payloads instead of throwing', async () => {
128+
const builder = new ChatSessionContentBuilder('copilot-cloud-agent', new TestGitService());
129+
const logs = [
130+
'data: {"choices":[{"finish_reason":"stop","delta":{"role":"assistant","content":"Cloud reply","tool_calls":{"id":"not-an-array"}}}],"created":0,"id":"chunk-1","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0},"model":"test-model","object":"chat.completion.chunk"}',
131+
].join('\n');
132+
133+
const history = await builder.buildSessionHistory(
134+
Promise.resolve('Continue in cloud'),
135+
[createSession()],
136+
createPullRequest(),
137+
async () => logs,
138+
Promise.resolve([]),
139+
);
140+
141+
expect(history).toHaveLength(2);
142+
const responseTurn = history[1];
143+
expect(responseTurn).toBeInstanceOf(ChatResponseTurn2);
144+
if (!(responseTurn instanceof ChatResponseTurn2)) {
145+
throw new Error('Expected a response turn.');
146+
}
147+
148+
expect(responseTurn.response).toHaveLength(1);
149+
expect(responseTurn.response[0]).toBeInstanceOf(ChatResponseMarkdownPart);
150+
if (!(responseTurn.response[0] instanceof ChatResponseMarkdownPart)) {
151+
throw new Error('Expected markdown response content.');
152+
}
153+
154+
expect(responseTurn.response[0].value.value).toBe('Cloud reply');
155+
});
156+
});

0 commit comments

Comments
 (0)