Path: blob/main/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { SubscribeResult } from '../../../common/state/protocol/commands.js';7import type { IResponsePartAction } from '../../../common/state/sessionActions.js';8import type { FetchTurnsResult } from '../../../common/state/sessionProtocol.js';9import { ResponsePartKind, buildSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../../common/state/sessionState.js';10import {11createAndSubscribeSession,12dispatchTurnStarted,13getActionEnvelope,14IServerHandle,15isActionNotification,16startServer,17TestProtocolClient,18} from './testHelpers.js';1920suite('Protocol WebSocket — Turn Execution', function () {2122let server: IServerHandle;23let client: TestProtocolClient;2425suiteSetup(async function () {26this.timeout(15_000);27server = await startServer();28});2930suiteTeardown(function () {31server.process.kill();32});3334setup(async function () {35this.timeout(10_000);36client = new TestProtocolClient(server.port);37await client.connect();38});3940teardown(function () {41client.close();42});4344test('send message and receive responsePart + turnComplete', async function () {45this.timeout(10_000);4647const sessionUri = await createAndSubscribeSession(client, 'test-send-message');48dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1);4950const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));51const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction;52assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown);53assert.strictEqual((responsePartAction.part as MarkdownResponsePart).content, 'Hello, world!');5455await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));56});5758test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () {59this.timeout(10_000);6061const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation');62dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1);6364await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));65await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));66const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete'));67const tcAction = getActionEnvelope(toolComplete).action;68if (tcAction.type === 'session/toolCallComplete') {69assert.strictEqual(tcAction.result.success, true);70}71await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));72await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));73});7475test('error prompt triggers session/error', async function () {76this.timeout(10_000);7778const sessionUri = await createAndSubscribeSession(client, 'test-error');79dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1);8081const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error'));82const errorAction = getActionEnvelope(errorNotif).action;83if (errorAction.type === 'session/error') {84assert.strictEqual(errorAction.error.message, 'Something went wrong');85}86});8788test('cancel turn stops in-progress processing', async function () {89this.timeout(10_000);9091const sessionUri = await createAndSubscribeSession(client, 'test-cancel');92dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1);9394client.notify('dispatchAction', {95clientSeq: 2,96action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' },97});9899await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled'));100101const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });102const state = snapshot.snapshot.state as SessionState;103assert.ok(state.turns.length >= 1);104assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled');105});106107test('multiple sequential turns accumulate in history', async function () {108this.timeout(15_000);109110const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns');111112dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1);113await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));114115dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2);116await new Promise(resolve => setTimeout(resolve, 200));117await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));118119const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });120const state = snapshot.snapshot.state as SessionState;121assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`);122assert.strictEqual(state.turns[0].id, 'turn-m1');123assert.strictEqual(state.turns[1].id, 'turn-m2');124});125126test('fetchTurns returns completed turn history', async function () {127this.timeout(15_000);128129const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns');130131dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1);132await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));133134dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2);135await new Promise(resolve => setTimeout(resolve, 200));136await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));137138const result = await client.call<FetchTurnsResult>('fetchTurns', { session: sessionUri, limit: 10 });139assert.ok(result.turns.length >= 2);140assert.strictEqual(typeof result.hasMore, 'boolean');141});142143test('usage info is captured on completed turn', async function () {144this.timeout(10_000);145146const sessionUri = await createAndSubscribeSession(client, 'test-usage');147dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1);148149const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage'));150const usageAction = getActionEnvelope(usageNotif).action as { type: string; usage: { inputTokens: number; outputTokens: number } };151assert.strictEqual(usageAction.usage.inputTokens, 100);152assert.strictEqual(usageAction.usage.outputTokens, 50);153154await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));155156const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });157const state = snapshot.snapshot.state as SessionState;158assert.ok(state.turns.length >= 1);159const turn = state.turns[state.turns.length - 1];160assert.ok(turn.usage);161assert.strictEqual(turn.usage!.inputTokens, 100);162assert.strictEqual(turn.usage!.outputTokens, 50);163});164165test('modifiedAt updates on turn completion', async function () {166this.timeout(10_000);167168const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt');169170const initialSnapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });171const initialModifiedAt = (initialSnapshot.snapshot.state as SessionState).summary.modifiedAt;172173await new Promise(resolve => setTimeout(resolve, 50));174175dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1);176await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));177178const updatedSnapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });179const updatedModifiedAt = (updatedSnapshot.snapshot.state as SessionState).summary.modifiedAt;180assert.ok(updatedModifiedAt >= initialModifiedAt);181});182183test('subagent: inner tool calls land in child session, not parent', async function () {184this.timeout(15_000);185186const sessionUri = await createAndSubscribeSession(client, 'test-subagent');187dispatchTurnStarted(client, sessionUri, 'turn-sa', 'subagent', 1);188189// Wait for the parent turn to complete.190await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));191192// Subscribe to the child subagent session — its URI is derived from193// the parent session URI + parent toolCallId.194const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1');195196const parentSnapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });197const parentState = parentSnapshot.snapshot.state as SessionState;198const childSnapshot = await client.call<SubscribeResult>('subscribe', { resource: childUri });199const childState = childSnapshot.snapshot.state as SessionState;200201// Parent turn should contain the `task` tool call but NOT the inner one.202const parentTurn = parentState.turns[parentState.turns.length - 1];203const parentToolCalls = parentTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall);204const parentToolNames = parentToolCalls.map(p => p.toolCall.toolName);205assert.deepStrictEqual(parentToolNames, ['task'], 'parent turn should only contain the `task` tool call (inner tool must route to subagent)');206207// Child session should have one turn with the inner `echo_tool` call.208assert.ok(childState.turns.length >= 1, 'child subagent session should have at least one turn');209const childTurn = childState.turns[childState.turns.length - 1];210const childToolCalls = childTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall);211const childToolNames = childToolCalls.map(p => p.toolCall.toolName);212assert.deepStrictEqual(childToolNames, ['echo_tool'], 'child subagent session should contain the inner `echo_tool` call');213});214});215216217