Unify agentHost server-side dispatch: remove IProtocolSideEffectHandler (#306158)

* Unify agentHost server-side dispatch: remove IProtocolSideEffectHandler

Eliminate the IProtocolSideEffectHandler interface and make
ProtocolServerHandler talk to IAgentService directly. This removes
the duplicate adapter layer between the WebSocket protocol server
and the real service implementation.

Changes:
- ProtocolServerHandler now takes IAgentService + SessionStateManager +
  IProtocolServerConfig instead of IProtocolSideEffectHandler
- Deleted ~40-line inline adapter in agentHostMain.ts
- agentHostServerMain.ts now uses AgentService instead of manually
  wiring SessionStateManager + AgentSideEffects
- Removed implements IProtocolSideEffectHandler from AgentSideEffects
- Removed dead methods from AgentSideEffects that were only needed
  by the deleted interface (handleCreateSession, handleDisposeSession,
  handleListSessions, handleGetResourceMetadata, handleAuthenticate,
  getDefaultDirectory)
- Type conversions (URI<->string, IAgentSessionMetadata<->ISessionSummary)
  now happen at the protocol boundary in ProtocolServerHandler
- Fixed dispatchAction double-dispatch: WS path previously dispatched
  to stateManager AND called handleAction (which dispatched again)
- Extension methods (getResourceMetadata, authenticate, etc.) now call
  IAgentService directly instead of untyped fallbacks

(Written by Copilot)

* comments

Co-authored-by: Copilot <copilot@github.com>

* Simplify further

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Rob Lourens
2026-03-29 19:07:20 -07:00
committed by GitHub
parent bf71412e69
commit 94c7bf8213
9 changed files with 512 additions and 844 deletions

View File

@@ -4,14 +4,18 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { AgentSession } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js';
import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { AgentService } from '../../node/agentService.js';
import { MockAgent } from './mockAgent.js';
@@ -20,8 +24,9 @@ suite('AgentService (node dispatcher)', () => {
const disposables = new DisposableStore();
let service: AgentService;
let copilotAgent: MockAgent;
let fileService: FileService;
setup(() => {
setup(async () => {
const nullSessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
@@ -30,7 +35,14 @@ suite('AgentService (node dispatcher)', () => {
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};
service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService())), nullSessionDataService));
fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
// Seed a directory for browseDirectory tests
await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' }));
await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));
service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService));
copilotAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => copilotAgent.dispose()));
});
@@ -226,4 +238,108 @@ suite('AgentService (node dispatcher)', () => {
assert.ok(copilotShutdown);
});
});
// ---- restoreSession -------------------------------------------------
suite('restoreSession', () => {
test('restores a session with message history', async () => {
service.registerProvider(copilotAgent);
const session = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state, 'session should be in state manager');
assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(state!.turns.length, 1);
assert.strictEqual(state!.turns[0].userMessage.text, 'Hello');
const mdPart = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdPart);
assert.strictEqual(mdPart.content, 'Hi there!');
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
});
test('restores a session with tool calls', async () => {
service.registerProvider(copilotAgent);
const session = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' },
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
const turn = state!.turns[0];
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.confirmed, ToolCallConfirmationReason.NotNeeded);
});
test('flushes interrupted turns', async () => {
service.registerProvider(copilotAgent);
const session = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] },
{ type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);
assert.strictEqual(state!.turns[1].state, TurnState.Complete);
});
test('throws when session is not found on backend', async () => {
service.registerProvider(copilotAgent);
await assert.rejects(
() => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')),
/Session not found on backend/,
);
});
});
// ---- browseDirectory ------------------------------------------------
suite('browseDirectory', () => {
test('throws when the directory does not exist', async () => {
await assert.rejects(
() => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })),
/Directory not found/,
);
});
test('throws when the target is not a directory', async () => {
await assert.rejects(
() => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })),
/Not a directory/,
);
});
});
});

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 { PendingMessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { AgentSideEffects } from '../../node/agentSideEffects.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
import { MockAgent } from './mockAgent.js';
@@ -78,7 +78,7 @@ suite('AgentSideEffects', () => {
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
} satisfies ISessionDataService,
}, new NullLogService(), fileService));
}, new NullLogService()));
});
teardown(() => {
@@ -113,7 +113,7 @@ suite('AgentSideEffects', () => {
getAgent: () => undefined,
agents: emptyAgents,
sessionDataService: {} as ISessionDataService,
}, new NullLogService(), fileService));
}, new NullLogService()));
const envelopes: IActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
@@ -201,272 +201,6 @@ suite('AgentSideEffects', () => {
});
});
// ---- handleCreateSession --------------------------------------------
suite('handleCreateSession', () => {
test('creates a session and dispatches session/ready', async () => {
const envelopes: IActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
await sideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'mock' });
const ready = envelopes.find(e => e.action.type === ActionType.SessionReady);
assert.ok(ready, 'should dispatch session/ready');
});
test('throws when no provider is specified', async () => {
await assert.rejects(
() => sideEffects.handleCreateSession({ session: sessionUri.toString() }),
/No provider specified/,
);
});
test('throws when no agent matches provider', async () => {
const emptyAgents = observableValue<readonly IAgent[]>('agents', []);
const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, {
getAgent: () => undefined,
agents: emptyAgents,
sessionDataService: {} as ISessionDataService,
}, new NullLogService(), fileService));
await assert.rejects(
() => noAgentSideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'nonexistent' }),
/No agent registered/,
);
});
});
// ---- handleDisposeSession -------------------------------------------
suite('handleDisposeSession', () => {
test('disposes the session on the agent and removes state', async () => {
setupSession();
sideEffects.handleDisposeSession(sessionUri.toString());
await new Promise(r => setTimeout(r, 10));
assert.strictEqual(agent.disposeSessionCalls.length, 1);
assert.strictEqual(stateManager.getSessionState(sessionUri.toString()), undefined);
});
});
// ---- handleListSessions ---------------------------------------------
suite('handleListSessions', () => {
test('aggregates sessions from all agents', async () => {
await agent.createSession();
const sessions = await sideEffects.handleListSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].provider, 'mock');
assert.strictEqual(sessions[0].title, 'Session');
});
});
// ---- handleRestoreSession -----------------------------------------------
suite('handleRestoreSession', () => {
test('restores a session with message history into the state manager', async () => {
// Create a session on the agent backend (not in the state manager)
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
// Set up the agent's stored messages
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
];
// Before restore, state manager shouldn't have it
assert.strictEqual(stateManager.getSessionState(sessionResource), undefined);
await sideEffects.handleRestoreSession(sessionResource);
// After restore, state manager should have it
const state = stateManager.getSessionState(sessionResource);
assert.ok(state, 'session should be in state manager');
assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(state!.turns.length, 1);
assert.strictEqual(state!.turns[0].userMessage.text, 'Hello');
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);
});
test('restores a session with tool calls', async () => {
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' },
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] },
];
await sideEffects.handleRestoreSession(sessionResource);
const state = stateManager.getSessionState(sessionResource);
assert.ok(state);
assert.strictEqual(state!.turns.length, 1);
const turn = state!.turns[0];
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');
assert.strictEqual(tc.displayName, 'Shell');
assert.strictEqual(tc.success, true);
assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded);
});
test('restores a session with multiple turns', async () => {
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'First question', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'First answer', toolRequests: [] },
{ type: 'message', session, role: 'user', messageId: 'msg-3', content: 'Second question', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-4', content: 'Second answer', toolRequests: [] },
];
await sideEffects.handleRestoreSession(sessionResource);
const state = stateManager.getSessionState(sessionResource);
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].userMessage.text, 'First question');
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');
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 () => {
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted question', toolRequests: [] },
// No assistant message - the turn was interrupted
{ type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried question', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] },
];
await sideEffects.handleRestoreSession(sessionResource);
const state = stateManager.getSessionState(sessionResource);
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].userMessage.text, 'Interrupted question');
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');
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);
});
test('is a no-op for a session already in the state manager', async () => {
setupSession();
// Should not throw or create a duplicate
await sideEffects.handleRestoreSession(sessionUri.toString());
assert.ok(stateManager.getSessionState(sessionUri.toString()));
});
test('throws when no agent found for session', async () => {
const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, {
getAgent: () => undefined,
agents: observableValue<readonly IAgent[]>('agents', []),
sessionDataService: {} as ISessionDataService,
}, new NullLogService(), fileService));
await assert.rejects(
() => noAgentSideEffects.handleRestoreSession('unknown://session-1'),
/No agent for session/,
);
});
test('response parts include markdown segments', async () => {
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'response text', toolRequests: [] },
];
await sideEffects.handleRestoreSession(sessionResource);
const state = stateManager.getSessionState(sessionResource);
assert.ok(state);
assert.strictEqual(state!.turns[0].responseParts.length, 1);
assert.strictEqual(state!.turns[0].responseParts[0].kind, ResponsePartKind.Markdown);
assert.strictEqual(state!.turns[0].responseParts[0].content, 'response text');
});
test('throws when session is not found on backend', async () => {
// Agent exists but session is not in listSessions
await assert.rejects(
() => sideEffects.handleRestoreSession(AgentSession.uri('mock', 'nonexistent').toString()),
/Session not found on backend/,
);
});
test('preserves workingDirectory from agent metadata', async () => {
agent.sessionMetadataOverrides = { workingDirectory: URI.file('/home/user/project') };
const session = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session.toString();
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hi', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'hello', toolRequests: [] },
];
await sideEffects.handleRestoreSession(sessionResource);
const state = stateManager.getSessionState(sessionResource);
assert.ok(state);
assert.strictEqual(state!.summary.workingDirectory, URI.file('/home/user/project').toString());
});
});
// ---- handleBrowseDirectory ------------------------------------------
suite('handleBrowseDirectory', () => {
test('throws when the directory does not exist', async () => {
await assert.rejects(
() => 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' }).toString()),
/Not a directory/,
);
});
});
// ---- agents observable --------------------------------------------------
suite('agents observable', () => {
@@ -485,45 +219,6 @@ suite('AgentSideEffects', () => {
});
});
// ---- handleGetResourceMetadata / handleAuthenticate -----------------
suite('auth', () => {
test('handleGetResourceMetadata aggregates resources from agents', () => {
agentList.set([agent], undefined);
const metadata = sideEffects.handleGetResourceMetadata();
assert.strictEqual(metadata.resources.length, 0, 'mock agent has no protected resources');
});
test('handleGetResourceMetadata returns resources when agent declares them', () => {
const copilotAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => copilotAgent.dispose()));
agentList.set([copilotAgent], undefined);
const metadata = sideEffects.handleGetResourceMetadata();
assert.strictEqual(metadata.resources.length, 1);
assert.strictEqual(metadata.resources[0].resource, 'https://api.github.com');
});
test('handleAuthenticate returns authenticated for matching resource', async () => {
const copilotAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => copilotAgent.dispose()));
agentList.set([copilotAgent], undefined);
const result = await sideEffects.handleAuthenticate({ resource: 'https://api.github.com', token: 'test-token' });
assert.deepStrictEqual(result, { authenticated: true });
assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'test-token' }]);
});
test('handleAuthenticate returns not authenticated for non-matching resource', async () => {
agentList.set([agent], undefined);
const result = await sideEffects.handleAuthenticate({ resource: 'https://unknown.example.com', token: 'test-token' });
assert.deepStrictEqual(result, { authenticated: false });
});
});
// ---- Pending message sync -----------------------------------------------
suite('pending message sync', () => {

View File

@@ -9,13 +9,14 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js';
import { IFetchContentResult } from '../../common/state/protocol/commands.js';
import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IBrowseDirectoryResult, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot, type IWriteFileParams, type IWriteFileResult } from '../../common/state/sessionProtocol.js';
import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js';
import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js';
import { ProtocolServerHandler, type IProtocolSideEffectHandler } from '../../node/protocolServerHandler.js';
import { ProtocolServerHandler } from '../../node/protocolServerHandler.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
// ---- Mock helpers -----------------------------------------------------------
@@ -64,24 +65,49 @@ class MockProtocolServer implements IProtocolServer {
}
}
class MockSideEffectHandler implements IProtocolSideEffectHandler {
class MockAgentService implements IAgentService {
declare readonly _serviceBrand: undefined;
readonly handledActions: ISessionAction[] = [];
readonly browsedUris: URI[] = [];
readonly browseErrors = new Map<string, Error>();
handleAction(action: ISessionAction): void {
this.handledActions.push(action);
private readonly _onDidAction = new Emitter<import('../../common/state/sessionActions.js').IActionEnvelope>();
readonly onDidAction = this._onDidAction.event;
private readonly _onDidNotification = new Emitter<import('../../common/state/sessionActions.js').INotification>();
readonly onDidNotification = this._onDidNotification.event;
private _stateManager!: SessionStateManager;
/** Connect to the state manager so dispatchAction works correctly. */
setStateManager(sm: SessionStateManager): void {
this._stateManager = sm;
}
async handleCreateSession(_command: ICreateSessionParams): Promise<void> { /* session created via state manager */ }
handleDisposeSession(_session: string): void { }
async handleListSessions(): Promise<ISessionSummary[]> { return []; }
async handleRestoreSession(_session: string): Promise<void> { }
handleGetResourceMetadata() { return { resources: [] }; }
handleWriteFile() { return Promise.resolve({}); }
async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; }
async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> {
this.browsedUris.push(URI.parse(uri));
const error = this.browseErrors.get(uri);
dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void {
this.handledActions.push(action);
const origin = { clientId, clientSeq };
this._stateManager.dispatchClientAction(action, origin);
}
async createSession(_config?: IAgentCreateSessionConfig): Promise<URI> { return URI.parse('copilot:///new-session'); }
async disposeSession(_session: URI): Promise<void> { }
async listSessions(): Promise<IAgentSessionMetadata[]> { return []; }
async subscribe(resource: URI): Promise<IStateSnapshot> {
const snapshot = this._stateManager.getSnapshot(resource.toString());
if (!snapshot) {
throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`);
}
return snapshot;
}
unsubscribe(_resource: URI): void { }
async shutdown(): Promise<void> { }
async getResourceMetadata(): Promise<IResourceMetadata> { return { resources: [] }; }
async authenticate(_params: IAuthenticateParams): Promise<IAuthenticateResult> { return { authenticated: true }; }
async refreshModels(): Promise<void> { }
async listAgents(): Promise<IAgentDescriptor[]> { return []; }
async writeFile(_params: IWriteFileParams): Promise<IWriteFileResult> { return {}; }
async browseDirectory(uri: URI): Promise<IBrowseDirectoryResult> {
this.browsedUris.push(uri);
const error = this.browseErrors.get(uri.toString());
if (error) {
throw error;
}
@@ -92,12 +118,14 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler {
],
};
}
getDefaultDirectory(): string {
return URI.file('/home/testuser').toString();
}
async handleFetchContent(_uri: string): Promise<IFetchContentResult> {
async fetchContent(_uri: URI): Promise<IFetchContentResult> {
throw new Error('Not implemented');
}
dispose(): void {
this._onDidAction.dispose();
this._onDidNotification.dispose();
}
}
// ---- Helpers ----------------------------------------------------------------
@@ -129,7 +157,7 @@ suite('ProtocolServerHandler', () => {
let disposables: DisposableStore;
let stateManager: SessionStateManager;
let server: MockProtocolServer;
let sideEffects: MockSideEffectHandler;
let agentService: MockAgentService;
let handler: ProtocolServerHandler;
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();
@@ -160,11 +188,14 @@ suite('ProtocolServerHandler', () => {
disposables = new DisposableStore();
stateManager = disposables.add(new SessionStateManager(new NullLogService()));
server = disposables.add(new MockProtocolServer());
sideEffects = new MockSideEffectHandler();
agentService = new MockAgentService();
agentService.setStateManager(stateManager);
disposables.add(agentService);
disposables.add(handler = new ProtocolServerHandler(
agentService,
stateManager,
server,
sideEffects,
{ defaultDirectory: URI.file('/home/testuser').toString() },
new NullLogService(),
));
});
@@ -362,8 +393,8 @@ suite('ProtocolServerHandler', () => {
transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri }));
const resp = await responsePromise;
assert.strictEqual(sideEffects.browsedUris.length, 1);
assert.strictEqual(sideEffects.browsedUris[0].path, '/home/user/project');
assert.strictEqual(agentService.browsedUris.length, 1);
assert.strictEqual(agentService.browsedUris[0].path, '/home/user/project');
assert.ok(resp);
const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result;
@@ -379,7 +410,7 @@ suite('ProtocolServerHandler', () => {
transport.sent.length = 0;
const dirUri = URI.file('/missing').toString();
sideEffects.browseErrors.set(dirUri, new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`));
agentService.browseErrors.set(URI.file('/missing').toString(), 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 } };
@@ -416,9 +447,9 @@ suite('ProtocolServerHandler', () => {
});
test('extension request preserves ProtocolError code and data', async () => {
// Override handleAuthenticate to throw a ProtocolError with data
const origHandler = sideEffects.handleAuthenticate;
sideEffects.handleAuthenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); };
// Override authenticate to throw a ProtocolError with data
const origHandler = agentService.authenticate;
agentService.authenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); };
const transport = connectClient('client-auth-error');
transport.sent.length = 0;
@@ -432,7 +463,7 @@ suite('ProtocolServerHandler', () => {
assert.strictEqual(resp.error!.message, 'Auth required');
assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' });
sideEffects.handleAuthenticate = origHandler;
agentService.authenticate = origHandler;
});
// ---- Connection count event -----------------------------------------