mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-25 11:08:51 +01:00
Fix loading sessions that were created by a previous remote agent host instance (#304344)
* Fix loading sessions that were created by a previous instance of the server Co-authored-by: Copilot <copilot@github.com> * Add handleRestoreSession to agentHostMain side effects Wire up the handleRestoreSession method in the utility process agent host entry point, delegating to AgentService which forwards to AgentSideEffects. This was missing after the interface was updated to require session restore support. * Address Copilot review: wrap backend errors, use Cancelled for interrupted turns - Wrap agent.listSessions() and agent.getSessionMessages() calls in try/catch so raw backend errors become ProtocolErrors instead of leaking stack traces to clients. - Use TurnState.Cancelled instead of TurnState.Complete for interrupted/dangling turns during session restoration. - Update test assertions to match new interrupted turn state. (Written by Copilot) --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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, SessionStatus } from '../../common/state/sessionState.js';
|
||||
import { PermissionKind, ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IToolCallCompletedState } from '../../common/state/sessionState.js';
|
||||
import { AgentSideEffects } from '../../node/agentSideEffects.js';
|
||||
import { SessionStateManager } from '../../node/sessionStateManager.js';
|
||||
import { MockAgent } from './mockAgent.js';
|
||||
@@ -296,6 +296,181 @@ suite('AgentSideEffects', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 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');
|
||||
assert.strictEqual(state!.turns[0].responseText, '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];
|
||||
assert.strictEqual(turn.toolCalls.length, 1);
|
||||
const tc = turn.toolCalls[0] 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');
|
||||
assert.strictEqual(state!.turns[0].responseText, 'First answer');
|
||||
assert.strictEqual(state!.turns[1].userMessage.text, 'Second question');
|
||||
assert.strictEqual(state!.turns[1].responseText, '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');
|
||||
assert.strictEqual(state!.turns[0].responseText, '');
|
||||
assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);
|
||||
assert.strictEqual(state!.turns[1].userMessage.text, 'Retried question');
|
||||
assert.strictEqual(state!.turns[1].responseText, '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: '/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, '/home/user/project');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- handleBrowseDirectory ------------------------------------------
|
||||
|
||||
suite('handleBrowseDirectory', () => {
|
||||
|
||||
Reference in New Issue
Block a user