agentHost: preserve local side chat context

Keep the selected local turn as side-chat provenance while using a separate concrete provider anchor. Inject the bounded local context exactly once on the first side-chat prompt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Connor Peet
2026-07-24 10:52:33 -07:00
co-authored by Copilot
parent e8df488cdb
commit 37d99fc160
10 changed files with 231 additions and 14 deletions
+1
View File
@@ -195,3 +195,4 @@ You **must** run these checks before declaring work complete:
- **Docked side-pane width persistence must be symmetric about the detail (aux) width**: in single-pane the docked detail (auxiliary bar) lives *inside* the editor grid node, so the workbench persists the pure editor-content width (`_persistedEditorWidth` = node detail) and the grid descriptor reconstructs node = editor-content + detail. These must use the **same condition** for including the detail: only when the detail is visible (`partVisibility.auxiliaryBar`). Subtracting the detail width *unconditionally* at save while adding it back only when the detail is visible shrank an **Editor-only** session's side pane by the detail width on *every* reload, compounding toward zero ("side pane always tiny on reload"). Fix `_persistedEditorWidth` to subtract only when the detail is visible.
- **Side-pane (editor grid node) size is workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes` via `_savePartSizes`/`createDesktopGridDescriptor`), so switching sessions keeps the same width and reload restores it in one paint. Do **not** add a per-session width map in the layout controller that re-applies a width on session switch/reveal — it makes switching sessions jump the side pane around, and a post-paint restore on reload flickers. The 60% first-open default (`SIDE_PANE_WIDTH_RATIO` in `parts/editorPartSizing.ts`, applied by the single-pane `_applyEditorSplitSize` override on the first reveal that has no size to restore) is the only width the layout intentionally sets; everything else is the user's persisted grid size.
- **Per-session menu visibility must flow through scoped context keys, not widget DOM hiding or global active-session keys**: toolbars evaluate `when` against their scoped `IContextKeyService`, while their pickers act on scoped `ISessionContext`. Expose provider-specific availability as an observable on `ISession` and publish it through `setSessionContextKeys`, so every visible session surface evaluates the same scoped state declaratively.
- **Keep validation proportional to the change**: once focused tests cover a small correction, do not chain repeated reviewers or broad suites unless a concrete failure warrants escalation.
@@ -993,6 +993,8 @@ export interface IAgentCreateChatSideChatSource {
readonly source: URI;
/** Turn ID in the source chat the side chat records as its provenance. */
readonly turnId: string;
/** Concrete provider turn ID to fork/resume from when `turnId` names a host-only local turn. */
readonly providerAnchorTurnId?: string;
/** Bounded source-chat context captured from host state when the provider transcript lags. */
readonly sourceContext?: string;
/** User-visible assistant text captured while the source turn was active. */
@@ -16,6 +16,7 @@ export const MAX_SIDE_CHAT_CONTEXT_CHARS = 20_000;
export interface IPersistedSideChat {
readonly source: string;
readonly turnId: string;
readonly providerAnchorTurnId?: string;
readonly inheritedTurnCount: number;
readonly partialResponse?: string;
readonly context?: string;
@@ -50,6 +51,14 @@ export function getSideChatPartialResponse(activeTurn: ActiveTurn | undefined):
return responseMarkdown ? truncateMiddle(responseMarkdown, MAX_SIDE_CHAT_CONTEXT_CHARS) : undefined;
}
export function buildBoundedSideChatSourceContext(turns: readonly Turn[], turnId: string, activeTurn?: ActiveTurn): string | undefined {
if (activeTurn?.id === turnId) {
return buildSideChatSourceContext(turns, activeTurn);
}
const turnIndex = turns.findIndex(turn => turn.id === turnId);
return turnIndex === -1 ? undefined : buildSideChatSourceContext(turns.slice(0, turnIndex + 1));
}
export function injectSideChatContext(prompt: string, partialResponse?: string, sourceContext?: string): string {
const context = [SIDE_CHAT_GUIDANCE];
if (sourceContext) {
@@ -77,11 +86,11 @@ export function prepareSideChatPrompt(prompt: string, turns: readonly Turn[], si
if (!sideChat || turns.length > sideChat.inheritedTurnCount) {
return prompt;
}
const sourceTurn = turns.find(turn => turn.id === sideChat.turnId);
const sourceContext = sourceTurn ? undefined : sideChat.context;
const selectedSourceTurn = turns.find(turn => turn.id === sideChat.turnId);
const sourceContext = selectedSourceTurn ? undefined : sideChat.context;
let partialResponse = sideChat.partialResponse;
if (partialResponse) {
const inheritedResponse = sourceTurn ? renderResponseMarkdown(sourceTurn.responseParts) : '';
const inheritedResponse = selectedSourceTurn ? renderResponseMarkdown(selectedSourceTurn.responseParts) : '';
if (inheritedResponse.includes(partialResponse)) {
partialResponse = undefined;
}
@@ -180,16 +189,18 @@ export function decodeProviderData(providerData: string): IPersistedChat | undef
const validModel = model && typeof model === 'object' && typeof (model as { id?: unknown }).id === 'string'
? model as ModelSelection
: undefined;
const sideChat = value.sideChat as { source?: unknown; turnId?: unknown; inheritedTurnCount?: unknown; partialResponse?: unknown; context?: unknown } | undefined;
const sideChat = value.sideChat as { source?: unknown; turnId?: unknown; providerAnchorTurnId?: unknown; inheritedTurnCount?: unknown; partialResponse?: unknown; context?: unknown } | undefined;
const validSideChat = sideChat
&& typeof sideChat.source === 'string'
&& typeof sideChat.turnId === 'string'
&& (sideChat.providerAnchorTurnId === undefined || typeof sideChat.providerAnchorTurnId === 'string')
&& typeof sideChat.inheritedTurnCount === 'number'
&& (sideChat.partialResponse === undefined || typeof sideChat.partialResponse === 'string')
&& (sideChat.context === undefined || typeof sideChat.context === 'string')
? {
source: sideChat.source,
turnId: sideChat.turnId,
...(sideChat.providerAnchorTurnId ? { providerAnchorTurnId: sideChat.providerAnchorTurnId } : {}),
inheritedTurnCount: sideChat.inheritedTurnCount,
...(sideChat.partialResponse ? { partialResponse: sideChat.partialResponse } : {}),
...(sideChat.context ? { context: sideChat.context } : {}),
+11 -8
View File
@@ -36,7 +36,7 @@ import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../comm
import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { IProductService } from '../../product/common/productService.js';
import { buildSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js';
import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js';
import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js';
@@ -1193,14 +1193,11 @@ export class AgentService extends Disposable implements IAgentService {
sideChat: {
...options.sideChat,
source: URI.parse(resolvedSideChat.sourceChat),
...(resolvedSideChat.providerAnchorTurnId ? { providerAnchorTurnId: resolvedSideChat.providerAnchorTurnId } : {}),
...(resolvedSideChat.sourceContext ? { sourceContext: resolvedSideChat.sourceContext } : {}),
...(resolvedSideChat.partialResponse ? { partialResponse: resolvedSideChat.partialResponse } : {}),
},
};
const concreteTurnId = this._localTurns.resolveConcreteTurnId(resolvedSideChat.sourceChat, options.sideChat.turnId);
if (concreteTurnId !== undefined) {
createOptions = { ...createOptions, sideChat: { ...createOptions.sideChat!, turnId: concreteTurnId } };
}
}
if (options?.fork) {
const sourceKey = options.fork.source.toString();
@@ -1284,7 +1281,7 @@ export class AgentService extends Disposable implements IAgentService {
* origin. Throws when the source chat is not part of `session` or when the
* referenced completed or active turn is absent.
*/
private _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): { origin: ChatOrigin; sourceChat: string; sourceContext?: string; partialResponse?: string } {
private _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): { origin: ChatOrigin; sourceChat: string; providerAnchorTurnId?: string; sourceContext?: string; partialResponse?: string } {
const sessionKey = session.toString();
const sourceKey = sideChat.source.toString();
const { sourceChatKey, sourceSessionKey, sourceState } = this._resolveSessionSourceChat(session, sideChat.source);
@@ -1296,11 +1293,16 @@ export class AgentService extends Disposable implements IAgentService {
}
// The bounded turn must be a real completed or currently-active turn.
const activeTurn = sourceState?.activeTurn?.id === sideChat.turnId ? sourceState.activeTurn : undefined;
if (!sourceState?.turns.some(t => t.id === sideChat.turnId) && !activeTurn) {
const hasCompletedTurn = sourceState?.turns.some(t => t.id === sideChat.turnId) ?? false;
if (!hasCompletedTurn && !activeTurn) {
throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`);
}
const isLocalSourceTurn = !activeTurn && this._localTurns.isLocal(sourceChatKey, sideChat.turnId);
const providerAnchorTurnId = isLocalSourceTurn ? this._localTurns.resolveConcreteTurnId(sourceChatKey, sideChat.turnId) : undefined;
const partialResponse = getSideChatPartialResponse(activeTurn);
const sourceContext = activeTurn ? buildSideChatSourceContext(sourceState?.turns ?? [], activeTurn) : undefined;
const sourceContext = (activeTurn || isLocalSourceTurn)
? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], sideChat.turnId, activeTurn)
: undefined;
return {
origin: {
kind: ChatOriginKind.SideChat,
@@ -1308,6 +1310,7 @@ export class AgentService extends Disposable implements IAgentService {
turnId: sideChat.turnId,
},
sourceChat: sourceChatKey,
...(providerAnchorTurnId ? { providerAnchorTurnId } : {}),
...(sourceContext ? { sourceContext } : {}),
...(partialResponse ? { partialResponse } : {}),
};
@@ -1256,7 +1256,7 @@ export class ClaudeAgent extends Disposable implements IAgent {
// chat rather than inheriting the whole source backend.
sdkSessionId = (await this._forkChat(session, options.fork))?.sessionId;
} else if (options?.sideChat) {
const forked = await this._forkChat(session, options.sideChat);
const forked = await this._forkChat(session, { source: options.sideChat.source, turnId: options.sideChat.providerAnchorTurnId ?? options.sideChat.turnId });
sdkSessionId = forked?.sessionId;
const fallbackContext = options.sideChat.sourceContext ?? (!forked ? this._buildSideChatContext(session, options.sideChat.source, options.sideChat.turnId) : undefined);
if (!forked && !fallbackContext && !options.sideChat.partialResponse) {
@@ -1265,6 +1265,7 @@ export class ClaudeAgent extends Disposable implements IAgent {
sideChat = {
source: options.sideChat.source.toString(),
turnId: options.sideChat.turnId,
...(options.sideChat.providerAnchorTurnId ? { providerAnchorTurnId: options.sideChat.providerAnchorTurnId } : {}),
inheritedTurnCount: forked?.inheritedTurnCount ?? 0,
...(fallbackContext ? { context: fallbackContext } : {}),
...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}),
@@ -2287,11 +2287,12 @@ export class CopilotAgent extends Disposable implements IAgent {
if (!sourceEntry) {
throw new Error(`[Copilot] createChat side chat: source chat ${options.sideChat.source.toString()} not found`);
}
const forked = await this._forkSdkChat(client, sourceEntry, options.sideChat.turnId, this._sessionDataService.getSessionDataDir(chat));
const forked = await this._forkSdkChat(client, sourceEntry, options.sideChat.providerAnchorTurnId ?? options.sideChat.turnId, this._sessionDataService.getSessionDataDir(chat));
sdkSessionId = forked.sessionId;
sideChat = {
source: options.sideChat.source.toString(),
turnId: options.sideChat.turnId,
...(options.sideChat.providerAnchorTurnId ? { providerAnchorTurnId: options.sideChat.providerAnchorTurnId } : {}),
inheritedTurnCount: forked.inheritedTurnCount,
...(options.sideChat.sourceContext ? { context: options.sideChat.sourceContext } : {}),
...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}),
@@ -121,6 +121,28 @@ suite('agentPeerChats', () => {
});
});
test('injects completed local-turn context even when the inherited transcript already contains the concrete provider anchor', () => {
const sourceContext = 'User request:\nsource question\n\nAgent response:\nsource answer\n\n---\n\nUser request:\n!command';
const localSideChat: IPersistedSideChat = {
source: 'ahp-chat://default/source',
turnId: 'local-turn',
providerAnchorTurnId: sourceTurn.id,
inheritedTurnCount: 1,
context: sourceContext,
};
const prepared = prepareSideChatPrompt('Explain the branch', [sourceTurn], localSideChat);
assert.deepStrictEqual({
prepared,
localQuestionCount: countOccurrences(prepared, 'User request:\n!command'),
sourceQuestionCount: countOccurrences(prepared, 'User request:\nsource question'),
}, {
prepared: injectSideChatContext('Explain the branch', undefined, sourceContext),
localQuestionCount: 1,
sourceQuestionCount: 1,
});
});
test('strips hidden context even when the source text contains the legacy delimiter', () => {
const prepared = prepareSideChatPrompt('Visible prompt', [], {
...sideChat,
@@ -3336,6 +3336,50 @@ suite('AgentService (node dispatcher)', () => {
});
});
test('creates a side chat from a completed local turn without losing its stable source turn identity', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new SideChatAgent('copilot'));
localService.registerProvider(agent);
const { session } = await agent.createSession();
const sessionResource = (await agent.listSessions())[0].session;
const defaultChatUri = buildDefaultChatUri(sessionResource.toString());
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'real-1', content: 'first question', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'first answer', toolRequests: [] },
];
const localTurn: Turn = {
id: 'local-1',
state: TurnState.Complete,
message: { text: '!command', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
};
await db.insertLocalTurn({ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) });
await localService.restoreSession(sessionResource);
const chatUri = URI.parse(buildChatUri(sessionResource, 'side-local'));
await localService.createChat(sessionResource, chatUri, { sideChat: { source: URI.parse(defaultChatUri), turnId: 'local-1' } });
assert.deepStrictEqual({
origin: localService.stateManager.getChatState(chatUri.toString())?.origin,
sideChatForwarded: agent.lastCreateOptions?.sideChat && {
source: agent.lastCreateOptions.sideChat.source.toString(),
turnId: agent.lastCreateOptions.sideChat.turnId,
providerAnchorTurnId: agent.lastCreateOptions.sideChat.providerAnchorTurnId,
sourceContext: agent.lastCreateOptions.sideChat.sourceContext,
},
}, {
origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 'local-1' },
sideChatForwarded: {
source: defaultChatUri,
turnId: 'local-1',
providerAnchorTurnId: 'real-1',
sourceContext: 'User request:\nfirst question\n\nAgent response:\nfirst answer\n\n---\n\nUser request:\n!command',
},
});
});
test('creates a side chat from the current active turn', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
@@ -6846,6 +6846,59 @@ suite('ClaudeAgent — Phase 11 customizations', () => {
});
});
test('createChat({ sideChat }) preserves a local source turn id while forking from the concrete provider anchor', async () => {
const { agent, sdk } = createTestContext(disposables);
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok');
const created = await agent.createSession({ workingDirectory: URI.file('/work') });
const parentId = AgentSession.id(created.session);
sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId));
sdk.forkSessionResult = { sessionId: 'side-local-1' };
sdk.sessionList = [{ sessionId: 'side-local-1', summary: 'side local', lastModified: 1, cwd: URI.file('/work').fsPath }];
const sourceContext = 'User request:\nsource question\n\nAgent response:\nsource answer\n\n---\n\nUser request:\n!command';
const injectedPrompt = injectSideChatContext('side question', undefined, sourceContext);
sdk.sessionMessagesById.set('side-local-1', forkSourceMessages('side-local-1').slice(0, 2));
const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-side-local'));
const result = await agent.chats.createChat(chatUri, {
sideChat: {
source: created.session,
turnId: 'local-1',
providerAnchorTurnId: 'u1',
sourceContext,
},
});
sdk.nextQueryMessages = [makeSystemInitMessage('side-local-1'), makeResultSuccess('side-local-1')];
await agent.chats.sendMessage(chatUri, 'side question', undefined, undefined, 'turn-side-local');
const sentContent = sdk.warmQueries.at(-1)?.produced?.drainedPrompts[0]?.message.content;
const sentPrompt = typeof sentContent === 'string'
? sentContent
: sentContent?.filter(block => block.type === 'text').map(block => block.text).join('\n');
sdk.sessionMessagesById.set('side-local-1', [
...forkSourceMessages('side-local-1').slice(0, 2),
{ type: 'user', uuid: 'turn-side-local', session_id: 'side-local-1', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text: injectedPrompt }] } },
{ type: 'assistant', uuid: 'a3', session_id: 'side-local-1', parent_tool_use_id: null, message: { id: 'msg_a3', role: 'assistant', content: [{ type: 'text', text: 'side answer' }] } },
]);
const turns = await agent.chats.getMessages(chatUri);
assert.deepStrictEqual({
forkCall: sdk.forkSessionCalls[0],
sentPrompt,
turns: turns.map(turn => turn.message.text),
sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined,
}, {
forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } },
sentPrompt: injectedPrompt,
turns: ['side question'],
sideChat: {
source: created.session.toString(),
turnId: 'local-1',
providerAnchorTurnId: 'u1',
inheritedTurnCount: 1,
context: sourceContext,
},
});
});
test('createChat({ fork }) with an unknown turn falls back to a fresh chat', async () => {
const { agent, sdk } = createTestContext(disposables);
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok');
@@ -3555,6 +3555,85 @@ suite('CopilotAgent', () => {
}
});
test('createChat side chat preserves a local source turn id while forking from the concrete provider anchor', async () => {
const sessionDataService = disposables.add(new TestSessionDataService());
const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) });
try {
await agent.authenticate('https://api.github.com', 'token');
const session = AgentSession.uri('copilotcli', 'side-local-peer');
await agent.createSession({ session, workingDirectory: URI.file('/workspace') });
const sourceTurn: Turn = {
id: 't1',
state: TurnState.Complete,
message: { text: 'source', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
};
const sourceContext = 'User request:\nsource\n\nAgent response:\nsource answer\n\n---\n\nUser request:\n!command';
const injectedPrompt = injectSideChatContext('side', undefined, sourceContext);
const sideTurn: Turn = {
id: 't2',
state: TurnState.Complete,
message: { text: injectedPrompt, origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
};
const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]);
setDefaultSessionStub(agent, AgentSession.id(session), source.fake);
const internals = agent as unknown as ChatInternals;
let forkTurnId: string | undefined;
internals._forkSdkChat = async (_client, _sourceEntry, turnId) => {
forkTurnId = turnId;
return { sessionId: 'side-sdk-id', inheritedTurnCount: 1 };
};
let messageReadCount = 0;
let sideRecorder: IFakeChatRecorder | undefined;
internals._createAgentSession = launchPlan => {
const side = makeFakeChatSession(session, launchPlan.sessionId, async () => {
messageReadCount++;
return messageReadCount <= 2 ? [sourceTurn] : [sourceTurn, sideTurn];
}, launchPlan.shellManager);
sideRecorder = side.rec;
return side.fake;
};
const chatUri = URI.parse(buildChatUri(session, 'peer-side-local'));
const result = await agent.chats.createChat(chatUri, {
sideChat: {
source: URI.parse(buildDefaultChatUri(session)),
turnId: 'local-1',
providerAnchorTurnId: 't1',
sourceContext,
},
});
await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2');
await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3');
const turns = await agent.chats.getMessages(chatUri);
assert.deepStrictEqual({
forkTurnId,
sentPrompts: sideRecorder?.sends.map(send => send.prompt),
turns: turns.map(turn => turn.id),
visiblePrompt: turns[0]?.message.text,
sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined,
}, {
forkTurnId: 't1',
sentPrompts: [injectedPrompt, 'follow-up'],
turns: ['t2'],
visiblePrompt: 'side',
sideChat: {
source: buildDefaultChatUri(session),
turnId: 'local-1',
providerAnchorTurnId: 't1',
inheritedTurnCount: 1,
context: sourceContext,
},
});
} finally {
await disposeAgent(agent);
}
});
test('sendMessage routes a turn to the targeted peer chat only', async () => {
const agent = createTestAgent(disposables);
try {