agentHost: sync fixed tool call ordering

Adopts https://github.com/microsoft/agent-host-protocol/pull/20
This commit is contained in:
Connor Peet
2026-03-24 16:44:03 -07:00
parent f42f9b10ea
commit ff895d1fcd
29 changed files with 1021 additions and 990 deletions

View File

@@ -11,7 +11,6 @@ import type {
IAgentErrorEvent,
IAgentIdleEvent,
IAgentMessageEvent,
IAgentPermissionRequestEvent,
IAgentReasoningEvent,
IAgentTitleChangedEvent,
IAgentToolCompleteEvent,
@@ -20,8 +19,8 @@ import type {
} from '../../common/agentService.js';
import type {
IDeltaAction,
IPermissionRequestAction,
IReasoningAction,
IResponsePartAction,
ISessionAction,
ISessionErrorAction,
ITitleChangedAction,
@@ -31,8 +30,8 @@ import type {
ITurnCompleteAction,
IUsageAction,
} from '../../common/state/sessionActions.js';
import { PermissionKind, ToolResultContentType } from '../../common/state/sessionState.js';
import { mapProgressEventToActions } from '../../node/agentEventMapper.js';
import { ToolResultContentType, type IMarkdownResponsePart, type IReasoningResponsePart } from '../../common/state/sessionState.js';
import { AgentEventMapper } from '../../node/agentEventMapper.js';
/** Helper: flatten the result of mapProgressEventToActions into an array. */
function mapToArray(result: ISessionAction | ISessionAction[] | undefined): ISessionAction[] {
@@ -46,10 +45,15 @@ suite('AgentEventMapper', () => {
const session = URI.from({ scheme: 'copilot', path: '/test-session' });
const turnId = 'turn-1';
let mapper: AgentEventMapper;
setup(() => {
mapper = new AgentEventMapper();
});
ensureNoDisposablesAreLeakedInTestSuite();
test('delta event maps to session/delta action', () => {
test('first delta event creates a responsePart with content', () => {
const event: IAgentDeltaEvent = {
session,
type: 'delta',
@@ -57,14 +61,28 @@ suite('AgentEventMapper', () => {
content: 'hello world',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
const action = actions[0];
assert.strictEqual(action.type, 'session/delta');
const delta = action as IDeltaAction;
assert.strictEqual(delta.content, 'hello world');
assert.strictEqual(delta.session.toString(), session.toString());
assert.strictEqual(delta.turnId, turnId);
assert.strictEqual(actions[0].type, 'session/responsePart');
const part = (actions[0] as IResponsePartAction).part;
assert.strictEqual(part.kind, 'markdown');
assert.strictEqual(part.content, 'hello world');
assert.ok(part.id);
});
test('subsequent delta event maps to session/delta action', () => {
const first: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'hello ' };
const second: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'world' };
const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId));
const partId = ((firstActions[0] as IResponsePartAction).part as IMarkdownResponsePart).id;
const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId));
assert.strictEqual(secondActions.length, 1);
const delta = secondActions[0] as IDeltaAction;
assert.strictEqual(delta.type, 'session/delta');
assert.strictEqual(delta.content, 'world');
assert.strictEqual(delta.partId, partId);
});
test('tool_start event maps to toolCallStart + toolCallReady actions', () => {
@@ -80,7 +98,7 @@ suite('AgentEventMapper', () => {
language: 'shellscript',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 2);
const startAction = actions[0] as IToolCallStartAction;
@@ -111,7 +129,7 @@ suite('AgentEventMapper', () => {
},
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
const complete = actions[0] as IToolCallCompleteAction;
assert.strictEqual(complete.type, 'session/toolCallComplete');
@@ -127,7 +145,7 @@ suite('AgentEventMapper', () => {
type: 'idle',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
const turnComplete = actions[0] as ITurnCompleteAction;
assert.strictEqual(turnComplete.type, 'session/turnComplete');
@@ -144,7 +162,7 @@ suite('AgentEventMapper', () => {
stack: 'Error: Something went wrong\n at foo.ts:1',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
const errorAction = actions[0] as ISessionErrorAction;
assert.strictEqual(errorAction.type, 'session/error');
@@ -163,7 +181,7 @@ suite('AgentEventMapper', () => {
cacheReadTokens: 25,
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
const usageAction = actions[0] as IUsageAction;
assert.strictEqual(usageAction.type, 'session/usage');
@@ -180,48 +198,41 @@ suite('AgentEventMapper', () => {
title: 'New Title',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].type, 'session/titleChanged');
assert.strictEqual((actions[0] as ITitleChangedAction).title, 'New Title');
});
test('permission_request event maps to session/permissionRequest action', () => {
const event: IAgentPermissionRequestEvent = {
session,
type: 'permission_request',
requestId: 'perm-1',
permissionKind: PermissionKind.Shell,
toolCallId: 'tc-2',
fullCommandText: 'rm -rf /',
intention: 'Delete all files',
rawRequest: '{}',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].type, 'session/permissionRequest');
const req = (actions[0] as IPermissionRequestAction).request;
assert.strictEqual(req.requestId, 'perm-1');
assert.strictEqual(req.permissionKind, 'shell');
assert.strictEqual(req.toolCallId, 'tc-2');
assert.strictEqual(req.fullCommandText, 'rm -rf /');
assert.strictEqual(req.intention, 'Delete all files');
});
test('reasoning event maps to session/reasoning action', () => {
test('first reasoning event creates a responsePart with content', () => {
const event: IAgentReasoningEvent = {
session,
type: 'reasoning',
content: 'Let me think about this...',
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId));
assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].type, 'session/reasoning');
const reasoning = actions[0] as IReasoningAction;
assert.strictEqual(reasoning.content, 'Let me think about this...');
assert.strictEqual(reasoning.turnId, turnId);
assert.strictEqual(actions[0].type, 'session/responsePart');
const part = (actions[0] as IResponsePartAction).part;
assert.strictEqual(part.kind, 'reasoning');
assert.strictEqual(part.content, 'Let me think about this...');
assert.ok(part.id);
});
test('subsequent reasoning event maps to session/reasoning action', () => {
const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Let me think...' };
const second: IAgentReasoningEvent = { session, type: 'reasoning', content: ' more thoughts' };
const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId));
const partId = ((firstActions[0] as IResponsePartAction).part as IReasoningResponsePart).id;
const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId));
assert.strictEqual(secondActions.length, 1);
const reasoning = secondActions[0] as IReasoningAction;
assert.strictEqual(reasoning.type, 'session/reasoning');
assert.strictEqual(reasoning.content, ' more thoughts');
assert.strictEqual(reasoning.partId, partId);
});
test('message event returns undefined', () => {
@@ -233,7 +244,7 @@ suite('AgentEventMapper', () => {
content: 'Some full message',
};
const result = mapProgressEventToActions(event, session.toString(), turnId);
const result = mapper.mapProgressEventToActions(event, session.toString(), turnId);
assert.strictEqual(result, undefined);
});
});

View File

@@ -67,7 +67,7 @@ suite('AgentService (node dispatcher)', () => {
disposables.add(service.onDidAction(e => envelopes.push(e)));
copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' });
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta));
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));
});
});

View File

@@ -16,7 +16,7 @@ import { NullLogService } from '../../../log/common/log.js';
import { AgentSession, IAgent } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js';
import { PermissionKind, ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IToolCallCompletedState } from '../../common/state/sessionState.js';
import { ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { AgentSideEffects } from '../../node/agentSideEffects.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
import { MockAgent } from './mockAgent.js';
@@ -147,38 +147,6 @@ suite('AgentSideEffects', () => {
});
});
// ---- handleAction: session/permissionResolved -----------------------
suite('handleAction — session/permissionResolved', () => {
test('routes permission response to the correct agent', () => {
setupSession();
startTurn('turn-1');
// Simulate a permission_request progress event to populate the pending map
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
session: sessionUri,
type: 'permission_request',
requestId: 'perm-1',
permissionKind: PermissionKind.Write,
path: 'file.ts',
rawRequest: '{}',
});
// Now resolve it
sideEffects.handleAction({
type: ActionType.SessionPermissionResolved,
session: sessionUri.toString(),
turnId: 'turn-1',
requestId: 'perm-1',
approved: true,
});
assert.deepStrictEqual(agent.respondToPermissionCalls, [{ requestId: 'perm-1', approved: true }]);
});
});
// ---- handleAction: session/modelChanged -----------------------------
suite('handleAction — session/modelChanged', () => {
@@ -211,7 +179,8 @@ suite('AgentSideEffects', () => {
agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' });
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta));
// First delta creates a response part (not a delta action)
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));
});
test('returns a disposable that stops listening', () => {
@@ -223,11 +192,11 @@ suite('AgentSideEffects', () => {
const listener = sideEffects.registerProgressListener(agent);
agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' });
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1);
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);
listener.dispose();
agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' });
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1);
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);
});
});
@@ -323,7 +292,9 @@ suite('AgentSideEffects', () => {
assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(state!.turns.length, 1);
assert.strictEqual(state!.turns[0].userMessage.text, 'Hello');
assert.strictEqual(state!.turns[0].responseText, 'Hi there!');
const mdPart = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdPart, 'should have a markdown response part');
assert.strictEqual(mdPart.content, 'Hi there!');
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
});
@@ -347,8 +318,9 @@ suite('AgentSideEffects', () => {
assert.strictEqual(state!.turns.length, 1);
const turn = state!.turns[0];
assert.strictEqual(turn.toolCalls.length, 1);
const tc = turn.toolCalls[0] as IToolCallCompletedState;
const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.strictEqual(toolCallParts.length, 1);
const tc = toolCallParts[0].toolCall as IToolCallCompletedState;
assert.strictEqual(tc.status, ToolCallStatus.Completed);
assert.strictEqual(tc.toolCallId, 'tc-1');
assert.strictEqual(tc.toolName, 'shell');
@@ -375,9 +347,11 @@ suite('AgentSideEffects', () => {
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].userMessage.text, 'First question');
assert.strictEqual(state!.turns[0].responseText, 'First answer');
const mdPart0 = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.strictEqual(mdPart0?.content, 'First answer');
assert.strictEqual(state!.turns[1].userMessage.text, 'Second question');
assert.strictEqual(state!.turns[1].responseText, 'Second answer');
const mdPart1 = state!.turns[1].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.strictEqual(mdPart1?.content, 'Second answer');
});
test('flushes interrupted turns when user message arrives without closing assistant message', async () => {
@@ -398,10 +372,12 @@ suite('AgentSideEffects', () => {
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].userMessage.text, 'Interrupted question');
assert.strictEqual(state!.turns[0].responseText, '');
const mdPart0 = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(!mdPart0 || mdPart0.content === '', 'interrupted turn should have empty response');
assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);
assert.strictEqual(state!.turns[1].userMessage.text, 'Retried question');
assert.strictEqual(state!.turns[1].responseText, 'Answer');
const mdPart1 = state!.turns[1].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.strictEqual(mdPart1?.content, 'Answer');
assert.strictEqual(state!.turns[1].state, TurnState.Complete);
});

View File

@@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js';
import { URI } from '../../../../base/common/uri.js';
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js';
import { PermissionKind, ToolResultContentType, type IToolCallResult } from '../../common/state/sessionState.js';
import { ToolResultContentType, type IToolCallResult } from '../../common/state/sessionState.js';
/**
* General-purpose mock agent for unit tests. Tracks all method calls
@@ -190,18 +190,28 @@ export class ScriptedMockAgent implements IAgent {
break;
case 'permission': {
// Fire permission_request, then wait for respondToPermissionRequest
const permEvent: IAgentProgressEvent = {
type: 'permission_request',
// Fire tool_start to create the tool, then tool_ready to request confirmation
const toolStartEvent = {
type: 'tool_start' as const,
session,
requestId: 'perm-1',
permissionKind: PermissionKind.Shell,
fullCommandText: 'echo test',
intention: 'Run a test command',
rawRequest: JSON.stringify({ permissionKind: PermissionKind.Shell, fullCommandText: 'echo test', intention: 'Run a test command' }),
toolCallId: 'tc-perm-1',
toolName: 'shell',
displayName: 'Shell',
invocationMessage: 'Run a test command',
};
setTimeout(() => this._onDidSessionProgress.fire(permEvent), 10);
this._pendingPermissions.set('perm-1', (approved) => {
const toolReadyEvent = {
type: 'tool_ready' as const,
session,
toolCallId: 'tc-perm-1',
invocationMessage: 'Run a test command',
toolInput: 'echo test',
confirmationTitle: 'Run a test command',
};
setTimeout(() => {
this._onDidSessionProgress.fire(toolStartEvent);
setTimeout(() => this._onDidSessionProgress.fire(toolReadyEvent), 5);
}, 10);
this._pendingPermissions.set('tc-perm-1', (approved) => {
if (approved) {
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Allowed.' },
@@ -264,10 +274,10 @@ export class ScriptedMockAgent implements IAgent {
// Mock agent doesn't track model state
}
respondToPermissionRequest(requestId: string, approved: boolean): void {
const callback = this._pendingPermissions.get(requestId);
respondToPermissionRequest(toolCallId: string, approved: boolean): void {
const callback = this._pendingPermissions.get(toolCallId);
if (callback) {
this._pendingPermissions.delete(requestId);
this._pendingPermissions.delete(toolCallId);
callback(approved);
}
}

View File

@@ -9,7 +9,7 @@ import { fileURLToPath } from 'url';
import { WebSocket } from 'ws';
import { URI } from '../../../../base/common/uri.js';
import { ISubscribeResult } from '../../common/state/protocol/commands.js';
import type { IActionEnvelope, IDeltaAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js';
import type { IActionEnvelope, IResponsePartAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
import {
isJsonRpcNotification,
@@ -25,7 +25,7 @@ import {
type IProtocolMessage,
type IReconnectResult
} from '../../common/state/sessionProtocol.js';
import type { ISessionState } from '../../common/state/sessionState.js';
import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { PRE_EXISTING_SESSION_URI } from './mockAgent.js';
// ---- JSON-RPC test client ---------------------------------------------------
@@ -327,24 +327,22 @@ suite('Protocol WebSocket E2E', function () {
});
// 3. Send message and receive response
test('send message and receive delta + turnComplete', async function () {
test('send message and receive responsePart + turnComplete', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-send-message');
dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1);
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const deltaAction = getActionEnvelope(delta).action;
assert.strictEqual(deltaAction.type, 'session/delta');
if (deltaAction.type === 'session/delta') {
assert.strictEqual(deltaAction.content, 'Hello, world!');
}
const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction;
assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown);
assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Hello, world!');
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
// 4. Tool invocation lifecycle
test('tool invocation: toolCallStart → toolCallComplete → delta → turnComplete', async function () {
test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation');
@@ -357,7 +355,7 @@ suite('Protocol WebSocket E2E', function () {
if (tcAction.type === 'session/toolCallComplete') {
assert.strictEqual(tcAction.result.success, true);
}
await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
@@ -375,29 +373,33 @@ suite('Protocol WebSocket E2E', function () {
}
});
// 6. Permission flow
// 6. Permission flow (via tool_ready confirmation)
test('permission request → resolve → response', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-permission');
dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/permissionRequest'));
// The mock agent now fires tool_start + tool_ready instead of permission_request
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
// Confirm the tool call
client.notify('dispatchAction', {
clientSeq: 2,
action: {
type: 'session/permissionResolved',
type: 'session/toolCallConfirmed',
session: sessionUri,
turnId: 'turn-perm',
requestId: 'perm-1',
toolCallId: 'tc-perm-1',
approved: true,
},
});
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const content = (getActionEnvelope(delta).action as IDeltaAction).content;
assert.strictEqual(content, 'Allowed.');
const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction;
assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown);
assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Allowed.');
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
@@ -595,8 +597,8 @@ suite('Protocol WebSocket E2E', function () {
dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1);
const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/delta'));
const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
assert.ok(d1);
assert.ok(d2);
@@ -680,9 +682,11 @@ suite('Protocol WebSocket E2E', function () {
const turn = state.turns[0];
assert.strictEqual(turn.userMessage.text, 'What files are here?');
assert.strictEqual(turn.state, 'complete');
assert.ok(turn.toolCalls.length >= 1, 'turn should have tool calls');
assert.strictEqual(turn.toolCalls[0].toolName, 'list_files');
assert.ok(turn.responseText.includes('file1.ts'));
const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts');
assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files');
const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts');
// Restoring should NOT emit a duplicate sessionAdded notification
// (the session is already known to clients via listSessions).

View File

@@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js';
import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, type ISessionState } from '../../common/state/sessionState.js';
import { ISessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, type IMarkdownResponsePart, type ISessionState } from '../../common/state/sessionState.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
suite('SessionStateManager', () => {
@@ -253,9 +253,7 @@ suite('SessionStateManager', () => {
{
id: 'turn-1',
userMessage: { text: 'hello' },
responseText: 'world',
responseParts: [],
toolCalls: [],
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies IMarkdownResponsePart],
usage: undefined,
state: TurnState.Complete,
},
@@ -265,7 +263,7 @@ suite('SessionStateManager', () => {
assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(state.turns.length, 1);
assert.strictEqual(state.turns[0].userMessage.text, 'hello');
assert.strictEqual(state.turns[0].responseText, 'world');
assert.strictEqual((state.turns[0].responseParts[0] as IMarkdownResponsePart).content, 'world');
});
test('restoreSession returns existing state for duplicate session', () => {