diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index dc87add4e8d..efd589eb232 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -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. diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 84daff21d51..0e0914d645c 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -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. */ diff --git a/src/vs/platform/agentHost/node/agentPeerChats.ts b/src/vs/platform/agentHost/node/agentPeerChats.ts index 3bf0ef0310c..b2564768274 100644 --- a/src/vs/platform/agentHost/node/agentPeerChats.ts +++ b/src/vs/platform/agentHost/node/agentPeerChats.ts @@ -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 } : {}), diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c85fe84f6ea..3c1b3873ca1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -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 } : {}), }; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 9800465984f..f54f867fe5f 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -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 } : {}), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 21fed4f470c..b9668357f0e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -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 } : {}), diff --git a/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts b/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts index 1a6fd6d0a89..823dc1005f1 100644 --- a/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts @@ -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, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ca75d5ac639..61041dbf1a2 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -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); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 50a1dd56682..032f7ad7fdf 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -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'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 7d3a3a90da2..12a8afc50ff 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -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 {