mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-29 13:03:42 +01:00
agentHost: migrate to use protocol types
- Migrates to use AHP types that are synced via `npx tsx scripts/sync-agent-host-protocol.ts` - One big churn was migrating out of URIs as rich objects in the protocol. We can't really shove our own `$mid`-type objects in there. I also explored doing a generated translation layer but I had trouble getting one I was happy with. - This tightens up some type safety in general and fixes some areas where vscode had silently/sloppily diverged from the protocol types.
This commit is contained in:
@@ -56,7 +56,7 @@ suite('AgentEventMapper', () => {
|
||||
content: 'hello world',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
const action = actions[0];
|
||||
assert.strictEqual(action.type, 'session/delta');
|
||||
@@ -79,7 +79,7 @@ suite('AgentEventMapper', () => {
|
||||
language: 'shellscript',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 2);
|
||||
|
||||
const startAction = actions[0] as IToolCallStartAction;
|
||||
@@ -87,8 +87,8 @@ suite('AgentEventMapper', () => {
|
||||
assert.strictEqual(startAction.toolCallId, 'tc-1');
|
||||
assert.strictEqual(startAction.toolName, 'readFile');
|
||||
assert.strictEqual(startAction.displayName, 'Read File');
|
||||
assert.strictEqual(startAction.toolKind, 'terminal');
|
||||
assert.strictEqual(startAction.language, 'shellscript');
|
||||
assert.strictEqual(startAction._meta?.toolKind, 'terminal');
|
||||
assert.strictEqual(startAction._meta?.language, 'shellscript');
|
||||
|
||||
const readyAction = actions[1] as IToolCallReadyAction;
|
||||
assert.strictEqual(readyAction.type, 'session/toolCallReady');
|
||||
@@ -108,14 +108,14 @@ suite('AgentEventMapper', () => {
|
||||
toolOutput: 'file contents here',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
const complete = actions[0] as IToolCallCompleteAction;
|
||||
assert.strictEqual(complete.type, 'session/toolCallComplete');
|
||||
assert.strictEqual(complete.toolCallId, 'tc-1');
|
||||
assert.strictEqual(complete.result.success, true);
|
||||
assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully');
|
||||
assert.strictEqual(complete.result.toolOutput, 'file contents here');
|
||||
assert.deepStrictEqual(complete.result.content, [{ type: 'text', text: 'file contents here' }]);
|
||||
});
|
||||
|
||||
test('idle event maps to session/turnComplete action', () => {
|
||||
@@ -124,7 +124,7 @@ suite('AgentEventMapper', () => {
|
||||
type: 'idle',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
const turnComplete = actions[0] as ITurnCompleteAction;
|
||||
assert.strictEqual(turnComplete.type, 'session/turnComplete');
|
||||
@@ -141,7 +141,7 @@ suite('AgentEventMapper', () => {
|
||||
stack: 'Error: Something went wrong\n at foo.ts:1',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
const errorAction = actions[0] as ISessionErrorAction;
|
||||
assert.strictEqual(errorAction.type, 'session/error');
|
||||
@@ -160,7 +160,7 @@ suite('AgentEventMapper', () => {
|
||||
cacheReadTokens: 25,
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
const usageAction = actions[0] as IUsageAction;
|
||||
assert.strictEqual(usageAction.type, 'session/usage');
|
||||
@@ -177,7 +177,7 @@ suite('AgentEventMapper', () => {
|
||||
title: 'New Title',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(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');
|
||||
@@ -195,7 +195,7 @@ suite('AgentEventMapper', () => {
|
||||
rawRequest: '{}',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
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;
|
||||
@@ -213,7 +213,7 @@ suite('AgentEventMapper', () => {
|
||||
content: 'Let me think about this...',
|
||||
};
|
||||
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session, turnId));
|
||||
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));
|
||||
assert.strictEqual(actions.length, 1);
|
||||
assert.strictEqual(actions[0].type, 'session/reasoning');
|
||||
const reasoning = actions[0] as IReasoningAction;
|
||||
@@ -230,7 +230,7 @@ suite('AgentEventMapper', () => {
|
||||
content: 'Some full message',
|
||||
};
|
||||
|
||||
const result = mapProgressEventToActions(event, session, turnId);
|
||||
const result = mapProgressEventToActions(event, session.toString(), turnId);
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,19 +35,19 @@ suite('AgentSideEffects', () => {
|
||||
|
||||
function setupSession(): void {
|
||||
stateManager.createSession({
|
||||
resource: sessionUri,
|
||||
resource: sessionUri.toString(),
|
||||
provider: 'mock',
|
||||
title: 'Test',
|
||||
status: SessionStatus.Idle,
|
||||
createdAt: Date.now(),
|
||||
modifiedAt: Date.now(),
|
||||
});
|
||||
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
|
||||
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri.toString() });
|
||||
}
|
||||
|
||||
function startTurn(turnId: string): void {
|
||||
stateManager.dispatchClientAction(
|
||||
{ type: 'session/turnStarted', session: sessionUri, turnId, userMessage: { text: 'hello' } },
|
||||
{ type: 'session/turnStarted', session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } },
|
||||
{ clientId: 'test', clientSeq: 1 },
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ suite('AgentSideEffects', () => {
|
||||
setupSession();
|
||||
const action: ISessionAction = {
|
||||
type: 'session/turnStarted',
|
||||
session: sessionUri,
|
||||
session: sessionUri.toString(),
|
||||
turnId: 'turn-1',
|
||||
userMessage: { text: 'hello world' },
|
||||
};
|
||||
@@ -94,7 +94,7 @@ suite('AgentSideEffects', () => {
|
||||
// sendMessage is async but fire-and-forget; wait a tick
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: sessionUri, prompt: 'hello world' }]);
|
||||
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world' }]);
|
||||
});
|
||||
|
||||
test('dispatches session/error when no agent is found', async () => {
|
||||
@@ -110,7 +110,7 @@ suite('AgentSideEffects', () => {
|
||||
|
||||
noAgentSideEffects.handleAction({
|
||||
type: 'session/turnStarted',
|
||||
session: sessionUri,
|
||||
session: sessionUri.toString(),
|
||||
turnId: 'turn-1',
|
||||
userMessage: { text: 'hello' },
|
||||
});
|
||||
@@ -128,13 +128,13 @@ suite('AgentSideEffects', () => {
|
||||
setupSession();
|
||||
sideEffects.handleAction({
|
||||
type: 'session/turnCancelled',
|
||||
session: sessionUri,
|
||||
session: sessionUri.toString(),
|
||||
turnId: 'turn-1',
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.deepStrictEqual(agent.abortSessionCalls, [sessionUri]);
|
||||
assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ suite('AgentSideEffects', () => {
|
||||
// Now resolve it
|
||||
sideEffects.handleAction({
|
||||
type: 'session/permissionResolved',
|
||||
session: sessionUri,
|
||||
session: sessionUri.toString(),
|
||||
turnId: 'turn-1',
|
||||
requestId: 'perm-1',
|
||||
approved: true,
|
||||
@@ -178,13 +178,13 @@ suite('AgentSideEffects', () => {
|
||||
setupSession();
|
||||
sideEffects.handleAction({
|
||||
type: 'session/modelChanged',
|
||||
session: sessionUri,
|
||||
session: sessionUri.toString(),
|
||||
model: 'gpt-5',
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.deepStrictEqual(agent.changeModelCalls, [{ session: sessionUri, model: 'gpt-5' }]);
|
||||
assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: 'gpt-5' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,7 +230,7 @@ suite('AgentSideEffects', () => {
|
||||
const envelopes: IActionEnvelope[] = [];
|
||||
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
||||
|
||||
await sideEffects.handleCreateSession({ session: sessionUri, provider: 'mock' });
|
||||
await sideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'mock' });
|
||||
|
||||
const ready = envelopes.find(e => e.action.type === 'session/ready');
|
||||
assert.ok(ready, 'should dispatch session/ready');
|
||||
@@ -238,7 +238,7 @@ suite('AgentSideEffects', () => {
|
||||
|
||||
test('throws when no provider is specified', async () => {
|
||||
await assert.rejects(
|
||||
() => sideEffects.handleCreateSession({ session: sessionUri }),
|
||||
() => sideEffects.handleCreateSession({ session: sessionUri.toString() }),
|
||||
/No provider specified/,
|
||||
);
|
||||
});
|
||||
@@ -251,7 +251,7 @@ suite('AgentSideEffects', () => {
|
||||
}, new NullLogService(), fileService));
|
||||
|
||||
await assert.rejects(
|
||||
() => noAgentSideEffects.handleCreateSession({ session: sessionUri, provider: 'nonexistent' }),
|
||||
() => noAgentSideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'nonexistent' }),
|
||||
/No agent registered/,
|
||||
);
|
||||
});
|
||||
@@ -264,12 +264,12 @@ suite('AgentSideEffects', () => {
|
||||
test('disposes the session on the agent and removes state', async () => {
|
||||
setupSession();
|
||||
|
||||
sideEffects.handleDisposeSession(sessionUri);
|
||||
sideEffects.handleDisposeSession(sessionUri.toString());
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
assert.strictEqual(agent.disposeSessionCalls.length, 1);
|
||||
assert.strictEqual(stateManager.getSessionState(sessionUri), undefined);
|
||||
assert.strictEqual(stateManager.getSessionState(sessionUri.toString()), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,14 +292,14 @@ suite('AgentSideEffects', () => {
|
||||
|
||||
test('throws when the directory does not exist', async () => {
|
||||
await assert.rejects(
|
||||
() => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })),
|
||||
() => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' }).toString()),
|
||||
/Directory not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when the target is not a directory', async () => {
|
||||
await assert.rejects(
|
||||
() => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })),
|
||||
() => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }).toString()),
|
||||
/Not a directory/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import type { ISessionAction } from '../../common/state/sessionActions.js';
|
||||
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IProtocolNotification, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
|
||||
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IAhpNotification, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
|
||||
import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js';
|
||||
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
|
||||
import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js';
|
||||
@@ -71,13 +71,13 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler {
|
||||
handleAction(action: ISessionAction): void {
|
||||
this.handledActions.push(action);
|
||||
}
|
||||
async handleCreateSession(_command: ICreateSessionParams): Promise<URI> { return URI.parse('copilot:/mock-session'); }
|
||||
handleDisposeSession(_session: URI): void { }
|
||||
async handleCreateSession(_command: ICreateSessionParams): Promise<void> { /* session created via state manager */ }
|
||||
handleDisposeSession(_session: string): void { }
|
||||
async handleListSessions(): Promise<ISessionSummary[]> { return []; }
|
||||
handleSetAuthToken(_token: string): void { }
|
||||
async handleBrowseDirectory(uri: URI): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> {
|
||||
this.browsedUris.push(uri);
|
||||
const error = this.browseErrors.get(uri.toString());
|
||||
async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> {
|
||||
this.browsedUris.push(URI.parse(uri));
|
||||
const error = this.browseErrors.get(uri);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -88,8 +88,8 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler {
|
||||
],
|
||||
};
|
||||
}
|
||||
getDefaultDirectory(): URI {
|
||||
return URI.file('/home/testuser');
|
||||
getDefaultDirectory(): string {
|
||||
return URI.file('/home/testuser').toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ function request(id: number, method: string, params?: unknown): IProtocolMessage
|
||||
return { jsonrpc: '2.0', id, method, params } as IProtocolMessage;
|
||||
}
|
||||
|
||||
function findNotifications(sent: IProtocolMessage[], method: string): IProtocolNotification[] {
|
||||
return sent.filter(isJsonRpcNotification) as IProtocolNotification[];
|
||||
function findNotifications(sent: IProtocolMessage[], method: string): IAhpNotification[] {
|
||||
return sent.filter(isJsonRpcNotification) as IAhpNotification[];
|
||||
}
|
||||
|
||||
function findResponse(sent: IProtocolMessage[], id: number): IProtocolMessage | undefined {
|
||||
@@ -124,9 +124,9 @@ suite('ProtocolServerHandler', () => {
|
||||
let server: MockProtocolServer;
|
||||
let sideEffects: MockSideEffectHandler;
|
||||
|
||||
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' });
|
||||
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();
|
||||
|
||||
function makeSessionSummary(resource?: URI): ISessionSummary {
|
||||
function makeSessionSummary(resource?: string): ISessionSummary {
|
||||
return {
|
||||
resource: resource ?? sessionUri,
|
||||
provider: 'copilot',
|
||||
@@ -137,7 +137,7 @@ suite('ProtocolServerHandler', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function connectClient(clientId: string, initialSubscriptions?: readonly URI[]): MockProtocolTransport {
|
||||
function connectClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport {
|
||||
const transport = new MockProtocolTransport();
|
||||
server.simulateConnection(transport);
|
||||
transport.simulateMessage(request(1, 'initialize', {
|
||||
@@ -200,8 +200,8 @@ suite('ProtocolServerHandler', () => {
|
||||
const resp = await responsePromise;
|
||||
|
||||
assert.ok(resp, 'should have sent response');
|
||||
const snapshot = (resp as { result: IStateSnapshot }).result;
|
||||
assert.strictEqual(snapshot.resource.toString(), sessionUri.toString());
|
||||
const result = (resp as unknown as { result: { snapshot: IStateSnapshot } }).result;
|
||||
assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString());
|
||||
});
|
||||
|
||||
test('client action is dispatched and echoed', () => {
|
||||
@@ -223,11 +223,11 @@ suite('ProtocolServerHandler', () => {
|
||||
|
||||
const actionMsgs = findNotifications(transport.sent, 'action');
|
||||
const turnStarted = actionMsgs.find(m => {
|
||||
const params = m.params as { envelope: { action: { type: string } } };
|
||||
return params.envelope.action.type === 'session/turnStarted';
|
||||
const envelope = m.params as unknown as { action: { type: string } };
|
||||
return envelope.action.type === 'session/turnStarted';
|
||||
});
|
||||
assert.ok(turnStarted, 'should have echoed turnStarted');
|
||||
const envelope = (turnStarted!.params as { envelope: { origin: { clientId: string; clientSeq: number } } }).envelope;
|
||||
const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } };
|
||||
assert.strictEqual(envelope.origin.clientId, 'client-1');
|
||||
assert.strictEqual(envelope.origin.clientSeq, 1);
|
||||
});
|
||||
@@ -342,14 +342,14 @@ suite('ProtocolServerHandler', () => {
|
||||
const resp = findResponse(transport.sent, 1);
|
||||
assert.ok(resp);
|
||||
const result = (resp as { result: IInitializeResult }).result;
|
||||
assert.strictEqual(URI.revive(result.defaultDirectory!).path, '/home/testuser');
|
||||
assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser');
|
||||
});
|
||||
|
||||
test('browseDirectory routes to side effect handler', async () => {
|
||||
const transport = connectClient('client-browse');
|
||||
transport.sent.length = 0;
|
||||
|
||||
const dirUri = URI.file('/home/user/project');
|
||||
const dirUri = URI.file('/home/user/project').toString();
|
||||
const responsePromise = waitForResponse(transport, 2);
|
||||
transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri }));
|
||||
const resp = await responsePromise;
|
||||
@@ -358,7 +358,7 @@ suite('ProtocolServerHandler', () => {
|
||||
assert.strictEqual(sideEffects.browsedUris[0].path, '/home/user/project');
|
||||
|
||||
assert.ok(resp);
|
||||
const result = (resp as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result;
|
||||
const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result;
|
||||
assert.strictEqual(result.entries.length, 2);
|
||||
assert.strictEqual(result.entries[0].name, 'src');
|
||||
assert.strictEqual(result.entries[0].type, 'directory');
|
||||
@@ -370,8 +370,8 @@ suite('ProtocolServerHandler', () => {
|
||||
const transport = connectClient('client-browse-error');
|
||||
transport.sent.length = 0;
|
||||
|
||||
const dirUri = URI.file('/missing');
|
||||
sideEffects.browseErrors.set(dirUri.toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri.toString()}`));
|
||||
const dirUri = URI.file('/missing').toString();
|
||||
sideEffects.browseErrors.set(dirUri, new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`));
|
||||
const responsePromise = waitForResponse(transport, 2);
|
||||
transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri }));
|
||||
const resp = await responsePromise as { error?: { code: number; message: string } };
|
||||
|
||||
@@ -9,12 +9,10 @@ import { fileURLToPath } from 'url';
|
||||
import { WebSocket } from 'ws';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
|
||||
import { protocolReplacer, protocolReviver } from '../../common/state/jsonSerialization.js';
|
||||
import {
|
||||
isJsonRpcNotification,
|
||||
isJsonRpcResponse,
|
||||
JSON_RPC_PARSE_ERROR,
|
||||
type IActionBroadcastParams,
|
||||
type IFetchTurnsResult,
|
||||
type IInitializeResult,
|
||||
type IJsonRpcErrorResponse,
|
||||
@@ -22,11 +20,11 @@ import {
|
||||
type IListSessionsResult,
|
||||
type INotificationBroadcastParams,
|
||||
type IProtocolMessage,
|
||||
type IProtocolNotification,
|
||||
type IAhpNotification,
|
||||
type IReconnectResult,
|
||||
type IStateSnapshot,
|
||||
} from '../../common/state/sessionProtocol.js';
|
||||
import type { IDeltaAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js';
|
||||
import type { IActionEnvelope, IDeltaAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js';
|
||||
import type { ISessionState } from '../../common/state/sessionState.js';
|
||||
|
||||
// ---- JSON-RPC test client ---------------------------------------------------
|
||||
@@ -40,8 +38,8 @@ class TestProtocolClient {
|
||||
private readonly _ws: WebSocket;
|
||||
private _nextId = 1;
|
||||
private readonly _pendingCalls = new Map<number, IPendingCall>();
|
||||
private readonly _notifications: IProtocolNotification[] = [];
|
||||
private readonly _notifWaiters: { predicate: (n: IProtocolNotification) => boolean; resolve: (n: IProtocolNotification) => void; reject: (err: Error) => void }[] = [];
|
||||
private readonly _notifications: IAhpNotification[] = [];
|
||||
private readonly _notifWaiters: { predicate: (n: IAhpNotification) => boolean; resolve: (n: IAhpNotification) => void; reject: (err: Error) => void }[] = [];
|
||||
|
||||
constructor(port: number) {
|
||||
this._ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
@@ -52,7 +50,7 @@ class TestProtocolClient {
|
||||
this._ws.on('open', () => {
|
||||
this._ws.on('message', (data: Buffer | string) => {
|
||||
const text = typeof data === 'string' ? data : data.toString('utf-8');
|
||||
const msg = JSON.parse(text, protocolReviver);
|
||||
const msg = JSON.parse(text);
|
||||
this._handleMessage(msg);
|
||||
});
|
||||
resolve();
|
||||
@@ -90,15 +88,13 @@ class TestProtocolClient {
|
||||
|
||||
/** Send a JSON-RPC notification (fire-and-forget). */
|
||||
notify(method: string, params?: unknown): void {
|
||||
const msg: IProtocolMessage = { jsonrpc: '2.0', method, params };
|
||||
this._ws.send(JSON.stringify(msg, protocolReplacer));
|
||||
this._ws.send(JSON.stringify({ jsonrpc: '2.0', method, params }));
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC request and await the response. */
|
||||
call<T>(method: string, params?: unknown, timeoutMs = 5000): Promise<T> {
|
||||
const id = this._nextId++;
|
||||
const msg: IProtocolMessage = { jsonrpc: '2.0', id, method, params };
|
||||
this._ws.send(JSON.stringify(msg, protocolReplacer));
|
||||
this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -114,13 +110,13 @@ class TestProtocolClient {
|
||||
}
|
||||
|
||||
/** Wait for a server notification matching a predicate. */
|
||||
waitForNotification(predicate: (n: IProtocolNotification) => boolean, timeoutMs = 5000): Promise<IProtocolNotification> {
|
||||
waitForNotification(predicate: (n: IAhpNotification) => boolean, timeoutMs = 5000): Promise<IAhpNotification> {
|
||||
const existing = this._notifications.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
|
||||
return new Promise<IProtocolNotification>((resolve, reject) => {
|
||||
return new Promise<IAhpNotification>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const idx = this._notifWaiters.findIndex(w => w.resolve === resolve);
|
||||
if (idx >= 0) {
|
||||
@@ -138,7 +134,7 @@ class TestProtocolClient {
|
||||
}
|
||||
|
||||
/** Return all received notifications matching a predicate. */
|
||||
receivedNotifications(predicate?: (n: IProtocolNotification) => boolean): IProtocolNotification[] {
|
||||
receivedNotifications(predicate?: (n: IAhpNotification) => boolean): IAhpNotification[] {
|
||||
return predicate ? this._notifications.filter(predicate) : [...this._notifications];
|
||||
}
|
||||
|
||||
@@ -231,20 +227,20 @@ function nextSessionUri(): URI {
|
||||
return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` });
|
||||
}
|
||||
|
||||
function isActionNotification(n: IProtocolNotification, actionType: string): boolean {
|
||||
function isActionNotification(n: IAhpNotification, actionType: string): boolean {
|
||||
if (n.method !== 'action') {
|
||||
return false;
|
||||
}
|
||||
const params = n.params as IActionBroadcastParams;
|
||||
return params.envelope.action.type === actionType;
|
||||
const envelope = n.params as unknown as IActionEnvelope;
|
||||
return envelope.action.type === actionType;
|
||||
}
|
||||
|
||||
function getActionParams(n: IProtocolNotification): IActionBroadcastParams {
|
||||
return n.params as IActionBroadcastParams;
|
||||
function getActionEnvelope(n: IAhpNotification): IActionEnvelope {
|
||||
return n.params as unknown as IActionEnvelope;
|
||||
}
|
||||
|
||||
/** Perform handshake, create a session, subscribe, and return its URI. */
|
||||
async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise<URI> {
|
||||
async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise<string> {
|
||||
await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId });
|
||||
|
||||
await c.call('createSession', { session: nextSessionUri(), provider: 'mock' });
|
||||
@@ -260,7 +256,7 @@ async function createAndSubscribeSession(c: TestProtocolClient, clientId: string
|
||||
return realSessionUri;
|
||||
}
|
||||
|
||||
function dispatchTurnStarted(c: TestProtocolClient, session: URI, turnId: string, text: string, clientSeq: number): void {
|
||||
function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void {
|
||||
c.notify('dispatchAction', {
|
||||
clientSeq,
|
||||
action: {
|
||||
@@ -325,7 +321,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
|
||||
);
|
||||
const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification;
|
||||
assert.strictEqual(notification.summary.resource.scheme, 'mock');
|
||||
assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock');
|
||||
assert.strictEqual(notification.summary.provider, 'mock');
|
||||
});
|
||||
|
||||
@@ -337,7 +333,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1);
|
||||
|
||||
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
|
||||
const deltaAction = getActionParams(delta).envelope.action;
|
||||
const deltaAction = getActionEnvelope(delta).action;
|
||||
assert.strictEqual(deltaAction.type, 'session/delta');
|
||||
if (deltaAction.type === 'session/delta') {
|
||||
assert.strictEqual(deltaAction.content, 'Hello, world!');
|
||||
@@ -356,7 +352,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
|
||||
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
|
||||
const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete'));
|
||||
const tcAction = getActionParams(toolComplete).envelope.action;
|
||||
const tcAction = getActionEnvelope(toolComplete).action;
|
||||
if (tcAction.type === 'session/toolCallComplete') {
|
||||
assert.strictEqual(tcAction.result.success, true);
|
||||
}
|
||||
@@ -372,7 +368,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1);
|
||||
|
||||
const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error'));
|
||||
const errorAction = getActionParams(errorNotif).envelope.action;
|
||||
const errorAction = getActionEnvelope(errorNotif).action;
|
||||
if (errorAction.type === 'session/error') {
|
||||
assert.strictEqual(errorAction.error.message, 'Something went wrong');
|
||||
}
|
||||
@@ -399,7 +395,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
});
|
||||
|
||||
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
|
||||
const content = (getActionParams(delta).envelope.action as IDeltaAction).content;
|
||||
const content = (getActionEnvelope(delta).action as IDeltaAction).content;
|
||||
assert.strictEqual(content, 'Allowed.');
|
||||
|
||||
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
|
||||
@@ -417,8 +413,8 @@ suite('Protocol WebSocket E2E', function () {
|
||||
);
|
||||
|
||||
const result = await client.call<IListSessionsResult>('listSessions');
|
||||
assert.ok(Array.isArray(result.sessions));
|
||||
assert.ok(result.sessions.length >= 1, 'should have at least one session');
|
||||
assert.ok(Array.isArray(result.items));
|
||||
assert.ok(result.items.length >= 1, 'should have at least one session');
|
||||
});
|
||||
|
||||
// 8. Reconnect
|
||||
@@ -431,7 +427,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
|
||||
const allActions = client.receivedNotifications(n => n.method === 'action');
|
||||
assert.ok(allActions.length > 0);
|
||||
const missedFromSeq = getActionParams(allActions[0]).envelope.serverSeq - 1;
|
||||
const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1;
|
||||
|
||||
client.close();
|
||||
|
||||
@@ -460,7 +456,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1);
|
||||
|
||||
const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage'));
|
||||
const usageAction = getActionParams(usageNotif).envelope.action as IUsageAction;
|
||||
const usageAction = getActionEnvelope(usageNotif).action as IUsageAction;
|
||||
assert.strictEqual(usageAction.usage.inputTokens, 100);
|
||||
assert.strictEqual(usageAction.usage.outputTokens, 50);
|
||||
|
||||
@@ -643,7 +639,7 @@ suite('Protocol WebSocket E2E', function () {
|
||||
});
|
||||
|
||||
const modelChanged = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged'));
|
||||
const action = getActionParams(modelChanged).envelope.action;
|
||||
const action = getActionEnvelope(modelChanged).action;
|
||||
assert.strictEqual(action.type, 'session/modelChanged');
|
||||
if (action.type === 'session/modelChanged') {
|
||||
assert.strictEqual((action as { model: string }).model, 'new-mock-model');
|
||||
|
||||
Reference in New Issue
Block a user