mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-27 03:54:24 +01:00
agentHost: sync fixed tool call ordering
Adopts https://github.com/microsoft/agent-host-protocol/pull/20
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user