diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 8574a5c082513..ff1bdfea16ee8 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -41,6 +41,10 @@ Then read the relevant spec for the area you are changing (see table below). If - **Collapsing distinct provider identities in pickers**: Do not collapse extension-backed chat session ids (e.g. `copilotcli`) and agent-host ids (e.g. `agent-host-copilotcli`) based only on friendly names or well-known provider enums. They can coexist in the Agents window and route to different infrastructure; keep the exact session type id through selection/delegation and hide ambiguous legacy targets when an agent-host target supersedes them. - **Resolving a session's provider via the create-only tracking map**: On the agent host, resolve the owning provider for any per-session operation (createChat, disposeChat, sendMessage, …) through `AgentService._findProviderForSession`, never the raw `_sessionToProvider` map. That map is populated only by `createSession`, so a **restored** session (alive in the state manager after a host restart but never created in this process) is absent from it — a direct lookup throws `no provider for session` and silently breaks the feature (e.g. Add Chat did nothing for restored sessions while messaging worked, because messaging already used the fallback). `_findProviderForSession` falls back to the session URI's scheme provider, which is what makes restored sessions work. - **Dispatching per-chat side-channel actions (agent/model) to the session URI**: An agent-host session can own multiple peer chats, each with its own backend conversation (`CopilotAgent._chatSessions`). Conversation side-channel actions like `SessionAgentChanged`/`SessionModelChanged` must be dispatched to the per-chat **turn channel** (`_resolveTurnDispatchChannel`, which carries a `chatId` fragment for peer chats), not `session.toString()`. The session URI resolves to the session's *default* chat (`_sessions`), so dispatching there silently applies the change to the wrong conversation and an additional chat never sees the agent/model swap. The host must also forward the `chatChannel` through `agentSideEffects.handleAction` → `changeAgent`/`changeModel`, which apply it to `_chatSessions` when present. The protocol models `summary.agent`/`summary.model` at session level only, so equality guards comparing against session summary are valid for the default chat but must be skipped for peer chats. +- **Do not infer or fall back from a peer chat channel after progress was emitted**: Agent progress signals for chat-scoped actions, especially tool-call readiness and permission requests, must be emitted with the exact `ahp-chat://...` channel that owns the tool. Do not recover by scanning active turns, remapping `ChatToolCallConfirmed`, or using `parseDefaultChatUri(...) ?? sessionUri` in `AgentSideEffects`; malformed/misrouted chat channels should fail loudly so the producer or dispatch path is fixed. `handleToolCallConfirmed` and `_toolCallAgents` must use the chat channel URI containing the tool call; keying by the parent session URI makes confirmations miss the pending SDK request. +- **Do not synthesize default chat URIs in the workbench handler**: `AgentHostSessionHandler` must source the upstream default chat URI from hydrated `SessionState.defaultChat` / `SessionState.chats` and store that mapping in its chat-resource-to-upstream-URI map. Calling `buildDefaultChatUri(session)` in the handler assumes one server URI shape and hides protocol/provider bugs; dispatch turn lifecycle and pending/input actions through the mapped upstream chat URI instead. +- **Model subagents as chats, not sessions**: A subagent spawned from a tool call belongs to the parent session as an additional chat with `origin.kind === "tool"`, hidden from the chat tab strip. Do not call `restoreSession` for subagents; that creates `_sessionStates` without a matching `_chatStates` entry, so later chat actions hit "Action for unknown chat". Add a chat on the parent session and dispatch the subagent turn to that chat URI. +- **Keep case-sensitive ids out of URI authority**: URI authorities are case-insensitive, so do not place tool call ids in the `ahp-chat` authority. Subagent chat URIs use a stable `subagent` authority and put the encoded tool call id in the path; use `buildSubagentChatUri(...)` instead of `buildChatUri(..., \`subagent-${toolCallId}\`)`. - **Selected custom agent must be in the SDK's `customAgents`, not just `pluginDirectories`**: The Copilot SDK validates the session-start `agent:` option (passed to `createSession`/`resumeSession`) against the `customAgents` list **by name only** — it does NOT consult `pluginDirectories`. `copilotSessionLauncher._buildSessionConfig` deliberately omits agents from file-dir plugins from `customAgents` (relying on the SDK's `pluginDirectories` discovery to avoid duplicates), so selecting a plugin/extension-contributed agent (e.g. "Inbox") otherwise fails with `Custom agent '' not found`. The fix (`toSdkSessionCustomAgents`) force-adds the resolved selected agent into `customAgents` while every other file-dir agent still loads via `pluginDirectories`. Note the agent picker offers VS Code chat modes from `IChatModeService`, but only `plugin`/`extension` storage agents are synced to the host (`SYNCABLE_STORAGE_SOURCES`); `user`/`local` agents are never synced, so `_resolveAgentName` returns `undefined` for them and no `agent:` is sent. - **Derive SDK custom-agent names exactly like `parseAgentFile`**: `_resolveAgentName` resolves the selected agent through the plugin parser, which trims the frontmatter `name` (`getStringValue('name')?.trim() || nameFromFile`). When building the SDK `customAgents` list (`toSdkCustomAgents`), derive the name the same way (`?.trim() || agent.name`); reading the raw frontmatter `name` without trimming yields a config name that won't match the trimmed `resolvedAgentName`, so the SDK still rejects the session with `Custom agent '' not found`. - **Peer chats have no server `summary`, so dedup side-channel dispatch against the last value sent for that chat**: equality guards before dispatching `SessionModelChanged`/`SessionAgentChanged` compare against `summary.model`/`summary.agent`, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on the `AgentHostChatSession` instance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (`undefined`) can't be detected. diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts index bced49d5b4925..e9b7a4c065ff2 100644 --- a/src/vs/base/common/map.ts +++ b/src/vs/base/common/map.ts @@ -919,21 +919,79 @@ export class NKeyMap { return currentMap.get(keys[keys.length - 1]); } + public delete(...keys: [...TKeys]): boolean { + const maps: Map[] = [this._data]; + let currentMap = this._data; + for (let i = 0; i < keys.length - 1; i++) { + const nextMap = currentMap.get(keys[i]); + if (nextMap === undefined) { + return false; + } + currentMap = nextMap; + maps.push(currentMap); + } + const deleted = currentMap.delete(keys[keys.length - 1]); + for (let i = keys.length - 2; deleted && i >= 0; i--) { + if (maps[i + 1].size === 0) { + maps[i].delete(keys[i]); + } + } + return deleted; + } + + public deleteAll(...keys: Partial): boolean { + if (keys.length === 0) { + const hadData = this._data.size > 0; + this._data.clear(); + return hadData; + } + const maps: Map[] = [this._data]; + let currentMap = this._data; + for (let i = 0; i < keys.length - 1; i++) { + const nextMap = currentMap.get(keys[i]); + if (nextMap === undefined) { + return false; + } + currentMap = nextMap; + maps.push(currentMap); + } + const deleted = currentMap.delete(keys[keys.length - 1]); + for (let i = keys.length - 2; deleted && i >= 0; i--) { + if (maps[i + 1].size === 0) { + maps[i].delete(keys[i]); + } + } + return deleted; + } + public clear(): void { this._data.clear(); } + public *getAll(...keys: Partial): IterableIterator { + let currentMap = this._data; + for (const key of keys) { + const nextMap = currentMap.get(key); + if (nextMap === undefined) { + return; + } + currentMap = nextMap; + } + yield* this._values(currentMap); + } + public *values(): IterableIterator { - function* iterate(map: Map): IterableIterator { - for (const value of map.values()) { - if (value instanceof Map) { - yield* iterate(value); - } else { - yield value; - } + yield* this._values(this._data); + } + + private *_values(map: Map): IterableIterator { + for (const value of map.values()) { + if (value instanceof Map) { + yield* this._values(value); + } else { + yield value; } } - yield* iterate(this._data); } /** diff --git a/src/vs/base/test/common/map.test.ts b/src/vs/base/test/common/map.test.ts index 895726ab312d4..dd7c4a1841a04 100644 --- a/src/vs/base/test/common/map.test.ts +++ b/src/vs/base/test/common/map.test.ts @@ -716,6 +716,49 @@ suite('NKeyMap', () => { assert.deepStrictEqual(Array.from(map.values()), [1, 2, 3]); }); + test('getAll', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'a', 'e', 'f'); + map.set(4, 'g', 'h', 'i'); + assert.deepStrictEqual(Array.from(map.getAll('a', 'b')), [1, 2]); + assert.deepStrictEqual(Array.from(map.getAll('a')), [1, 2, 3]); + assert.deepStrictEqual(Array.from(map.getAll('missing')), []); + }); + + test('delete', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'x', 'y', 'z'); + assert.strictEqual(map.delete('a', 'b', 'c'), true); + assert.strictEqual(map.delete('a', 'b', 'c'), false); + assert.deepStrictEqual(Array.from(map.values()), [2, 3]); + }); + + test('deleteAll', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'a', 'e', 'f'); + map.set(4, 'g', 'h', 'i'); + assert.strictEqual(map.deleteAll('a', 'b'), true); + assert.deepStrictEqual(Array.from(map.values()), [3, 4]); + assert.strictEqual(map.deleteAll('missing'), false); + assert.strictEqual(map.deleteAll(), true); + assert.deepStrictEqual(Array.from(map.values()), []); + }); + + test('deleteAll cleans empty parent maps', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'x', 'y', 'z'); + assert.strictEqual(map.deleteAll('a', 'b'), true); + assert.strictEqual(map.deleteAll('a'), false); + assert.deepStrictEqual(Array.from(map.values()), [2]); + }); + test('toString', () => { const map = new NKeyMap(); map.set(1, 'f', 'o', 'o'); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e9654b37d4399..f4ac9d2b12312 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -720,13 +720,13 @@ export type AgentSignal = * dispatches the action through the state manager after routing via * {@link IAgentActionSignal.parentToolCallId} (if set). * - * Agents are responsible for populating `session` and any `turnId` / + * Agents are responsible for populating the target channel and any `turnId` / * `partId` fields on the action. */ export interface IAgentActionSignal { readonly kind: 'action'; - /** Top-level session URI. For inner subagent events this is the parent session — see {@link parentToolCallId}. */ - readonly session: URI; + /** Target session or chat channel URI. For inner subagent events this is the parent session — see {@link parentToolCallId}. */ + readonly resource: URI; /** Protocol action to dispatch. */ readonly action: SessionAction | ChatAction; /** If set, route the action to the subagent session belonging to this tool call. */ @@ -749,7 +749,8 @@ export interface IAgentActionSignal { */ export interface IAgentToolPendingConfirmationSignal { readonly kind: 'pending_confirmation'; - readonly session: URI; + /** Target chat channel URI containing the tool call. */ + readonly chat: URI; /** Protocol-shaped pending-confirmation state, dispatched verbatim into `ChatToolCallReady`. */ readonly state: ToolCallPendingConfirmationState; /** Host-only auto-approval kind (not part of the dispatched action). */ @@ -781,7 +782,7 @@ export interface IAgentToolPendingConfirmationSignal { */ export interface IAgentSubagentStartedSignal { readonly kind: 'subagent_started'; - readonly session: URI; + readonly chat: URI; readonly toolCallId: string; readonly agentName: string; readonly agentDisplayName: string; @@ -797,14 +798,14 @@ export interface IAgentSubagentStartedSignal { */ export interface IAgentSubagentCompletedSignal { readonly kind: 'subagent_completed'; - readonly session: URI; + readonly chat: URI; readonly toolCallId: string; } /** A steering message was consumed (sent to the model). */ export interface IAgentSteeringConsumedSignal { readonly kind: 'steering_consumed'; - readonly session: URI; + readonly chat: URI; readonly id: string; } @@ -936,10 +937,8 @@ export interface IAgent { /** Return dynamic completions for a session configuration property. */ sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; - /** Send a user message into an existing session. When `chat` is provided - * (and differs from the default chat), the harness routes the message to - * that specific chat within a multi-chat session. */ - sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise; + /** Send a user message into a chat within an existing session. */ + sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise; /** * Create an additional chat within an existing session, backed by a new diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 5a8eb2e1eacf2..b49803e39d049 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -46,6 +46,9 @@ export interface IAgentSubscription { /** Fires when {@link value} changes (optimistic or confirmed). */ readonly onDidChange: Event; + /** Fires when the subscription enters an error state. */ + readonly onDidError?: Event; + /** Fires before a server-originated action is applied to this subscription's state. */ readonly onWillApplyAction: Event; @@ -103,6 +106,9 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs protected readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; + protected readonly _onDidError = this._register(new Emitter()); + readonly onDidError: Event = this._onDidError.event; + protected readonly _onWillApplyAction = this._register(new Emitter()); readonly onWillApplyAction: Event = this._onWillApplyAction.event; @@ -144,6 +150,7 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs */ setError(error: Error): void { this._error = error; + this._onDidError.fire(error); } /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 7c0ac7b3deb4d..0e76e2ef23c56 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -617,6 +617,14 @@ export function buildDefaultChatUri(sessionUri: ProtocolURI | ResourceURI): stri return buildChatUri(sessionUri, DEFAULT_CHAT_ID); } +const SUBAGENT_CHAT_ID = 'subagent'; + +export function buildSubagentChatUri(sessionUri: ProtocolURI | ResourceURI, toolCallId: string): string { + const session = typeof sessionUri === 'string' ? sessionUri : sessionUri.toString(); + const encoded = encodeBase64(VSBuffer.fromString(session), false, true); + return `${AHP_CHAT_SCHEME}://${SUBAGENT_CHAT_ID}/${encoded}/${encodeURIComponent(toolCallId)}`; +} + /** * Inverse of {@link buildChatUri}: recovers the owning session URI and chat id * from any chat channel URI. Returns `undefined` when `uri` is not a well-formed @@ -637,6 +645,14 @@ export function parseChatUri(uri: ProtocolURI | ResourceURI): { session: string; return undefined; } try { + if (parsed.authority === SUBAGENT_CHAT_ID) { + const [sessionPart, ...toolCallIdParts] = encoded.split('/'); + const toolCallId = toolCallIdParts.join('/'); + if (!sessionPart || !toolCallId) { + return undefined; + } + return { session: decodeBase64(sessionPart).toString(), chatId: `${SUBAGENT_CHAT_ID}/${decodeURIComponent(toolCallId)}` }; + } return { session: decodeBase64(encoded).toString(), chatId: parsed.authority }; } catch { return undefined; @@ -653,6 +669,14 @@ export function parseDefaultChatUri(uri: ProtocolURI | ResourceURI): string | un return parseChatUri(uri)?.session; } +export function parseRequiredSessionUriFromChatUri(uri: ProtocolURI | ResourceURI): string { + const session = parseDefaultChatUri(uri); + if (session === undefined) { + throw new Error(`Malformed AHP chat URI: ${typeof uri === 'string' ? uri : uri.toString()}`); + } + return session; +} + /** Returns `true` when `uri` is the default chat of its session. */ export function isDefaultChatUri(uri: ProtocolURI | ResourceURI): boolean { return parseChatUri(uri)?.chatId === DEFAULT_CHAT_ID; diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 1aae402b0578a..48a7baca9a29a 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -12,13 +12,13 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { buildAnnotationsUri, isAnnotationsUri } from '../common/annotationsUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; -import { ChangesSummary } from '../common/state/protocol/state.js'; +import { ChangesSummary, type ChatOrigin } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; export interface IAgentHostStateManagerOptions { @@ -662,7 +662,7 @@ export class AgentHostStateManager extends Disposable { * is a no-op (returning the existing summary) when a chat with the same URI * already exists. */ - addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[] }): ChatSummary | undefined { + addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); @@ -689,6 +689,7 @@ export class AgentHostStateManager extends Disposable { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options?.title ?? '', status: SessionStatus.Idle, + origin: options?.origin, }; this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options?.turns ?? [] }); this.dispatchServerAction(session, { type: ActionType.SessionChatAdded, summary: chatSummary }); @@ -1087,11 +1088,6 @@ export class AgentHostStateManager extends Disposable { private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown { let resultingState: unknown = undefined; - // Channel the resulting envelope is emitted on. Chat actions are - // dispatched by producers against the owning session URI for - // backward compatibility, but must be emitted on the chat channel - // URI so per-chat subscribers receive them. - let emitChannel = channel; // Apply to state if (isRootAction(action)) { // `RootConfigChanged` can be a true no-op: the reducer merges/replaces @@ -1136,22 +1132,20 @@ export class AgentHostStateManager extends Disposable { } if (isChatAction(action)) { + if (!isAhpChatChannel(channel)) { + throw new Error(`[AgentHostStateManager] Chat action dispatched to non-chat channel: ${channel}, type=${action.type}`); + } + const chatAction = action as ChatAction; - // Producers dispatch chat actions against either the session URI - // (compat) or the chat channel URI. Resolve both so we can update - // the chat state, bridge status to the session summary, and emit - // on the chat channel. - const sessionKey = isAhpChatChannel(channel) ? parseDefaultChatUri(channel) : channel; - const chatUri = isAhpChatChannel(channel) ? channel : buildDefaultChatUri(channel); - emitChannel = chatUri; - const chat = this._chatStates.get(chatUri); + const sessionKey = parseRequiredSessionUriFromChatUri(channel); + const chat = this._chatStates.get(channel); if (chat && sessionKey !== undefined) { const newChat = chatReducer(chat, chatAction, this._log); - this._chatStates.set(chatUri, newChat); - this._onChatStateChanged(sessionKey, chatUri, chat, newChat); + this._chatStates.set(channel, newChat); + this._onChatStateChanged(sessionKey, channel, chat, newChat); resultingState = newChat; } else { - this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${chatUri}, type=${action.type}`); + this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`); } } @@ -1189,7 +1183,7 @@ export class AgentHostStateManager extends Disposable { // Emit envelope const envelope: ActionEnvelope = { - channel: emitChannel, + channel, action, serverSeq: ++this._serverSeq, origin, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 66c259290ec25..be3cff1db7602 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -6,7 +6,7 @@ import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agentService.js'; import type { MessageAttachment } from '../common/state/protocol/state.js'; -import { isSubagentSession, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -77,11 +77,12 @@ export class AgentHostTelemetryReporter { userMessageSent(provider: string, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void { const attachmentCount = attachments?.length ?? 0; const activeClients = sessionState?.activeClients ?? []; + const sessionUri = isAhpChatChannel(session) ? parseRequiredSessionUriFromChatUri(session) : session; this._telemetryService.publicLog2('agentHost.userMessageSent', { provider, - agentSessionId: AgentSession.id(session), + agentSessionId: AgentSession.id(sessionUri), source, - isSubagentSession: isSubagentSession(session), + isSubagentSession: isSubagentSession(sessionUri), turnCount: sessionState?.turns.length ?? 0, ...(activeClients.length > 0 ? { activeClientId: activeClients[0].clientId, @@ -93,9 +94,10 @@ export class AgentHostTelemetryReporter { } turnCompleted(report: IAgentHostTurnCompletedReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnCompleted', { provider: report.provider, - agentSessionId: AgentSession.id(report.session), + agentSessionId: AgentSession.id(session), timeToFirstProgress: report.timeToFirstProgress, totalTime: report.totalTime, result: report.result, @@ -104,4 +106,3 @@ export class AgentHostTelemetryReporter { }); } } - diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0b50149239567..bbd4e3a9dc9c4 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -29,7 +29,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ResponsePartKind, SESSION_META_GITHUB_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, withSessionGitHubState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ResponsePartKind, SESSION_META_GITHUB_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, withSessionGitHubState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -1298,7 +1298,7 @@ export class AgentService extends Disposable implements IAgentService { // URI for all session-scoped work (attachment snapshotting, agent // lookup, telemetry, permissions — all keyed by session). const chatChannel = isAhpChatChannel(channel) ? channel : undefined; - const sessionChannel = chatChannel ? (parseDefaultChatUri(chatChannel) ?? channel) : channel; + const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel; const pending = this._clientDispatchQueues.get(clientId); if (!pending && !this._needsAsyncRewrite(sessionChannel, action)) { @@ -1327,9 +1327,7 @@ export class AgentService extends Disposable implements IAgentService { if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); } - // Side effects key session-scoped work by the session URI, but route - // per-chat operations (message send, turn cancel) to the chat channel. - this._sideEffects.handleAction(sessionChannel, action, channel !== sessionChannel ? channel : undefined); + this._sideEffects.handleAction(channel, action); } private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 4416a67f4bc18..3273260b6da71 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { NKeyMap } from '../../../base/common/map.js'; import { equals } from '../../../base/common/objects.js'; import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; import { hasKey } from '../../../base/common/types.js'; @@ -18,22 +19,21 @@ import { AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../com import { toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import { ToolCallContributorKind, type AgentInfo } from '../common/state/protocol/state.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { ChatOriginKind, ToolCallContributorKind, type AgentInfo } from '../common/state/protocol/state.js'; import { ActionType, StateAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { - buildSubagentSessionUri, + buildSubagentChatUri, getToolFileEdits, isAhpChatChannel, isDefaultChatUri, MessageKind, parseChatUri, - parseDefaultChatUri, + parseRequiredSessionUriFromChatUri, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, - SessionStatus, ToolCallStatus, ToolResultContentType, type ErrorInfo, @@ -82,6 +82,13 @@ interface IPendingSubagentSignal { readonly agent: IAgent; } +interface ISubagentSessionRef { + readonly parentChatUri: ProtocolURI; + readonly toolCallId: string; + readonly sessionUri: ProtocolURI; + readonly chatUri: ProtocolURI; +} + /** * Shared implementation of agent side-effect handling. * @@ -100,11 +107,7 @@ export class AgentSideEffects extends Disposable { private readonly _permissionManager: SessionPermissionManager; - /** - * Maps `parentSession:toolCallId` → subagent session URI. - * Used to route signals with `parentToolCallId` to the correct subagent. - */ - private readonly _subagentSessions = new Map(); + private readonly _subagentChats = new NKeyMap(); /** * Buffers signals whose `parentToolCallId` references a subagent @@ -115,9 +118,8 @@ export class AgentSideEffects extends Disposable { * UI would render the inner tool calls flat at the top level rather than * grouping them under the subagent. Drained by `_handleSubagentStarted`. * - * Key: `${parentSession}:${parentToolCallId}`. */ - private readonly _pendingSubagentSignals = new Map(); + private readonly _pendingSubagentSignals = new NKeyMap(); private readonly _telemetryReporter: AgentHostTelemetryReporter; private readonly _turnTracker: AgentHostTurnTracker; private readonly _titleController: AgentHostSessionTitleController; @@ -157,9 +159,9 @@ export class AgentSideEffects extends Disposable { // Chat-action envelopes are emitted on the chat channel URI; // agents are keyed by session URI, so resolve back to the // owning session before notifying the agent. - const sessionChannel = isAhpChatChannel(envelope.channel) ? (parseDefaultChatUri(envelope.channel) ?? envelope.channel) : envelope.channel; + const sessionChannel = isAhpChatChannel(envelope.channel) ? parseRequiredSessionUriFromChatUri(envelope.channel) : envelope.channel; const agent = this._options.getAgent(sessionChannel); - agent?.onClientToolCallComplete(URI.parse(sessionChannel), action.toolCallId, action.result); + agent?.onClientToolCallComplete(URI.parse(envelope.channel), action.toolCallId, action.result); } if (envelope.action.type === ActionType.ChatDraftChanged) { this._persistChatDraft(envelope.channel, envelope.action.draft); @@ -277,40 +279,26 @@ export class AgentSideEffects extends Disposable { * once the `subagent_started` arrives. */ private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { - const sessionKey = signal.session.toString(); - - // Track tool calls so handleAction can route confirmations. Defer - // registration for inner subagent tool calls until we know which - // subagent session they belong to — otherwise we'd register them - // under the parent session key and a later `pending_confirmation` - // (which lacks - // `parentToolCallId`) could be routed against the wrong session. - if (signal.kind === 'action' - && signal.action.type === ActionType.ChatToolCallStart - && !signal.parentToolCallId - ) { - this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id); - } - if (signal.kind === 'subagent_started') { - this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); - this._drainPendingSubagentSignals(sessionKey, signal.toolCallId); + this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); + this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId); return; } if (signal.kind === 'subagent_completed') { - this.completeSubagentSession(sessionKey, signal.toolCallId); + this.completeSubagentSession(signal.chat.toString(), signal.toolCallId); return; } if (signal.kind === 'steering_consumed') { - this._stateManager.dispatchServerAction(sessionKey, { + this._stateManager.dispatchServerAction(signal.chat.toString(), { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: signal.id, }); return; } + const sessionKey = signal.kind === 'action' ? signal.resource.toString() : signal.chat.toString(); // Route signals with parentToolCallId to the subagent session. // Both action signals and pending_confirmation signals can carry @@ -319,31 +307,24 @@ export class AgentSideEffects extends Disposable { // call, and that signal must be routed to the subagent session // (otherwise the resulting ChatToolCallReady would land on the // parent session, which has no matching ChatToolCallStart). - const parentToolCallId = signal.kind === 'action' || signal.kind === 'pending_confirmation' - ? signal.parentToolCallId - : undefined; + const parentToolCallId = signal.parentToolCallId; if (parentToolCallId) { - const subagentKey = `${sessionKey}:${parentToolCallId}`; - const subagentSession = this._subagentSessions.get(subagentKey); + const subagentSession = this._subagentChats.get(sessionKey, parentToolCallId); if (subagentSession) { - // Track tool calls in subagent context for confirmation routing. - if (signal.kind === 'action' && signal.action.type === ActionType.ChatToolCallStart) { - this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id); - } - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); if (subTurnId) { - this._dispatchActionForSession(signal, subagentSession, subTurnId, agent); + this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, agent); } return; } // Subagent session does not exist yet — buffer the signal so we can // replay it after `subagent_started` arrives. - this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`); - let buffer = this._pendingSubagentSignals.get(subagentKey); + this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${sessionKey}/${parentToolCallId}`); + let buffer = this._pendingSubagentSignals.get(sessionKey, parentToolCallId); if (!buffer) { buffer = []; - this._pendingSubagentSignals.set(subagentKey, buffer); + this._pendingSubagentSignals.set(buffer, sessionKey, parentToolCallId); } buffer.push({ signal, agent }); return; @@ -354,10 +335,10 @@ export class AgentSideEffects extends Disposable { // tool was previously registered under its subagent session key in // _toolCallAgents). if (signal.kind === 'pending_confirmation') { - const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId); - if (subagentSession) { - const subTurnId = this._stateManager.getActiveTurnId(subagentSession) ?? ''; - void this._handleToolReady(signal, subagentSession, subTurnId, agent).catch(err => { + const subagentChatUri = this._findSubagentChatForToolCall(sessionKey, signal.state.toolCallId); + if (subagentChatUri) { + const subTurnId = this._stateManager.getActiveTurnId(subagentChatUri) ?? ''; + void this._handleToolReady(signal, subagentChatUri, subTurnId, agent).catch(err => { this._logService.error('[AgentSideEffects] _handleToolReady failed', err); }); return; @@ -425,15 +406,20 @@ export class AgentSideEffects extends Disposable { action = { ...action, turnId }; } + if (action.type === ActionType.ChatToolCallStart && agent) { + this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); + } + + const sessionScope = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; + // When a parent tool call has an associated subagent session, // preserve the subagent content metadata in the completion result. // The SDK's tool_complete provides its own content which would // overwrite the ToolResultSubagentContent that was set via // ChatToolCallContentChanged while running. if (action.type === ActionType.ChatToolCallComplete) { - const subagentKey = `${sessionKey}:${action.toolCallId}`; - const subagentUri = this._subagentSessions.get(subagentKey); - if (subagentUri) { + const subagent = this._subagentChats.get(sessionKey, action.toolCallId); + if (subagent) { const parentState = this._stateManager.getSessionState(sessionKey); const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId); const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); @@ -442,6 +428,7 @@ export class AgentSideEffects extends Disposable { const merged: ChatToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } }; action = merged; } + } } @@ -462,11 +449,8 @@ export class AgentSideEffects extends Disposable { // teardown is driven by the `subagent_completed` signal because // background subagents (`mode: background`) continue running // after the parent tool call returns. - this._pendingSubagentSignals.delete(`${sessionKey}:${action.toolCallId}`); + this._pendingSubagentSignals.delete(sessionKey, action.toolCallId); if (getToolFileEdits(action.result).length > 0) { - // Changesets track the session's shared working tree; key by - // the owning session when this is an additional chat channel. - const sessionScope = isAhpChatChannel(sessionKey) ? (parseDefaultChatUri(sessionKey) ?? sessionKey) : sessionKey; this._changesets.onToolCallEditsApplied(sessionScope, turnId); } } @@ -498,7 +482,7 @@ export class AgentSideEffects extends Disposable { // message consumption (queues live on the chat state). For the // default chat / single-chat case `sessionKey` is already the // session URI, so this is a no-op. - const sessionScope = isAhpChatChannel(sessionKey) ? (parseDefaultChatUri(sessionKey) ?? sessionKey) : sessionKey; + const sessionScope = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; // Capture the end-of-turn git checkpoint BEFORE notifying the // changeset service so the per-turn changeset recompute can take // the authoritative git-diff fast path (which includes terminal-tool @@ -539,14 +523,13 @@ export class AgentSideEffects extends Disposable { * `subagent_started` to create the subagent session. Called immediately * after `_handleSubagentStarted`. */ - private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void { - const subagentKey = `${parentSession}:${parentToolCallId}`; - const buffer = this._pendingSubagentSignals.get(subagentKey); + private _drainPendingSubagentSignals(parentChatURI: ProtocolURI, parentToolCallId: string): void { + const buffer = this._pendingSubagentSignals.get(parentChatURI, parentToolCallId); if (!buffer) { return; } - this._pendingSubagentSignals.delete(subagentKey); - this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`); + this._pendingSubagentSignals.delete(parentChatURI, parentToolCallId); + this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${parentChatURI}/${parentToolCallId}`); for (const { signal, agent } of buffer) { this._handleAgentSignal(agent, signal); } @@ -560,69 +543,56 @@ export class AgentSideEffects extends Disposable { * and immediately transitioned to ready with an active turn. */ private _handleSubagentStarted( - parentSession: ProtocolURI, + chatURI: ProtocolURI, toolCallId: string, agentName: string, agentDisplayName: string, agentDescription?: string, ): void { - const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId); - const subagentKey = `${parentSession}:${toolCallId}`; + const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI); + const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId); // Already tracking this subagent - if (this._subagentSessions.has(subagentKey)) { + if (this._subagentChats.get(chatURI, toolCallId)) { return; } - this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`); - const parentState = this._stateManager.getSessionState(parentSession); - - // Create the subagent session silently (restoreSession skips notification) - const now = new Date().toISOString(); - this._stateManager.restoreSession( - { - resource: subagentSessionUri, - provider: 'subagent', - title: agentDisplayName, - status: SessionStatus.Idle, - createdAt: now, - modifiedAt: now, - ...(parentState?.project ? { project: parentState.project } : {}), - }, - [], - ); + this._logService.info(`[AgentSideEffects] Creating subagent chat: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`); + this._stateManager.addChat(parentSessionUri, subagentChatUri, { + title: agentDisplayName, + origin: { kind: ChatOriginKind.Tool, chat: chatURI, toolCallId }, + }); // Start a turn on the subagent session const turnId = generateUuid(); - this._stateManager.dispatchServerAction(subagentSessionUri, { + this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, message: { text: '', origin: { kind: MessageKind.User } }, }); - this._subagentSessions.set(subagentKey, subagentSessionUri); + this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri }, chatURI, toolCallId); // Dispatch content on the parent tool call so clients discover the subagent. // Merge with any existing content to avoid dropping prior content blocks. - const parentTurnId = this._stateManager.getActiveTurnId(parentSession); + const parentTurnId = this._stateManager.getActiveTurnId(chatURI); if (parentTurnId) { - const parentState = this._stateManager.getSessionState(parentSession); + const parentState = this._stateManager.getSessionState(chatURI); const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); - const mergedContent = [ - ...existingContent, - { - type: ToolResultContentType.Subagent as const, - resource: subagentSessionUri, - title: agentDisplayName, - agentName, - description: agentDescription, - }, - ]; - this._stateManager.dispatchServerAction(parentSession, { + this._stateManager.dispatchServerAction(chatURI, { type: ActionType.ChatToolCallContentChanged, turnId: parentTurnId, toolCallId, - content: mergedContent, + content: [ + ...existingContent, + { + type: ToolResultContentType.Subagent, + resource: subagentChatUri, + title: agentDisplayName, + agentName, + description: agentDescription, + }, + ], }); } } @@ -649,26 +619,20 @@ export class AgentSideEffects extends Disposable { /** * Cancels all active subagent sessions for a given parent session. */ - cancelSubagentSessions(parentSession: ProtocolURI): void { - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - const turnId = this._stateManager.getActiveTurnId(subagentUri); - if (turnId) { - this._stateManager.dispatchServerAction(subagentUri, { - type: ActionType.ChatTurnCancelled, - turnId, - }); - this._turnTracker.turnCompleted(subagentUri, turnId, 'cancelled'); - } - this._subagentSessions.delete(key); + cancelSubagentSessions(parentChatURI: ProtocolURI): void { + for (const subagent of this._subagentChats.getAll(parentChatURI)) { + const turnId = this._stateManager.getActiveTurnId(subagent.chatUri); + if (turnId) { + this._stateManager.dispatchServerAction(subagent.chatUri, { + type: ActionType.ChatTurnCancelled, + turnId, + }); + this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled'); } } + this._subagentChats.deleteAll(parentChatURI); // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } - } + this._pendingSubagentSignals.deleteAll(parentChatURI); } /** @@ -678,60 +642,42 @@ export class AgentSideEffects extends Disposable { * parent tool call completion — background subagents keep running after * their parent tool returns. */ - completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void { - const key = `${parentSession}:${toolCallId}`; - + completeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string): void { // Drop any events that were buffered waiting for a `subagent_started` // that never arrived (e.g. the parent tool failed before the subagent // was created). Without this, the buffer entry would leak until the // parent session is disposed. - this._pendingSubagentSignals.delete(key); + this._pendingSubagentSignals.delete(parentChatURI, toolCallId); - const subagentUri = this._subagentSessions.get(key); - if (!subagentUri) { + const subagent = this._subagentChats.get(parentChatURI, toolCallId); + if (!subagent) { return; } - const turnId = this._stateManager.getActiveTurnId(subagentUri); + const turnId = this._stateManager.getActiveTurnId(subagent.chatUri); if (turnId) { - this._stateManager.dispatchServerAction(subagentUri, { + this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnComplete, turnId, }); } - this._subagentSessions.delete(key); + this._subagentChats.delete(parentChatURI, toolCallId); } /** - * Removes all subagent sessions for a given parent session from - * the state manager. Called when the parent session is disposed. + * Removes all subagent chats for a given parent session from the state manager. */ removeSubagentSessions(parentSession: ProtocolURI): void { - const toRemove: string[] = []; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - this._stateManager.disposeSessionChangesets(subagentUri); - this._stateManager.removeSession(subagentUri); - toRemove.push(key); + const parentChatURIs = new Set(); + for (const subagent of this._subagentChats.values()) { + if (subagent.sessionUri === parentSession) { + this._stateManager.removeChat(subagent.sessionUri, subagent.chatUri); + parentChatURIs.add(subagent.parentChatUri); } } - for (const key of toRemove) { - this._subagentSessions.delete(key); - } - - // Also clean up any subagent sessions that are in the state manager - // but not tracked (e.g. restored sessions) - const prefix = `${parentSession}/subagent/`; - for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) { - this._stateManager.disposeSessionChangesets(uri); - this._stateManager.removeSession(uri); - } - - // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } + for (const parentChatURI of parentChatURIs) { + this._subagentChats.deleteAll(parentChatURI); + this._pendingSubagentSignals.deleteAll(parentChatURI); } } @@ -741,11 +687,10 @@ export class AgentSideEffects extends Disposable { * session key in `_toolCallAgents`. Scoped to subagent sessions owned * by the given parent to avoid cross-session collisions. */ - private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined { - const prefix = `${parentSession}:`; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) { - return subagentUri; + private _findSubagentChatForToolCall(parentChatURI: ProtocolURI, toolCallId: string): ProtocolURI | undefined { + for (const subagent of this._subagentChats.getAll(parentChatURI)) { + if (this._toolCallAgents.has(`${subagent.chatUri}:${toolCallId}`)) { + return subagent.chatUri; } } return undefined; @@ -762,7 +707,7 @@ export class AgentSideEffects extends Disposable { private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise { const approvalEvent = { toolCallId: e.state.toolCallId, - session: e.session, + session: e.chat, permissionKind: e.permissionKind, permissionPath: e.permissionPath, toolInput: e.state.toolInput, @@ -795,13 +740,14 @@ export class AgentSideEffects extends Disposable { ); } - handleAction(channel: ProtocolURI, action: StateAction, chatChannel?: ProtocolURI): void { - // `channel` is always the session URI (session-scoped work keys by it). - // `chatChannel`, when present, is the originating chat channel URI used - // to route per-chat operations (message send, turn cancel) to the - // correct chat in a multi-chat session. + handleAction(channel: ProtocolURI, action: StateAction): void { + const chatChannel = isAhpChatChannel(channel) ? channel : undefined; + const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel; switch (action.type) { case ActionType.ChatTurnStarted: { + if (!chatChannel) { + throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`); + } // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. @@ -810,7 +756,7 @@ export class AgentSideEffects extends Disposable { // than forwarded to the agent SDK. Mirrors the per-agent text-side // dispatch (`parseLeadingSlashCommand` in CopilotAgentSession), but // applies to every session type. - if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text, chatChannel)) { + if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text)) { break; } @@ -818,9 +764,9 @@ export class AgentSideEffects extends Disposable { if (!state) { this._logService.info(`[AgentSideEffects] Turn started for session not in state manager: ${channel}, turnId=${action.turnId} - status/summary updates may be dropped unless the session is restored`); } - this._titleController.seedTitleFromFirstMessage(channel, action.message.text, chatChannel); + this._titleController.seedTitleFromFirstMessage(sessionChannel, action.message.text, chatChannel); - const agent = this._options.getAgent(channel); + const agent = this._options.getAgent(sessionChannel); if (!agent) { this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatError, @@ -833,18 +779,20 @@ export class AgentSideEffects extends Disposable { this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments); const { model, permissionLevel } = this._getTurnTelemetryContext(state, action.message.model?.id); this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, permissionLevel); - - this._sendTurnMessage({ + void this._sendTurnMessage({ agent, - sessionChannel: channel, + sessionChannel, turnChannel: channel, - chat: chatChannel, + chat: channel, message: action.message, turnId: action.turnId, }); break; } case ActionType.ChatToolCallConfirmed: { + if (!chatChannel) { + throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`); + } const toolCallKey = `${channel}:${action.toolCallId}`; const agentId = this._toolCallAgents.get(toolCallKey); if (agentId) { @@ -863,16 +811,22 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.ChatInputCompleted: { - const agent = this._options.getAgent(channel); + if (!chatChannel) { + throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`); + } + const agent = this._options.getAgent(sessionChannel); agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); break; } case ActionType.ChatTurnCancelled: { + if (!chatChannel) { + throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`); + } this._turnTracker.turnCompleted(channel, action.turnId, 'cancelled'); // Cancel all subagent sessions for this parent this.cancelSubagentSessions(channel); - const agent = this._options.getAgent(channel); - agent?.abortSession(URI.parse(channel), chatChannel ? URI.parse(chatChannel) : undefined).catch(err => { + const agent = this._options.getAgent(sessionChannel); + agent?.abortSession(URI.parse(sessionChannel), isDefaultChatUri(channel) ? undefined : URI.parse(channel)).catch(err => { this._logService.error('[AgentSideEffects] abortSession failed', err); }); // Intentionally do NOT drain queued messages here: cancelling means @@ -887,8 +841,8 @@ export class AgentSideEffects extends Disposable { // The rename targeted a specific chat (default or additional), // not the whole session. Route it to a per-chat title update so // the session title stays independent. - this._stateManager.updateChatTitle(channel, chatChannel, action.title); - this._persistSessionFlag(channel, `customChatTitle:${chatChannel}`, action.title); + this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title); + this._persistSessionFlag(sessionChannel, `customChatTitle:${chatChannel}`, action.title); break; } this._persistSessionFlag(channel, 'customTitle', action.title); @@ -897,15 +851,21 @@ export class AgentSideEffects extends Disposable { case ActionType.ChatPendingMessageSet: case ActionType.ChatPendingMessageRemoved: case ActionType.ChatQueuedMessagesReordered: { + if (!chatChannel) { + throw new Error(`${action.type} must be handled on an AHP chat channel: ${channel}`); + } this._syncPendingMessages(channel); break; } case ActionType.ChatTruncated: { - const agent = this._options.getAgent(channel); - agent?.truncateSession?.(URI.parse(channel), action.turnId).catch(err => { + if (!chatChannel) { + throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`); + } + const agent = this._options.getAgent(sessionChannel); + agent?.truncateSession?.(URI.parse(sessionChannel), action.turnId).catch(err => { this._logService.error('[AgentSideEffects] truncateSession failed', err); }); - this._changesets.onSessionTruncated(channel); + this._changesets.onSessionTruncated(sessionChannel); break; } case ActionType.SessionActiveClientSet: { @@ -965,7 +925,7 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.ChatToolCallComplete: { - const agent = this._options.getAgent(channel); + const agent = this._options.getAgent(parseRequiredSessionUriFromChatUri(channel)); agent?.onClientToolCallComplete(URI.parse(channel), action.toolCallId, action.result); break; } @@ -994,18 +954,15 @@ export class AgentSideEffects extends Disposable { * @returns `true` when the message was a rename command and was handled here * (the caller MUST NOT forward it to the agent), `false` otherwise. */ - private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string, chatChannel?: ProtocolURI): boolean { + private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string): boolean { const title = parseRenameCommand(text); if (title === undefined) { return false; } - // The additional chat the turn belongs to (if any): an explicit - // `chatChannel`, or `channel` itself when it is an additional chat - // channel (the queued-message consumer passes the chat channel directly). const isAdditional = (uri: ProtocolURI | undefined): uri is ProtocolURI => !!uri && isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(chatChannel) ? chatChannel : (isAdditional(channel) ? channel : undefined); - const sessionChannel = chatTarget ? (parseDefaultChatUri(chatTarget) ?? channel) : channel; + const chatTarget = isAdditional(channel) ? channel : undefined; + const sessionChannel = chatTarget ? parseRequiredSessionUriFromChatUri(chatTarget) : (isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel); // The just-opened turn lives wherever the message was dispatched. const turnTarget = chatTarget ?? channel; if (title.length > 0) { @@ -1093,14 +1050,15 @@ export class AgentSideEffects extends Disposable { * The server controls queued message consumption; only steering messages * are forwarded to the agent for mid-turn injection. */ - private _syncPendingMessages(session: ProtocolURI): void { - const state = this._stateManager.getSessionState(session); + private _syncPendingMessages(chatChannel: ProtocolURI): void { + const sessionChannel = parseRequiredSessionUriFromChatUri(chatChannel); + const state = this._stateManager.getSessionState(chatChannel); if (!state) { return; } - const agent = this._options.getAgent(session); + const agent = this._options.getAgent(sessionChannel); agent?.setPendingMessages?.( - URI.parse(session), + URI.parse(sessionChannel), state.steeringMessage, [], ); @@ -1110,7 +1068,7 @@ export class AgentSideEffects extends Disposable { // has actually been sent to the model. // If the session is idle, try to consume the next queued message - this._tryConsumeNextQueuedMessage(session); + this._tryConsumeNextQueuedMessage(chatChannel); } /** @@ -1121,6 +1079,7 @@ export class AgentSideEffects extends Disposable { * consumed when the next `idle` event fires. */ private _tryConsumeNextQueuedMessage(session: ProtocolURI): void { + const sessionChannel = parseRequiredSessionUriFromChatUri(session); // Bail if there's already an active turn if (this._stateManager.getActiveTurnId(session)) { return; @@ -1154,8 +1113,6 @@ export class AgentSideEffects extends Disposable { // additional chat channel, the SDK conversation is owned by the // parent session: look up the provider by the parent session URI and // pass the chat channel so the harness routes to the right peer chat. - const chatTarget = isAhpChatChannel(session) && !isDefaultChatUri(session) ? session : undefined; - const sessionChannel = chatTarget ? (parseDefaultChatUri(chatTarget) ?? session) : session; const agent = this._options.getAgent(sessionChannel); if (!agent) { this._stateManager.dispatchServerAction(session, { @@ -1175,7 +1132,7 @@ export class AgentSideEffects extends Disposable { agent, sessionChannel, turnChannel: session, - chat: chatTarget, + chat: session, message: msg.message, turnId, }); @@ -1201,15 +1158,15 @@ export class AgentSideEffects extends Disposable { sessionChannel: ProtocolURI; /** The channel the turn runs on — where `ChatError` / turn completion are reported. */ turnChannel: ProtocolURI; - /** Peer-chat channel URI to route to, when the turn targets a non-default chat. */ - chat: ProtocolURI | undefined; + /** Chat channel URI the turn targets. */ + chat: ProtocolURI; message: Message; turnId: string; }): Promise { const { agent, sessionChannel, turnChannel, chat, message, turnId } = options; const sessionUri = URI.parse(sessionChannel); - const chatUri = chat ? URI.parse(chat) : undefined; + const chatUri = URI.parse(chat); const selectionUpdates: Promise[] = []; if (message.model) { const changeModel = agent.changeModel?.(sessionUri, message.model, chatUri); @@ -1228,7 +1185,7 @@ export class AgentSideEffects extends Disposable { await Promise.all(selectionUpdates); - await agent.sendMessage(URI.parse(sessionChannel), message.text, message.attachments, turnId, chatUri).catch(err => { + await agent.sendMessage(URI.parse(sessionChannel), chatUri, message.text, message.attachments, turnId).catch(err => { const errCode = (err as { code?: number })?.code; this._logService.error(`[AgentSideEffects] sendMessage failed for session=${turnChannel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); this._stateManager.dispatchServerAction(turnChannel, { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index b20a776950577..7e66b0c904e87 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -1129,7 +1129,7 @@ export class ClaudeAgent extends Disposable implements IAgent { })(); } - async sendMessage(sessionUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + async sendMessage(sessionUri: URI, _chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { // Plan section 3.8. The sequencer scope holds across BOTH materialize // and `session.send` so two concurrent first-message calls on the // same session collapse into one materialize plus two ordered @@ -1363,7 +1363,7 @@ export class ClaudeAgent extends Disposable implements IAgent { private _fireCustomizationUpdated(session: URI, item: ISyncedCustomization): void { this._onDidSessionProgress.fire({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationUpdated, customization: item.customization, diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index d71e203254785..4abf2858a456a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -23,7 +23,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import type { Customization, ToolCallResult } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; import { toSdkModelId } from './claudeModelId.js'; @@ -88,6 +88,7 @@ function resolveCurrentPermissionMode( export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; + private readonly _chatChannelUri: URI; /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; @@ -274,6 +275,7 @@ export class ClaudeAgentSession extends Disposable { @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); + this._chatChannelUri = URI.parse(buildDefaultChatUri(sessionUri)); this.project = project; this._provisionalModel = model; this._provisionalAgent = agent; @@ -404,6 +406,7 @@ export class ClaudeAgentSession extends Disposable { ClaudeSdkPipeline, this.sessionId, this.sessionUri, + this._chatChannelUri, warm, this.abortController, dbRef, @@ -775,7 +778,7 @@ export class ClaudeAgentSession extends Disposable { return this._pendingPermissions.registerAndFire(args.toolUseID, () => { this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: args.state, permissionKind: args.permissionKind, ...(args.permissionPath !== undefined ? { permissionPath: args.permissionPath } : {}), @@ -800,7 +803,7 @@ export class ClaudeAgentSession extends Disposable { return this._pendingUserInputs.registerAndFire(request.id, () => { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: this._chatChannelUri, action: { type: ActionType.ChatInputRequested, request, diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index 09b89d71dc19e..b3bbd63e4ed8b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -214,7 +214,7 @@ export class ClaudeMapperState { */ export function mapSDKMessageToAgentSignals( message: SDKMessage, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, logService: ILogService, @@ -232,31 +232,31 @@ export function mapSDKMessageToAgentSignals( switch (message.type) { case 'stream_event': return tagWithParent( - mapStreamEvent(message.event, session, turnId, state, logService, message.parent_tool_use_id, registry, clientToolOwner), - session, + mapStreamEvent(message.event, chat, turnId, state, logService, message.parent_tool_use_id, registry, clientToolOwner), + chat, message.parent_tool_use_id, registry, ); case 'result': - return mapResult(message, session, turnId, state, logService, registry); + return mapResult(message, chat, turnId, state, logService, registry); case 'assistant': return tagWithParent( - mapAssistantCanonical(message, session, turnId, state, message.parent_tool_use_id, registry), - session, + mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry), + chat, message.parent_tool_use_id, registry, ); case 'user': return tagWithParent( - mapUserMessage(message, session, state, logService, registry), - session, + mapUserMessage(message, chat, state, logService, registry), + chat, message.parent_tool_use_id, registry, ); default: // Phase 12 step 7 — system subtypes for subagent task discrimination. if (message.type === 'system') { - return mapSubagentSystemMessage(message, session, registry); + return mapSubagentSystemMessage(message, chat, registry); } return []; } @@ -284,7 +284,7 @@ export function mapSDKMessageToAgentSignals( */ function mapAssistantCanonical( message: Extract, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, parentToolUseId: string | null, @@ -296,11 +296,11 @@ function mapAssistantCanonical( if (block.type !== 'tool_use' || !SUBAGENT_SPAWNING_TOOL_NAMES.has(block.name)) { continue; } - top.push(buildTopLevelSubagentReadyAction(block, session, turnId, registry)); + top.push(buildTopLevelSubagentReadyAction(block, chat, turnId, registry)); } return top; } - return emitInnerAssistantSignals(message, session, turnId, state, parentToolUseId, registry); + return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry); } /** @@ -316,7 +316,7 @@ function mapAssistantCanonical( */ function mapUserMessage( message: Extract, - session: URI, + chat: URI, state: ClaudeMapperState, logService: ILogService, registry: SubagentRegistry, @@ -348,7 +348,7 @@ function mapUserMessage( : `${getClaudeToolDisplayName(tracked.toolName)} finished`; signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallComplete, turnId: tracked.turnId, @@ -369,7 +369,7 @@ function mapUserMessage( if (spawn && !spawn.background && spawn.markCompleted()) { signals.push({ kind: 'subagent_completed', - session, + chat, toolCallId: block.tool_use_id, }); registry.removeSpawn(block.tool_use_id); @@ -431,7 +431,7 @@ function mapResult( // `_meta.copilotUsage.totalNanoAiu` (the key the workbench reads). signals.push({ kind: 'action', - session, + resource: session, action: { type: ActionType.ChatUsage, turnId, @@ -455,7 +455,7 @@ function mapResult( if (errorText !== undefined) { signals.push({ kind: 'action', - session, + resource: session, action: { type: ActionType.ChatError, turnId, @@ -498,7 +498,7 @@ function getResultErrorText(message: Extract): s function mapStreamEvent( event: Extract['event'], - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, logService: ILogService, @@ -516,7 +516,7 @@ function mapStreamEvent( if (block.type === 'text') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -531,7 +531,7 @@ function mapStreamEvent( if (block.type === 'thinking') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -584,7 +584,7 @@ function mapStreamEvent( const toolClientId = isClientTool ? clientToolOwner?.(toolName) : undefined; return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallStart, turnId, @@ -603,7 +603,7 @@ function mapStreamEvent( if (event.delta.type === 'text_delta') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatDelta, turnId, @@ -615,7 +615,7 @@ function mapStreamEvent( if (event.delta.type === 'thinking_delta') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatReasoning, turnId, @@ -633,7 +633,7 @@ function mapStreamEvent( state.appendToolBlockInputDelta(event.index, event.delta.partial_json); return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallDelta, turnId, @@ -660,7 +660,7 @@ function mapStreamEvent( const meta = buildClaudeToolMeta(tracked.toolName); return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts index ff3b46bc9130b..c84a49dd5a97b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts @@ -39,7 +39,8 @@ export class ClaudeSdkMessageRouter extends Disposable { private _clientToolOwner: ((toolName: string) => string | undefined) | undefined; constructor( - private readonly _sessionUri: URI, + sessionUri: URI, + private readonly _chatChannelUri: URI, dbRef: IReference, private readonly _subagents: SubagentRegistry, clientToolOwner: ((toolName: string) => string | undefined) | undefined = undefined, @@ -49,7 +50,7 @@ export class ClaudeSdkMessageRouter extends Disposable { super(); this._clientToolOwner = clientToolOwner; this._editObserver = this._register( - instantiationService.createInstance(ClaudeFileEditObserver, _sessionUri.toString(), dbRef), + instantiationService.createInstance(ClaudeFileEditObserver, sessionUri.toString(), dbRef), ); } @@ -69,7 +70,7 @@ export class ClaudeSdkMessageRouter extends Disposable { try { const signals = mapSDKMessageToAgentSignals( message, - this._sessionUri, + this._chatChannelUri, turnId, this._mapperState, this._logService, diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index f55d5abdadc84..5a21d6e492e0d 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -204,6 +204,7 @@ export class ClaudeSdkPipeline extends Disposable { constructor( readonly sessionId: string, readonly sessionUri: URI, + readonly chatChannelUri: URI, warm: WarmQuery, abortController: AbortController, dbRef: IReference, @@ -222,12 +223,12 @@ export class ClaudeSdkPipeline extends Disposable { () => this._abortController.signal, (pendingId: string) => this._onDidProduceSignal.fire({ kind: 'steering_consumed', - session: this.sessionUri, + chat: this.chatChannelUri, id: pendingId, }), )); this._router = this._register(instantiationService.createInstance( - ClaudeSdkMessageRouter, sessionUri, dbRef, subagents, clientToolOwner, + ClaudeSdkMessageRouter, sessionUri, chatChannelUri, dbRef, subagents, clientToolOwner, )); this._register(this._router.onDidProduceSignal(s => this._onDidProduceSignal.fire(s))); // Dispose chain → abort → SDK cleanup. Reads the *current* @@ -615,7 +616,7 @@ export class ClaudeSdkPipeline extends Disposable { if (completed && this._queue.isEmpty) { this._onDidProduceSignal.fire({ kind: 'action', - session: this.sessionUri, + resource: this.chatChannelUri, action: { type: ActionType.ChatTurnComplete, turnId: completed.turnId, diff --git a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts index a048ac6021051..d9cfba9af33ee 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts @@ -39,7 +39,7 @@ export const SUBAGENT_SPAWNING_TOOL_NAMES: ReadonlySet = SUBAGENT_TOOL_N */ export function tagWithParent( signals: AgentSignal[], - session: URI, + chat: URI, parentToolUseId: string | null, registry: SubagentRegistry, ): AgentSignal[] { @@ -61,7 +61,7 @@ export function tagWithParent( } const started: IAgentSubagentStartedSignal = { kind: 'subagent_started', - session, + chat, toolCallId: parentToolUseId, agentName: spawn.subagentType ?? 'subagent', agentDisplayName: spawn.subagentType ?? 'Subagent', @@ -84,7 +84,7 @@ export function tagWithParent( */ export function mapSubagentSystemMessage( message: Extract, - session: URI, + chat: URI, registry: SubagentRegistry, ): AgentSignal[] { const sub = (message as { subtype?: string }).subtype; @@ -111,7 +111,7 @@ export function mapSubagentSystemMessage( } const toolUseId = m.tool_use_id; registry.removeSpawn(toolUseId); - return [{ kind: 'subagent_completed', session, toolCallId: toolUseId }]; + return [{ kind: 'subagent_completed', chat, toolCallId: toolUseId }]; } return []; } @@ -139,7 +139,7 @@ export function mapSubagentSystemMessage( */ export function buildTopLevelSubagentReadyAction( block: Extract, - session: URI, + chat: URI, turnId: string, registry: SubagentRegistry, ): AgentSignal { @@ -160,7 +160,7 @@ export function buildTopLevelSubagentReadyAction( } return { kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, @@ -195,7 +195,7 @@ export function buildTopLevelSubagentReadyAction( */ export function emitInnerAssistantSignals( message: Extract, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, parentToolUseId: string, @@ -208,7 +208,7 @@ export function emitInnerAssistantSignals( if (block.type === 'text') { signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -224,7 +224,7 @@ export function emitInnerAssistantSignals( if (block.type === 'thinking') { signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -256,7 +256,7 @@ export function emitInnerAssistantSignals( const toolInputStr = getClaudeToolInputString(toolName, block.input); signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallStart, turnId, @@ -268,7 +268,7 @@ export function emitInnerAssistantSignals( }); signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 6b62c179afb36..38ca702687a04 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -22,10 +22,10 @@ import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../co import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { ActionType, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; +import { ActionType, isChatAction, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; @@ -1367,7 +1367,7 @@ export class CodexAgent extends Disposable implements IAgent { } private _fireSteeringConsumed(session: ICodexSession, id: string): void { - this._onDidSessionProgress.fire({ kind: 'steering_consumed', session: session.sessionUri, id }); + this._onDidSessionProgress.fire({ kind: 'steering_consumed', chat: URI.parse(buildDefaultChatUri(session.sessionUri)), id }); } private _registerIgnoredNotifications(client: ICodexAppServerClient): void { @@ -1408,7 +1408,7 @@ export class CodexAgent extends Disposable implements IAgent { } const actions = mapFn(session); for (const action of actions) { - this._onDidSessionProgress.fire({ kind: 'action', session: session.sessionUri, action }); + this._fire(session.sessionUri, action); } } @@ -1546,20 +1546,12 @@ export class CodexAgent extends Disposable implements IAgent { session.hostTurnIdByAppTurnId.delete(appTurnId); } if (turnId) { - this._onDidSessionProgress.fire({ - kind: 'action', - session: session.sessionUri, - action: { - type: ActionType.ChatError, - turnId, - error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, - }, - }); - this._onDidSessionProgress.fire({ - kind: 'action', - session: session.sessionUri, - action: { type: ActionType.ChatTurnComplete, turnId }, + this._fire(session.sessionUri, { + type: ActionType.ChatError, + turnId, + error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, }); + this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId }); } } // Release resources. The proxy handle is refcounted and drops @@ -1825,7 +1817,7 @@ export class CodexAgent extends Disposable implements IAgent { } } - async sendMessage(sessionUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + async sendMessage(sessionUri: URI, _chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); const sessionId = AgentSession.id(sessionUri); const session = this._sessions.get(sessionId); @@ -2592,7 +2584,7 @@ export class CodexAgent extends Disposable implements IAgent { // #endregion private _fire(sessionUri: URI, action: SessionAction | ChatAction): void { - this._onDidSessionProgress.fire({ kind: 'action', session: sessionUri, action }); + this._onDidSessionProgress.fire({ kind: 'action', resource: isChatAction(action) ? URI.parse(buildDefaultChatUri(sessionUri)) : sessionUri, action }); } override dispose(): void { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index df9e7e2789a59..6e9152ea0b2c2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -43,7 +43,7 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; @@ -1464,10 +1464,10 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { + async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`, keyed by the chat URI. - if (chat && !isDefaultChatUri(chat)) { + if (!isDefaultChatUri(chat)) { const entry = await this._ensureChatSession(session, chat); if (!entry) { throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); @@ -2223,11 +2223,13 @@ export class CopilotAgent extends Disposable implements IAgent { */ private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, channelUri?: URI): CopilotAgentSession { const sessionUri = channelUri ?? AgentSession.uri(this.id, launchPlan.sessionId); + const chatChannelUri = channelUri ?? URI.parse(buildDefaultChatUri(sessionUri)); const agentSession = this._instantiationService.createInstance( CopilotAgentSession, { sessionUri, + chatChannelUri, rawSessionId: launchPlan.sessionId, onDidSessionProgress: this._onDidSessionProgress, sessionLauncher: this._sessionLauncher, @@ -3575,7 +3577,7 @@ class ActiveClient extends Disposable { // Forward per-session publish events into the agent's progress // stream. This replaces the previous clientId-based routing. this._register(this.pluginController.onDidPublish(action => { - onDidSessionProgress.fire({ kind: 'action', session: this._sessionUri, action }); + onDidSessionProgress.fire({ kind: 'action', resource: this._sessionUri, action }); })); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 092d14a28e66d..8b366a3d98fc9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -37,7 +37,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; @@ -305,6 +305,7 @@ function isCopilotSdkToolOutputTempFile(filePath: string, tmpDir: string): boole */ export interface ICopilotAgentSessionOptions { readonly sessionUri: URI; + readonly chatChannelUri: URI; readonly rawSessionId: string; readonly onDidSessionProgress: Emitter; readonly sessionLauncher: ICopilotSessionLauncher; @@ -426,6 +427,7 @@ class CopilotTurn { export class CopilotAgentSession extends Disposable { readonly sessionId: string; readonly sessionUri: URI; + private readonly _chatChannelUri: URI; /** Working directory this session operates in, if any. */ get workingDirectory(): URI | undefined { return this._workingDirectory; } @@ -583,6 +585,7 @@ export class CopilotAgentSession extends Disposable { super(); this.sessionId = options.rawSessionId; this.sessionUri = options.sessionUri; + this._chatChannelUri = options.chatChannelUri; this._onDidSessionProgress = options.onDidSessionProgress; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; @@ -651,10 +654,11 @@ export class CopilotAgentSession extends Disposable { // ---- AgentSignal helpers ------------------------------------------------ /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ + /** todo@connor4312: AHP is missing a chat activity update action which is needed to drop `SessionAction` here */ private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string): void { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: isChatAction(action) ? this._chatChannelUri : this.sessionUri, action, parentToolCallId, }); @@ -719,7 +723,7 @@ export class CopilotAgentSession extends Disposable { for (const id of ids) { this._onDidSessionProgress.fire({ kind: 'steering_consumed', - session: this.sessionUri, + chat: this._chatChannelUri, id, }); } @@ -944,14 +948,13 @@ export class CopilotAgentSession extends Disposable { if (!host) { return []; } - const sessionUri = this.sessionUri.toString(); return host.definitions.map(def => ({ name: def.name, description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, handler: async (args: Record): Promise => { try { - const text = host.executeTool(sessionUri, def.name, args); + const text = host.executeTool(this._chatChannelUri.toString(), def.name, args); return { textResultForLlm: text, resultType: 'success' }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1743,7 +1746,7 @@ export class CopilotAgentSession extends Disposable { const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: { status: ToolCallStatus.PendingConfirmation, toolCallId, @@ -1986,9 +1989,10 @@ export class CopilotAgentSession extends Disposable { ? localize('agentHost.unsandboxedCommandConfirmation.blockedDomains', "This command needs to access blocked network domain(s): {0}.", blockedDomains) : localize('agentHost.unsandboxedCommandConfirmation.generic', "This command needs to run outside the sandbox."); + const parentToolCallId = this._activeToolCalls.get(request.toolCallId)?.parentToolCallId; this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: { status: ToolCallStatus.PendingConfirmation, toolCallId: request.toolCallId, @@ -2004,7 +2008,7 @@ export class CopilotAgentSession extends Disposable { // Mirrors the workbench's sandbox-aware analyzer, which forces // `isAutoApproveAllowed: false` whenever `requiresUnsandboxConfirmation` // is set. - parentToolCallId: this._activeToolCalls.get(request.toolCallId)?.parentToolCallId, + parentToolCallId, }); return deferred.p; @@ -2774,7 +2778,7 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); this._onDidSessionProgress.fire({ kind: 'subagent_started', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, agentName: e.data.agentName, agentDisplayName: e.data.agentDisplayName, @@ -3127,7 +3131,7 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: this._chatChannelUri, action: { type: ActionType.ChatInputRequested, request: inputRequest, @@ -3290,7 +3294,7 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); this._onDidSessionProgress.fire({ kind: 'subagent_completed', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, }); })); @@ -3302,7 +3306,7 @@ export class CopilotAgentSession extends Disposable { this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); this._onDidSessionProgress.fire({ kind: 'subagent_completed', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, }); })); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 1c8daa57d846d..fbaf5e59928ea 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -38,7 +38,7 @@ import { type IStateSnapshot, type SubscribeResult, } from '../common/state/sessionProtocol.js'; -import { isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseDefaultChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { @@ -344,11 +344,10 @@ export class ProtocolServerHandler extends Disposable { // stamped while no client is connected are failed immediately by // the provider, so they never reach this path. if (envelope.action.type === ActionType.ChatToolCallStart || envelope.action.type === ActionType.ChatToolCallReady) { - // Chat-action envelopes are emitted on the chat channel URI; - // the disconnect-grace machinery keys by session URI, so - // resolve back to the owning session before checking. - const session = isAhpChatChannel(envelope.channel) ? (parseDefaultChatUri(envelope.channel) ?? envelope.channel) : envelope.channel; - this._checkOrphanedClientToolCalls(session); + if (!isAhpChatChannel(envelope.channel)) { + throw new Error(`[ProtocolServer] Chat tool-call action emitted on non-chat channel: ${envelope.channel}`); + } + this._checkOrphanedClientToolCalls(parseRequiredSessionUriFromChatUri(envelope.channel), envelope.channel); } })); @@ -761,12 +760,13 @@ export class ProtocolServerHandler extends Disposable { } } for (const session of this._stateManager.getSessionUris()) { - if (resubscribed.has(session)) { - continue; - } const state = this._stateManager.getSessionState(session); if (state && this._isActiveClient(state, client.clientId)) { - this._releaseActiveClientForSession(session, client.clientId); + for (const chat of state.chats) { + if (!resubscribed.has(session) && !resubscribed.has(chat.resource)) { + this._releaseActiveClientForSession(session, client.clientId, chat.resource); + } + } } } } @@ -782,7 +782,9 @@ export class ProtocolServerHandler extends Disposable { // calls) if it never returns; an explicit unsubscribe or a // reconnect without resubscription removes it sooner. if (isActive || ownsPendingToolCall) { - this._startClientToolCallDisconnectTimeout(clientId, session); + for (const chat of state?.chats ?? []) { + this._startClientToolCallDisconnectTimeout(clientId, session, chat.resource); + } } } } @@ -813,9 +815,9 @@ export class ProtocolServerHandler extends Disposable { * clients. Used by the explicit-unsubscribe and reconnect-reconciliation * paths to drop a client that has left a session. */ - private _releaseActiveClientForSession(session: string, clientId: string): void { - this._clearClientToolCallDisconnectTimeout(clientId, session); - this._completeDisconnectedClientToolCalls(clientId, session); + private _releaseActiveClientForSession(session: string, clientId: string, chatChannel: string): void { + this._clearClientToolCallDisconnectTimeout(clientId, chatChannel); + this._completeDisconnectedClientToolCalls(clientId, session, chatChannel); this._removeActiveClient(session, clientId); } @@ -872,17 +874,17 @@ export class ProtocolServerHandler extends Disposable { * to the first arm, so re-arms triggered by later orphaned tool calls in the * same session shrink the remaining window instead of resetting it. */ - private _startClientToolCallDisconnectTimeout(clientId: string, session: string): void { + private _startClientToolCallDisconnectTimeout(clientId: string, session: string, chatChannel: string): void { const record = this._ensureGraceRecord(clientId); if (!record) { // Client is connected; the grace machinery does not apply. return; } - record.disconnectTimeouts.deleteAndDispose(session); + record.disconnectTimeouts.deleteAndDispose(chatChannel); const elapsed = Date.now() - record.lastSeenAt; const delay = Math.max(0, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT - elapsed); - record.disconnectTimeouts.set(session, disposableTimeout(() => { - this._releaseActiveClientForSession(session, clientId); + record.disconnectTimeouts.set(chatChannel, disposableTimeout(() => { + this._releaseActiveClientForSession(session, clientId, chatChannel); }, delay)); } @@ -895,8 +897,8 @@ export class ProtocolServerHandler extends Disposable { * connected at stamp time) are failed immediately by the provider, so they * never reach a pending state here. */ - private _checkOrphanedClientToolCalls(session: string): void { - const state = this._stateManager.getSessionState(session); + private _checkOrphanedClientToolCalls(session: string, chatChannel: string): void { + const state = this._stateManager.getSessionState(chatChannel); const orphanOwners = new Set(); for (const { clientId } of this._pendingClientToolCalls(state)) { const ownerRecord = this._clients.get(clientId); @@ -905,7 +907,7 @@ export class ProtocolServerHandler extends Disposable { } } for (const ownerId of orphanOwners) { - this._startClientToolCallDisconnectTimeout(ownerId, session); + this._startClientToolCallDisconnectTimeout(ownerId, session, chatChannel); } } @@ -1016,15 +1018,15 @@ export class ProtocolServerHandler extends Disposable { } } - private _clearClientToolCallDisconnectTimeout(clientId: string, session: string): void { + private _clearClientToolCallDisconnectTimeout(clientId: string, channel: string): void { const record = this._clients.get(clientId); if (record?.state === 'grace') { - record.disconnectTimeouts.deleteAndDispose(session); + record.disconnectTimeouts.deleteAndDispose(channel); } } - private _completeDisconnectedClientToolCalls(clientId: string, session: string): void { - const state = this._stateManager.getSessionState(session); + private _completeDisconnectedClientToolCalls(clientId: string, session: string, chatChannel: string): void { + const state = this._stateManager.getSessionState(chatChannel); const activeTurn = state?.activeTurn; if (!state || !activeTurn) { return; @@ -1035,7 +1037,7 @@ export class ProtocolServerHandler extends Disposable { } const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); if (toolCall.status === ToolCallStatus.Streaming) { - this._stateManager.dispatchServerAction(session, { + this._stateManager.dispatchServerAction(chatChannel, { type: ActionType.ChatToolCallReady, turnId: activeTurn.id, toolCallId: toolCall.toolCallId, @@ -1043,7 +1045,7 @@ export class ProtocolServerHandler extends Disposable { confirmed: ToolCallConfirmationReason.NotNeeded, }); } - this._stateManager.dispatchServerAction(session, { + this._stateManager.dispatchServerAction(chatChannel, { type: ActionType.ChatToolCallComplete, turnId: activeTurn.id, toolCallId: toolCall.toolCallId, @@ -1480,7 +1482,14 @@ export class ProtocolServerHandler extends Disposable { return; } this._agentService.unsubscribe(URI.parse(sub.uri), client.clientId); - this._releaseActiveClientForSession(sub.uri, client.clientId); + if (isAhpChatChannel(sub.uri)) { + this._releaseActiveClientForSession(parseRequiredSessionUriFromChatUri(sub.uri), client.clientId, sub.uri); + } else { + const state = this._stateManager.getSessionState(sub.uri); + for (const chat of state?.chats ?? []) { + this._releaseActiveClientForSession(sub.uri, client.clientId, chat.resource); + } + } } else if (sub.kind === ChannelKind.ResourceWatch) { this._agentService.onResourceWatchUnsubscribed(sub.uri); } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 1ed2cb200f00d..06935134a02a3 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -21,6 +21,8 @@ import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { + isAhpChatChannel, + parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallConfirmationReason, type URI as ProtocolURI, @@ -364,9 +366,13 @@ export class SessionPermissionManager extends Disposable { * user selected "Allow in this Session". Adds the tool to the session's * permission allow list so future calls are auto-approved. */ - handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { + handleToolCallConfirmed(chatChannel: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { + if (!isAhpChatChannel(chatChannel)) { + throw new Error(`Tool call confirmations must be handled on an AHP chat channel: ${chatChannel}`); + } + const sessionKey = parseRequiredSessionUriFromChatUri(chatChannel); if (selectedOptionId === ALLOW_SESSION_OPTION_ID) { - const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); + const toolName = this._getToolNameForToolCall(chatChannel, toolCallId); if (toolName) { this._addToolToSessionPermissions(sessionKey, toolName); } diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 8ff1f6d1dcd11..c453955e86792 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -514,12 +514,12 @@ export const feedbackServerToolGroup: IServerToolGroup = { requiresConfirmation(toolName): boolean { return feedbackToolRequiresConfirmation(toolName); }, - execute(stateManager, sessionUri, toolName, rawArgs): string { + execute(stateManager, chatUri, toolName, rawArgs): string { // A session can contain multiple chats, each addressed by its own // `ahp-chat` URI but sharing the same context/workspace. Comments belong // to the session as a whole, so always resolve a chat URI back to its // owning session and operate on the main session's annotations channel. - const mainSessionUri = parseChatUri(sessionUri)?.session ?? sessionUri; + const mainSessionUri = parseChatUri(chatUri)?.session ?? chatUri; const annotationsUri = buildAnnotationsUri(mainSessionUri); const snapshot = stateManager.getSnapshot(annotationsUri); const state: AnnotationsState = (snapshot?.state as AnnotationsState | undefined) ?? { annotations: [] }; diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index beecf9b62f0c0..ebe65392b2310 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -182,10 +182,18 @@ suite('RootStateSubscription', () => { const sub = disposables.add(new RootStateSubscription('c1', noop)); sub.handleSnapshot(makeRootState(), 0); const err = new Error('failed'); + const errors: Error[] = []; + disposables.add(sub.onDidError(error => errors.push(error))); sub.setError(err); - assert.strictEqual(sub.value, err); - // verifiedValue should still be the state - assert.ok(sub.verifiedValue); + assert.deepStrictEqual({ + value: sub.value, + verifiedValueExists: !!sub.verifiedValue, + errors, + }, { + value: err, + verifiedValueExists: true, + errors: [err], + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index ef5ca47dbbc38..3419aff74af4c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -20,6 +20,7 @@ suite('AgentHostStateManager', () => { let disposables: DisposableStore; let manager: AgentHostStateManager; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + const sessionChatUri = buildDefaultChatUri(sessionUri); function makeSessionSummary(resource?: string): SessionSummary { return { @@ -225,7 +226,7 @@ suite('AgentHostStateManager', () => { assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -249,7 +250,7 @@ suite('AgentHostStateManager', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -264,7 +265,7 @@ suite('AgentHostStateManager', () => { test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -273,7 +274,7 @@ suite('AgentHostStateManager', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -291,25 +292,25 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(session2Uri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 2); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnComplete, turnId: 'turn-2', }); @@ -319,7 +320,7 @@ suite('AgentHostStateManager', () => { test('removeSession decrements active sessions when an active turn is stranded', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -360,14 +361,14 @@ suite('AgentHostStateManager', () => { // genuinely running. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', }); @@ -382,12 +383,12 @@ suite('AgentHostStateManager', () => { // from state's point of view. The count must mirror that. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'b', origin: { kind: MessageKind.User } }, @@ -395,7 +396,7 @@ suite('AgentHostStateManager', () => { assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-2', }); @@ -410,16 +411,16 @@ suite('AgentHostStateManager', () => { const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatError, turnId: 'turn-1', error: { errorType: 'failed', message: 'boom' }, @@ -440,16 +441,16 @@ suite('AgentHostStateManager', () => { const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'hi', origin: { kind: MessageKind.User } }, @@ -592,7 +593,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); // Start a turn → status becomes InProgress. - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -606,7 +607,7 @@ suite('AgentHostStateManager', () => { // Turn completes — status flips back to Idle. This schedules a summary // flush 100 ms later but we will call removeSession before it fires. - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -970,14 +971,14 @@ suite('AgentHostStateManager', () => { const idle = manager.hasActiveTurn(sessionUri); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); const afterStart = manager.hasActiveTurn(sessionUri); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -1000,12 +1001,12 @@ suite('AgentHostStateManager', () => { observed.push({ active: e.active, hasActiveTurn: manager.hasActiveTurn(sessionUri) }); })); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index b60cb9cab9d50..50a00273515aa 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -13,7 +14,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; -import { MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -85,6 +86,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const sessionUri = AgentSession.uri('mock', 'session-1'); const sessionKey = sessionUri.toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); function setupSession(): void { stateManager.createSession({ @@ -125,12 +127,12 @@ suite('AgentSideEffects — turn tracker telemetry', () => { // active turn (the progress-listener path relies on this) and then // invoke `handleAction` so the side-effect (which calls // `agent.sendMessage` and `turnTracker.turnStarted`) runs. - stateManager.dispatchClientAction(sessionKey, action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionKey, action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); } function fire(action: ChatAction): void { - agent.fireProgress({ kind: 'action', session: sessionUri, action }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action }); } function completedEvents(): { eventName: string; data: unknown }[] { @@ -266,7 +268,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { setupSession(); startTurn('turn-1'); - sideEffects.handleAction(sessionKey, { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); @@ -301,8 +303,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { id: 'q-err', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionKey, setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionKey, setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); await new Promise(r => setTimeout(r, 10)); @@ -318,7 +320,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { setupSession(); startTurn('turn-1'); - sideEffects.handleAction(sessionKey, { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1668ac3d78ef8..d742ebc6d6153 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -149,7 +149,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -158,7 +158,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(service.onDidAction(e => envelopes.push(e))); copilotAgent.fireProgress({ - kind: 'action', session, + kind: 'action', resource: URI.parse(buildDefaultChatUri(session.toString())), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } }, }); assert.ok(envelopes.some(e => e.action.type === ActionType.ChatResponsePart)); @@ -301,7 +301,7 @@ suite('AgentService (node dispatcher)', () => { })); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Please help me fix the TypeScript compile errors', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -328,7 +328,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Explain workspace search indexing', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -352,7 +352,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Create tests for terminal persistence', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -382,7 +382,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Investigate flaky terminal tests', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -409,13 +409,13 @@ suite('AgentService (node dispatcher)', () => { const { svc, session: sourceSession } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - sourceSession.toString(), + buildDefaultChatUri(sourceSession.toString()), { type: ActionType.ChatTurnStarted, turnId: 'source-turn', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.title === 'Source generated title', 'source generated title should be applied'); svc.dispatchAction( - sourceSession.toString(), + buildDefaultChatUri(sourceSession.toString()), { type: ActionType.ChatTurnComplete, turnId: 'source-turn' }, 'test-client', 2, ); @@ -486,7 +486,7 @@ suite('AgentService (node dispatcher)', () => { async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageResourceAttachment[] | { type: MessageAttachmentKind.EmbeddedResource; label: string; data: string; contentType: string; displayKind?: string }[]): Promise { svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', @@ -882,7 +882,7 @@ suite('AgentService (node dispatcher)', () => { // materialization completes), the session must stay visible so // renderer-side caches don't evict the in-flight session. service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -898,7 +898,7 @@ suite('AgentService (node dispatcher)', () => { // listSessions refresh in this window would evict the just-finished // session, reintroducing #321269's sibling eviction bug). service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, 'test-client', 2, ); @@ -2588,7 +2588,7 @@ suite('AgentService (node dispatcher)', () => { // when the refcount reaches zero, otherwise we'd drop live state // mid-response. service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); @@ -2896,12 +2896,12 @@ suite('AgentService (node dispatcher)', () => { const sessionResource = await service.createSession({ provider: 'copilot' }); service.addSubscriber(sessionResource, 'client-1'); service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, 'client-1', 2, ); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b0481742a131d..f671af8ce7181 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -20,10 +20,11 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { ChangesSummary, CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -142,6 +143,7 @@ suite('AgentSideEffects', () => { let telemetryService: TestTelemetryService; const sessionUri = AgentSession.uri('mock', 'session-1'); + const defaultChatUri = buildDefaultChatUri(sessionUri); function setupSession(workingDirectory?: string): void { stateManager.createSession({ @@ -158,8 +160,8 @@ suite('AgentSideEffects', () => { stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } - function startTurn(turnId: string): void { - stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.ChatTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, + function startTurn(turnId: string, channel = defaultChatUri): void { + stateManager.dispatchClientAction(channel, { type: ActionType.ChatTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -240,11 +242,11 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User } }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); await waitForSendMessageCalls(1); - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]); + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined, chat: URI.parse(defaultChatUri) }]); }); test('logs telemetry when sending a direct user message', () => { @@ -260,7 +262,7 @@ suite('AgentSideEffects', () => { stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), activeClientAction); const fileUri = URI.file('/workspace/direct.ts'); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] }, @@ -291,13 +293,14 @@ suite('AgentSideEffects', () => { message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }], + chat: URI.parse(defaultChatUri), }]); }); @@ -325,7 +328,7 @@ suite('AgentSideEffects', () => { }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ @@ -343,6 +346,7 @@ suite('AgentSideEffects', () => { }, }, }], + chat: URI.parse(defaultChatUri), }]); }); @@ -359,7 +363,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - noAgentSideEffects.handleAction(sessionUri.toString(), { + noAgentSideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -395,8 +399,8 @@ suite('AgentSideEffects', () => { message: { text: '/rename Renamed Session', origin: { kind: MessageKind.User } }, }; // Mirror production: the reducer applies the turn, then side effects run. - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); assert.deepStrictEqual(agent.sendMessageCalls, []); @@ -416,8 +420,8 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: '/rename', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); assert.deepStrictEqual(agent.sendMessageCalls, []); @@ -434,11 +438,11 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: '/renamed thing', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: '/renamed thing', attachments: undefined }]); + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), chat: URI.parse(defaultChatUri), prompt: '/renamed thing', attachments: undefined }]); }); }); @@ -465,7 +469,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Fix the login bug', origin: { kind: MessageKind.User } }, @@ -484,7 +488,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: ' ', origin: { kind: MessageKind.User } }, @@ -501,7 +505,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: longMessage, origin: { kind: MessageKind.User } }, @@ -522,7 +526,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); // Complete the first turn so turns.length becomes 1. - stateManager.dispatchServerAction(sessionUri.toString(), { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -530,7 +534,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'second message', origin: { kind: MessageKind.User } }, @@ -556,7 +560,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -571,7 +575,7 @@ suite('AgentSideEffects', () => { test('calls abortSession on the agent', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); @@ -588,7 +592,7 @@ suite('AgentSideEffects', () => { test('calls changeModel on the agent before sending the message', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, @@ -596,7 +600,7 @@ suite('AgentSideEffects', () => { await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: undefined }]); + assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }]); }); test('waits for model selection before sending the message', async () => { @@ -609,12 +613,12 @@ suite('AgentSideEffects', () => { agent.changeModelCalls.push({ session, model, chat }); await changeModelSettled; }; - agent.sendMessage = async (session, prompt, attachments, _turnId, chat) => { - agent.sendMessageCalls.push(chat ? { session, prompt, attachments, chat } : { session, prompt, attachments }); + agent.sendMessage = async (session, chat, prompt, attachments) => { + agent.sendMessageCalls.push({ session, prompt, attachments, chat }); resolveSend(); }; - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, @@ -625,24 +629,24 @@ suite('AgentSideEffects', () => { changeModelCalls: agent.changeModelCalls, sendMessageCalls: agent.sendMessageCalls, }, { - changeModelCalls: [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: undefined }], + changeModelCalls: [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }], sendMessageCalls: [], }); resolveChangeModel(); await sendStarted; - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello', attachments: undefined }]); + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello', attachments: undefined, chat: URI.parse(defaultChatUri) }]); }); test('forwards the chat channel for an additional (peer) chat', async () => { setupSession(); - const chatChannel = `${sessionUri.toString()}#peer-1`; - sideEffects.handleAction(sessionUri.toString(), { + const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1'); + sideEffects.handleAction(chatChannel, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, - }, chatChannel); + }); await new Promise(r => setTimeout(r, 10)); @@ -656,7 +660,7 @@ suite('AgentSideEffects', () => { test('calls changeAgent on the agent for the session default chat before sending the message', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, @@ -664,17 +668,17 @@ suite('AgentSideEffects', () => { await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: undefined }]); + assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: URI.parse(defaultChatUri) }]); }); test('forwards the chat channel for an additional (peer) chat', async () => { setupSession(); - const chatChannel = `${sessionUri.toString()}#peer-1`; - sideEffects.handleAction(sessionUri.toString(), { + const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1'); + sideEffects.handleAction(chatChannel, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, - }, chatChannel); + }); await new Promise(r => setTimeout(r, 10)); @@ -695,7 +699,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } }, }); @@ -712,14 +716,14 @@ suite('AgentSideEffects', () => { const listener = sideEffects.registerProgressListener(agent); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } }, }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1); listener.dispose(); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } }, }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1); @@ -825,8 +829,8 @@ suite('AgentSideEffects', () => { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); @@ -842,8 +846,8 @@ suite('AgentSideEffects', () => { id: 'q-1', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); // Queued messages are not forwarded to the agent; the server controls consumption assert.strictEqual(agent.setPendingMessagesCalls.length, 1); @@ -866,12 +870,13 @@ suite('AgentSideEffects', () => { message: { text: 'queued message', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }] }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), + chat: URI.parse(defaultChatUri), prompt: 'queued message', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }], }]); @@ -886,8 +891,8 @@ suite('AgentSideEffects', () => { id: 'q-telemetry', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); assert.deepStrictEqual(telemetryService.events, [{ eventName: 'agentHost.userMessageSent', @@ -912,8 +917,8 @@ suite('AgentSideEffects', () => { id: 'q-rm', message: { text: 'will be removed', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); agent.setPendingMessagesCalls.length = 0; @@ -923,8 +928,8 @@ suite('AgentSideEffects', () => { kind: PendingMessageKind.Queued, id: 'q-rm', }; - stateManager.dispatchClientAction(sessionUri.toString(), removeAction, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), removeAction); + stateManager.dispatchClientAction(defaultChatUri, removeAction, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, removeAction); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); @@ -935,19 +940,19 @@ suite('AgentSideEffects', () => { // Add two queued messages const setA = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-a', message: { text: 'A', origin: { kind: MessageKind.User } } }; - stateManager.dispatchClientAction(sessionUri.toString(), setA, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setA); + stateManager.dispatchClientAction(defaultChatUri, setA, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setA); const setB = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-b', message: { text: 'B', origin: { kind: MessageKind.User } } }; - stateManager.dispatchClientAction(sessionUri.toString(), setB, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), setB); + stateManager.dispatchClientAction(defaultChatUri, setB, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, setB); agent.setPendingMessagesCalls.length = 0; // Reorder const reorderAction = { type: ActionType.ChatQueuedMessagesReordered as const, order: ['q-b', 'q-a'] }; - stateManager.dispatchClientAction(sessionUri.toString(), reorderAction, { clientId: 'test', clientSeq: 3 }); - sideEffects.handleAction(sessionUri.toString(), reorderAction); + stateManager.dispatchClientAction(defaultChatUri, reorderAction, { clientId: 'test', clientSeq: 3 }); + sideEffects.handleAction(defaultChatUri, reorderAction); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); @@ -970,8 +975,8 @@ suite('AgentSideEffects', () => { id: 'q-auto', message: { text: 'auto queued', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // Message should NOT be consumed yet (turn is active) assert.strictEqual(agent.sendMessageCalls.length, 0); @@ -981,7 +986,7 @@ suite('AgentSideEffects', () => { // Fire idle → turn completes → queued message should be consumed agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -1017,16 +1022,16 @@ suite('AgentSideEffects', () => { id: 'q-after-abort', message: { text: 'queued behind abort', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // Not consumed yet — the turn is still active. assert.strictEqual(agent.sendMessageCalls.length, 0); // Cancel the active turn (client abort). const cancelAction = { type: ActionType.ChatTurnCancelled as const, turnId: 'turn-1' }; - stateManager.dispatchClientAction(sessionUri.toString(), cancelAction, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), cancelAction); + stateManager.dispatchClientAction(defaultChatUri, cancelAction, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, cancelAction); // The queued message must NOT auto-start, and must remain queued. assert.strictEqual(agent.sendMessageCalls.length, 0, 'cancelling must not drain queued messages'); @@ -1060,14 +1065,14 @@ suite('AgentSideEffects', () => { id: msg.id, message: { text: msg.text, origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, setAction); } // Fire idle → turn completes → `/rename` is consumed and intercepted, // then the message queued behind it must be drained to the agent. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -1107,7 +1112,7 @@ suite('AgentSideEffects', () => { // session URI with the chat channel passed as the `chat` argument // so the harness routes it to the right peer SDK conversation. agent.fireProgress({ - kind: 'action', session: chatUri, + kind: 'action', resource: chatUri, action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1' }, }); @@ -1133,8 +1138,8 @@ suite('AgentSideEffects', () => { id: 'q-wait', message: { text: 'should wait', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // No turn started for the queued message const turnStarted = envelopes.find(e => e.action.type === ActionType.ChatTurnStarted); @@ -1160,8 +1165,8 @@ suite('AgentSideEffects', () => { id: 'steer-rm', message: { text: 'steer me', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); // Removal is not dispatched synchronously; it waits for the agent let removal = envelopes.find(e => @@ -1173,7 +1178,7 @@ suite('AgentSideEffects', () => { // Simulate the agent consuming the steering message agent.fireProgress({ kind: 'steering_consumed', - session: sessionUri, + chat: URI.parse(defaultChatUri), id: 'steer-rm', }); @@ -1248,7 +1253,7 @@ suite('AgentSideEffects', () => { currentCustomizations = [loading]; agent.fireProgress({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationsChanged, customizations: [...currentCustomizations], @@ -1260,7 +1265,7 @@ suite('AgentSideEffects', () => { currentCustomizations = [loaded]; agent.fireProgress({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationUpdated, customization: loaded, @@ -1381,7 +1386,7 @@ suite('AgentSideEffects', () => { }; sideEffects.handleAction(sessionUri.toString(), action); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User } }, @@ -1465,12 +1470,12 @@ suite('AgentSideEffects', () => { test('routes confirmation to correct agent via _toolCallAgents', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); // Fire tool_start to register the tool call agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', contributor: undefined, @@ -1478,7 +1483,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined, @@ -1488,7 +1493,7 @@ suite('AgentSideEffects', () => { // Fire tool_ready asking for permission (non-write, so not auto-approved) agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-conf-1', toolName: '', displayName: '', @@ -1499,7 +1504,7 @@ suite('AgentSideEffects', () => { }); // Now confirm the tool call - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-conf-1', @@ -1514,11 +1519,11 @@ suite('AgentSideEffects', () => { test('handles denial of tool call', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, @@ -1526,7 +1531,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined, @@ -1534,7 +1539,7 @@ suite('AgentSideEffects', () => { }, }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-deny-1', @@ -1559,7 +1564,7 @@ suite('AgentSideEffects', () => { // tool_start puts the tool call into Streaming state agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1575,7 +1580,7 @@ suite('AgentSideEffects', () => { // tool_ready without confirmationTitle should dispatch the ready // action and advance the tool call to Running agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-ready-1', toolName: '', displayName: '', @@ -1602,7 +1607,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1613,7 +1618,7 @@ suite('AgentSideEffects', () => { // tool_ready with confirmationTitle should dispatch the ready // action and advance the tool call to PendingConfirmation agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-1', toolName: '', displayName: '', @@ -1634,6 +1639,74 @@ suite('AgentSideEffects', () => { 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); + test('tool_ready for an additional chat is emitted on that chat channel', async () => { + setupSession(); + const chatUri = buildChatUri(sessionUri.toString(), 'peer'); + stateManager.addChat(sessionUri.toString(), chatUri); + stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Permissions]: { allow: [], deny: [] } } }); + startTurn('turn-peer', chatUri); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(chatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-peer', + toolCallId: 'tc-peer-perm', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(chatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-peer-perm', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: '{"path":".env"}', + confirmationTitle: 'Write .env', edits: undefined, + }, + permissionKind: undefined, permissionPath: undefined, + }); + + const chatState = await waitForState(stateManager, () => { + const s = stateManager.getChatState(chatUri); + const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); + const defaultState = stateManager.getSessionState(sessionUri.toString()); + const defaultPart = defaultState?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + const peerPart = chatState.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + const readyEnvelope = envelopes.find(e => e.action.type === ActionType.ChatToolCallReady && hasKey(e.action, { toolCallId: true }) && e.action.toolCallId === 'tc-peer-perm'); + + assert.deepStrictEqual({ + peerToolStatus: peerPart?.kind === ResponsePartKind.ToolCall + ? peerPart.toolCall.status + : undefined, + defaultHasTool: defaultPart !== undefined, + readyEnvelopeChannel: readyEnvelope?.channel, + }, { + peerToolStatus: ToolCallStatus.PendingConfirmation, + defaultHasTool: false, + readyEnvelopeChannel: chatUri, + }); + + sideEffects.handleAction(chatUri, { + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-peer', + toolCallId: 'tc-peer-perm', + approved: true, + confirmed: 'user-action' as const, + selectedOptionId: 'allow-session', + } as ChatAction); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-peer-perm', approved: true }, + ]); + assert.deepStrictEqual(stateManager.getSessionState(sessionUri.toString())?.config?.values[SessionConfigKey.Permissions], { allow: ['write'], deny: [] }); + }); + test('pending_confirmation for a tool inside a subagent routes to the subagent session', async () => { // Regression: a `pending_confirmation` signal for a client tool // inside a subagent must dispatch ChatToolCallReady against @@ -1646,7 +1719,7 @@ suite('AgentSideEffects', () => { // Parent tool that delegates to a subagent. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, @@ -1654,18 +1727,18 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded, }, }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); // Inner client tool starts inside the subagent. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, @@ -1675,10 +1748,10 @@ suite('AgentSideEffects', () => { // Permission flow fires `pending_confirmation` for the inner // client tool. The signal must be routed to the subagent - // session — not to the parent — even though the signal carries - // only the parent session URI. + // chat — not to the parent — when the signal carries the parent + // chat URI and parentToolCallId. agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', @@ -1688,8 +1761,8 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - // The subagent session must contain the ChatToolCallReady. - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); + // The subagent chat must contain the ChatToolCallReady. + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); const subState = await waitForState(stateManager, () => { const s = stateManager.getSessionState(subagentUri); const inner = s?.activeTurn?.responseParts.find( @@ -1729,7 +1802,7 @@ suite('AgentSideEffects', () => { // Start a tool in the active turn agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-noop', toolName: 'view', displayName: 'Read', @@ -1740,14 +1813,14 @@ suite('AgentSideEffects', () => { // Complete the turn — state manager no longer has an active turn agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-noop', result: { success: true, pastTenseMessage: 'Read file' }, }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -1757,7 +1830,7 @@ suite('AgentSideEffects', () => { // Simulate the hook-triggered continuation: tool actions // arrive without a new protocol turn being started agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: '', toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read', @@ -1768,7 +1841,7 @@ suite('AgentSideEffects', () => { // Now the pending_confirmation arrives — this must NOT be dropped agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read', @@ -1819,7 +1892,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1827,7 +1900,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -1836,7 +1909,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-bypass-1', toolName: '', displayName: '', @@ -1859,7 +1932,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-bypass-shell-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, @@ -1867,7 +1940,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-bypass-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined, @@ -1876,7 +1949,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-bypass-shell-1', toolName: '', displayName: '', @@ -1900,7 +1973,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sandboxbypass-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, @@ -1909,7 +1982,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-sandboxbypass-1', toolName: '', displayName: '', @@ -1929,11 +2002,11 @@ suite('AgentSideEffects', () => { test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => { setupSessionWithConfig('autoApprove'); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1942,7 +2015,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task', @@ -1969,7 +2042,7 @@ suite('AgentSideEffects', () => { permissionCalls: [], }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-client-approve-1', @@ -1988,7 +2061,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1996,7 +2069,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -2005,7 +2078,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-default-1', toolName: '', displayName: '', @@ -2031,7 +2104,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2039,7 +2112,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -2048,7 +2121,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-mid-1', toolName: '', displayName: '', @@ -2076,7 +2149,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2084,7 +2157,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined, @@ -2093,7 +2166,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-auto-1', toolName: '', displayName: '', @@ -2119,7 +2192,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2127,7 +2200,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -2136,7 +2209,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-env-1', toolName: '', displayName: '', @@ -2160,7 +2233,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2168,7 +2241,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined, @@ -2177,7 +2250,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-pkg-1', toolName: '', displayName: '', @@ -2196,7 +2269,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2204,7 +2277,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined, @@ -2213,7 +2286,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-lock-1', toolName: '', displayName: '', @@ -2232,7 +2305,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2240,7 +2313,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined, @@ -2249,7 +2322,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-git-1', toolName: '', displayName: '', @@ -2273,7 +2346,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2281,7 +2354,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined, @@ -2290,7 +2363,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-read-1', toolName: '', displayName: '', @@ -2315,7 +2388,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2323,7 +2396,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined, @@ -2332,7 +2405,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-read-2', toolName: '', displayName: '', @@ -2489,14 +2562,14 @@ suite('AgentSideEffects', () => { suite('subagent sessions', () => { - test('subagent_started creates a subagent session and dispatches content on parent tool call', () => { + test('subagent_started creates a subagent chat and dispatches content on parent tool call', () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); // Start a parent tool call agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, @@ -2504,7 +2577,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined, @@ -2514,19 +2587,21 @@ suite('AgentSideEffects', () => { // Fire subagent_started agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, + kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'code-reviewer', agentDisplayName: 'Code Reviewer', agentDescription: 'Reviews code', }); - // Verify the subagent session was created - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + // Verify the subagent chat was created + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState, 'subagent session should exist'); - assert.strictEqual(subState!.title, 'Code Reviewer'); - assert.ok(subState!.activeTurn, 'subagent should have an active turn'); + assert.ok(subState, 'subagent chat should exist'); + const subagentSummary = subState!.chats.find(c => c.resource === subagentUri); + assert.strictEqual(subagentSummary?.title, 'Code Reviewer'); + assert.deepStrictEqual(subagentSummary?.origin, { kind: 'tool', chat: defaultChatUri, toolCallId: 'tc-1' }); + assert.ok(subState!.activeTurn, 'subagent chat should have an active turn'); // Verify content was dispatched on the parent tool call const parentState = stateManager.getSessionState(sessionUri.toString()); @@ -2547,13 +2622,13 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire an inner tool start with parentToolCallId agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, @@ -2561,7 +2636,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, @@ -2569,14 +2644,14 @@ suite('AgentSideEffects', () => { }, }); - // Verify the inner tool call is on the subagent session's turn, not the parent - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + // Verify the inner tool call is on the subagent chat's turn, not the parent + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn); const innerTool = subState!.activeTurn!.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' ); - assert.ok(innerTool, 'inner tool call should be in subagent session'); + assert.ok(innerTool, 'inner tool call should be in subagent chat'); // Verify the parent session does NOT have the inner tool call const parentState = stateManager.getSessionState(sessionUri.toString()); @@ -2596,12 +2671,12 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // Inner event arrives but `subagent_started` never does. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2609,7 +2684,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, @@ -2619,7 +2694,7 @@ suite('AgentSideEffects', () => { // Parent tool completes (e.g. it errored before delegating). agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2630,9 +2705,9 @@ suite('AgentSideEffects', () => { // Now a late `subagent_started` for the same toolCallId arrives. // This is unusual but possible after a reconnect/replay. The // drain must NOT replay the (cleared) buffered inner tool call. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState, 'subagent session should still be created'); const innerTool = subState!.activeTurn?.responseParts.find( @@ -2647,15 +2722,15 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Completing the parent tool call must NOT tear down the // subagent session — background subagents keep running after // their parent tool call returns. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2663,62 +2738,61 @@ suite('AgentSideEffects', () => { }, }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); let subState = stateManager.getSessionState(subagentUri); assert.ok(subState); assert.ok(subState!.activeTurn, 'subagent turn should still be active after parent tool completes'); // The SDK's `subagent.completed`/`subagent.failed` event is what // actually closes the subagent session. - agent.fireProgress({ kind: 'subagent_completed', session: sessionUri, toolCallId: 'tc-1' }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1' }); subState = stateManager.getSessionState(subagentUri); assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed'); assert.strictEqual(subState!.turns.length, 1); }); - test('cancelSubagentSessions cancels all subagent sessions', () => { + test('cancelSubagentSessions cancels all subagent chats', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); // Start two parent tool calls with subagents - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); - // Cancel via parent turn cancellation - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); - // Both subagent sessions should have their turns completed (cancelled) - const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`); - const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`); + // Both subagent chats should have their turns completed (cancelled) + const sub1 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-1')); + const sub2 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-2')); assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled'); assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled'); }); - test('removeSubagentSessions removes all subagent sessions from state', () => { + test('removeSubagentSessions removes all subagent chats from state', () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - assert.ok(stateManager.getSessionState(subagentUri)); + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); + assert.ok(stateManager.getChatState(subagentUri)); sideEffects.removeSubagentSessions(sessionUri.toString()); - assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed'); + assert.strictEqual(stateManager.getChatState(subagentUri), undefined, 'subagent chat should be removed'); }); test('deltas with parentToolCallId route to subagent session', () => { @@ -2726,18 +2800,18 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire a delta with parentToolCallId agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } }, }); // Verify the delta went to the subagent session - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn); const markdownPart = subState!.activeTurn!.responseParts.find( @@ -2751,9 +2825,9 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); // Verify subagent content is on the running tool const runningState = stateManager.getSessionState(sessionUri.toString()); @@ -2765,7 +2839,7 @@ suite('AgentSideEffects', () => { // Complete the tool — the SDK result has its own content agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2796,12 +2870,12 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // 1. Parent tool starts (the `task` invocation). - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // 2. Inner tool fires BEFORE subagent_started (race condition). agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, @@ -2809,7 +2883,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, @@ -2818,9 +2892,9 @@ suite('AgentSideEffects', () => { }); // 3. subagent_started arrives later. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn, 'subagent session should exist'); @@ -2847,14 +2921,14 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Parent task tool spawns a subagent. - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner tool inside the subagent requests permission to read a file // inside the parent workspace. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2862,7 +2936,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined, @@ -2870,7 +2944,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-read-1', toolName: '', displayName: '', @@ -2908,14 +2982,14 @@ suite('AgentSideEffects', () => { values: { autoApprove: 'autoApprove' }, }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner write outside the workspace would normally NOT auto-approve, // but session-level autoApprove on the parent must apply. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2923,7 +2997,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined, @@ -2931,7 +3005,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-write-1', toolName: '', displayName: '', @@ -2958,7 +3032,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -2966,7 +3040,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -2975,7 +3049,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-1', toolName: '', displayName: '', @@ -3007,11 +3081,11 @@ suite('AgentSideEffects', () => { schema: { type: 'object', properties: {} }, values: {}, }); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -3019,7 +3093,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -3028,7 +3102,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-2', toolName: '', displayName: '', @@ -3038,7 +3112,7 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-perm-2', @@ -3064,7 +3138,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -3072,7 +3146,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -3081,7 +3155,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-3', toolName: '', displayName: '', @@ -3107,7 +3181,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, @@ -3115,7 +3189,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, @@ -3123,7 +3197,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, + kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', @@ -3131,7 +3205,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -3139,7 +3213,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -3148,7 +3222,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-perm-1', toolName: '', displayName: '', @@ -3185,7 +3259,7 @@ suite('AgentSideEffects', () => { // tool_start + tool_ready + tool_complete with a recorded file edit. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-edit-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -3193,7 +3267,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-edit-1', invocationMessage: 'Write file', toolInput: undefined, @@ -3201,7 +3275,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-edit-1', @@ -3234,7 +3308,7 @@ suite('AgentSideEffects', () => { disposables.add(localSideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -3259,7 +3333,7 @@ suite('AgentSideEffects', () => { onTurnComplete: () => { }, }, undefined, NullTelemetryService, changesets); - localSideEffects.handleAction(sessionUri.toString(), { + localSideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-1', }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 8f7fff102cf56..ffc6312df9484 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -51,7 +51,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -645,7 +645,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { // First send materializes — drives `startup()`, which performs // the real HTTP round-trip on the real proxy. - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); // Snapshot what flowed through the integration in a single // assertion so the failure surface is the whole pipeline. @@ -766,7 +766,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { const sessionId = created.session.path.replace(/^\//, ''); sdk.queryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const startup = sdk.capturedStartupOptions[0]; assert.ok(typeof startup.canUseTool === 'function', 'canUseTool was wired into Options'); @@ -856,7 +856,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { } })); - await agent.sendMessage(created.session, 'please read /tmp/x', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'please read /tmp/x', undefined, 'turn-1'); // Snapshot the agent-side emission stream as a single shape so // the failure surface is the whole pipeline. diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 24b3cf6c981d5..7e26a85d31099 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -43,7 +43,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, customizationId, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildDefaultChatUri, buildSubagentSessionUri, customizationId, parseDefaultChatUri, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -1473,7 +1473,7 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work-resume'), model: initialModel }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); // Phase 2: user changes the model post-materialize — this hits the // runtime path inside session.setModel and rewrites the overlay. @@ -1485,7 +1485,7 @@ suite('ClaudeAgent', () => { await agent.disposeSession(created.session); sdk.sessionList = [{ sessionId, cwd: '/work-resume', summary: '', lastModified: Date.now() }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'turn 2', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn 2', undefined, 'turn-2'); // Phase 4: confirm the resume started the SDK with the updated model // from Phase 2. Model selection is no longer surfaced on @@ -1555,7 +1555,7 @@ suite('ClaudeAgent', () => { // First send resumes the forked file: the Query starts with `resume`. sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; - await agent.sendMessage(newUri, 'next', undefined, 'turn-1'); + await agent.sendMessage(newUri, URI.parse(buildDefaultChatUri(newUri)), 'next', undefined, 'turn-1'); assert.deepStrictEqual({ atForkTime, @@ -1610,9 +1610,9 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); await agent.truncateSession(created.session, 'u1'); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, @@ -1638,7 +1638,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); // Unload the session from memory; the transcript stays resumable. await agent.disposeSession(created.session); @@ -1647,7 +1647,7 @@ suite('ClaudeAgent', () => { await agent.truncateSession(created.session, 'u1'); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ @@ -1668,7 +1668,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); await assert.rejects(() => agent.truncateSession(created.session, 'no-such-turn'), /turn no-such-turn not found/); }); @@ -1699,12 +1699,12 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); await agent.truncateSession(created.session); // The next turn materializes FRESH (non-resume) on the SAME id. - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ deleted: sdk.deleteSessionCalls, @@ -1732,7 +1732,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); // Block the live query's teardown (models `transport.waitForExit()` — // the subprocess not yet exited / still flushing the transcript). @@ -1758,7 +1758,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); // Unload the session from memory; the transcript stays on disk. The // remove-all path then has no live `existing` and must read the cwd @@ -1774,7 +1774,7 @@ suite('ClaudeAgent', () => { await agent.truncateSession(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ deleted: sdk.deleteSessionCalls, @@ -1817,7 +1817,7 @@ suite('ClaudeAgent', () => { // Fork defers the Query; materialize it via the first send. The resume // path reads the inherited overlay into `Options.permissionMode`. sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; - await agent.sendMessage(result.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.permissionMode, 'plan'); }); @@ -1841,7 +1841,7 @@ suite('ClaudeAgent', () => { // started with on its first send. const forkedId = AgentSession.id(result.session); sdk.nextQueryMessages = [makeSystemInitMessage(forkedId), makeResultSuccess(forkedId)]; - await agent.sendMessage(result.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.model, 'claude-opus-4-6'); }); @@ -1937,7 +1937,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -1971,7 +1971,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'my-real-agent'); }); @@ -1988,7 +1988,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'Explore'); }); @@ -2013,7 +2013,7 @@ suite('ClaudeAgent', () => { assert.ok(agent.onDidMaterializeSession); disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.strictEqual(events.length, 1, 'event fires exactly once'); const ev = events[0]; @@ -2051,7 +2051,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -2086,7 +2086,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -2132,7 +2132,7 @@ suite('ClaudeAgent', () => { ]; // First turn — materializes; resolves on result(idx=1). - await agent.sendMessage(created.session, 'turn-1', undefined, 'turn-id-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn-1', undefined, 'turn-id-1'); // Snapshot before the second send so we can assert the second send // did NOT call startup() again. @@ -2140,7 +2140,7 @@ suite('ClaudeAgent', () => { const queryCallsAfterTurn1 = sdk.warmQueries[0]?.queryCallCount ?? -1; // Second turn — pushes onto the existing Query. - const p2 = agent.sendMessage(created.session, 'turn-2', undefined, 'turn-id-2'); + const p2 = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn-2', undefined, 'turn-id-2'); // Drain microtasks so `await entry.setPermissionMode(...)` resolves // and `entry.send(...)` synchronously pushes the second prompt onto // the in-flight queue BEFORE we release the iterator gate. Otherwise @@ -2199,7 +2199,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -2233,14 +2233,14 @@ suite('ClaudeAgent', () => { partIdsMatch: part.part.id === firstDelta.partId && part.part.id === secondDelta.partId, turnId: part.turnId, deltaTexts: [firstDelta.content, secondDelta.content], - session: partActions[0].s.kind === 'action' ? partActions[0].s.session.toString() : undefined, + session: partActions[0].s.kind === 'action' ? partActions[0].s.resource.toString() : undefined, }, { partKindIsMarkdown: true, partPrecedesDelta: true, partIdsMatch: true, turnId: 'turn-1', deltaTexts: ['hello ', 'world'], - session: created.session.toString(), + session: buildDefaultChatUri(created.session), }); }); @@ -2268,7 +2268,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -2347,7 +2347,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const tail = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2410,7 +2410,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const usage = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2449,7 +2449,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2512,7 +2512,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const responsePartCount = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2556,7 +2556,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2593,7 +2593,7 @@ suite('ClaudeAgent', () => { sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; // Snapshot before the SDK has streamed any messages. - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session); assert.ok(session, 'session is materialized'); @@ -2634,7 +2634,7 @@ suite('ClaudeAgent', () => { // queued by `entry.send`). Without this we'd race materialize. const materialized = Event.toPromise(agent.onDidMaterializeSession); - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2725,7 +2725,7 @@ suite('ClaudeAgent', () => { // Kick off the materialize. It will pass the post-startup abort // gate, create the wrapper, then park inside `setMetadata`. - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2781,7 +2781,7 @@ suite('ClaudeAgent', () => { // Materializing now requires a provisional record; without it // the sequencer task throws synchronously inside the queued fn. - const sendErr = await agent.sendMessage(created.session, 'hi', undefined, 'turn-1') + const sendErr = await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2831,7 +2831,7 @@ suite('ClaudeAgent', () => { const events: IAgentMaterializeSessionEvent[] = []; disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1'); + await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -2870,7 +2870,7 @@ suite('ClaudeAgent', () => { const sessionUri = AgentSession.uri('claude', 'ghost-session-id'); // sdk.sessionList stays empty — getSessionInfo resolves undefined. - const sendErr = await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1') + const sendErr = await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2922,10 +2922,10 @@ suite('ClaudeAgent', () => { cwd: URI.file('/work').fsPath, }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, 'turn-1', undefined, 't1'); + await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'turn-1', undefined, 't1'); sdk.nextQueryMessages = [makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, 'turn-2', undefined, 't2'); + await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'turn-2', undefined, 't2'); const fakeQuery = sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -2961,7 +2961,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(AgentSession.id(matCreated.session)), makeResultSuccess(AgentSession.id(matCreated.session)), ]; - await agent.sendMessage(matCreated.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(matCreated.session, URI.parse(buildDefaultChatUri(matCreated.session)), 'hi', undefined, 'turn-1'); // Leave a second session provisional. const provCreated = await agent.createSession({ workingDirectory: URI.file('/work-prov') }); @@ -2992,10 +2992,10 @@ suite('ClaudeAgent', () => { matWarmAsyncDisposed: matWarm.asyncDisposeCount > asyncDisposeBefore, // A post-shutdown sendMessage to the provisional URI must // fail because the provisional record was cleared. - provDropped: await agent.sendMessage(provCreated.session, 'late', undefined, 'turn-late') + provDropped: await agent.sendMessage(provCreated.session, URI.parse(buildDefaultChatUri(provCreated.session)), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), // Same for the materialized URI. - matDropped: await agent.sendMessage(matCreated.session, 'late', undefined, 'turn-late') + matDropped: await agent.sendMessage(matCreated.session, URI.parse(buildDefaultChatUri(matCreated.session)), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), }, { memoized: true, @@ -3027,7 +3027,8 @@ suite('ClaudeAgent', () => { const sessionUri = created.session; const observed: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => { - if (AgentSession.id(s.session) === AgentSession.id(sessionUri)) { + const resource = s.kind === 'action' ? s.resource : s.chat; + if ((parseDefaultChatUri(resource) ?? resource.toString()) === sessionUri.toString()) { observed.push(s); } })); @@ -3053,7 +3054,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const deltas = observed.flatMap(s => s.kind === 'action' && s.action.type === ActionType.ChatDelta @@ -3088,7 +3089,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-explicit'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-explicit'); const drained = sdk.warmQueries[0]?.produced?.drainedPrompts ?? []; assert.deepStrictEqual({ @@ -3121,7 +3122,7 @@ suite('ClaudeAgent', () => { const fileUri = URI.file('/work/src/foo.ts'); const dirUri = URI.file('/work/src/bar'); - await agent.sendMessage(created.session, 'review please', [ + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'review please', [ { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: dirUri.toString(), label: 'bar', displayKind: 'directory' }, ], 'turn-1'); @@ -3884,7 +3885,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -3933,7 +3934,7 @@ suite('ClaudeAgent', () => { agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = tools; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); const opts = sdk.capturedStartupOptions[0]; assert.ok(opts.mcpServers, 'mcpServers populated'); @@ -3961,13 +3962,13 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.queryAdvance = undefined; advance.complete(); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); const lastBuild = sdk.createSdkMcpServerCalls[sdk.createSdkMcpServerCalls.length - 1]; assert.deepStrictEqual({ @@ -3995,7 +3996,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); // Stage a pending truncation anchor, then send again. The pending anchor @@ -4003,7 +4004,7 @@ suite('ClaudeAgent', () => { await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); sdk.queryAdvance = undefined; advance.complete(); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, @@ -4027,12 +4028,12 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); // A later tool-driven rebind must NOT resurrect the consumed anchor. agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; - await agent.sendMessage(created.session, 'third', undefined, 'turn-3'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'third', undefined, 'turn-3'); const anchored = sdk.capturedStartupOptions.filter(o => o.resumeSessionAt === 'anchor-uuid'); assert.deepStrictEqual({ @@ -4054,17 +4055,17 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); // The anchor-carrying rebuild fails at startup (one-shot). The anchor // must NOT be cleared — losing it would silently proceed without // `resumeSessionAt`, undoing the checkpoint restore. sdk.startupRejection = new Error('transient startup failure'); - await assert.rejects(() => agent.sendMessage(created.session, 'second', undefined, 'turn-2')); + await assert.rejects(() => agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2')); // Retry: the staged anchor is re-applied on the next (now-succeeding) send. - await agent.sendMessage(created.session, 'second-retry', undefined, 'turn-2b'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second-retry', undefined, 'turn-2b'); assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.resumeSessionAt, 'anchor-uuid'); }); @@ -4075,7 +4076,7 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session)!; await session.truncateToTurn('turn-1', 'anchor-uuid'); @@ -4102,12 +4103,12 @@ suite('ClaudeAgent', () => { const tools: ToolDefinition[] = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = tools; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; advance.complete(); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 1, 'equal snapshot should NOT yield-restart'); }); @@ -4126,7 +4127,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); // Completion for an unknown tool_use_id is a benign no-op (no parked // handler in this test path because we don't drive the real MCP @@ -4161,7 +4162,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); await assert.doesNotReject(agent.disposeSession(created.session)); }); @@ -4172,11 +4173,11 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); // Change tools to force a rebind path (must use yield-restart, NOT Query.setMcpServers). agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo2', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); // If `Query.setMcpServers` had been called, `FakeQuery.setMcpServers` would have thrown. assert.strictEqual(sdk.startupCallCount, 2, 'rebind path used yield-restart, not setMcpServers'); }); @@ -4201,7 +4202,7 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); // Wait until the materializer has snapshotted ['first'] into the diff // and is paused inside `sdk.startup`. THEN inject the update. await startupReached.p; @@ -4249,7 +4250,7 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1'); + const send = agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1'); // Wait until the resume's `sdk.startup` is in flight, then inject the // update. Pre-fix the call hit the silent-drop branch because no // provisional was registered for the resume. @@ -4282,7 +4283,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Stage a rebind whose startup will reject. @@ -4290,7 +4291,7 @@ suite('ClaudeAgent', () => { sdk.startupRejection = new Error('simulated rebind startup failure'); sdk.queryAdvance = undefined; advance.complete(); - await assert.rejects(agent.sendMessage(created.session, 'second', undefined, 'turn-2')); + await assert.rejects(agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2')); // Pre-fix: `_buildClientMcpServers` consumed the diff, but the SDK // startup that followed rejected without re-marking dirty, so the next @@ -4299,7 +4300,7 @@ suite('ClaudeAgent', () => { // send retries the rebind and succeeds. sdk.startupRejection = undefined; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'third', undefined, 'turn-3'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'third', undefined, 'turn-3'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, lastSnapshot: sdk.createSdkMcpServerCalls.at(-1)?.toolNames, @@ -4421,7 +4422,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { values: { ...(seedConfig ?? {}) }, }; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); @@ -4490,7 +4491,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { assert.deepStrictEqual(captured, { kind: 'pending_confirmation', - session: sessionUri, + chat: URI.parse(buildDefaultChatUri(sessionUri)), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tu_shape', @@ -4703,7 +4704,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { } })); - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); return { ctx, canUseTool, inputRequests, sessionUri: created.session }; @@ -4798,7 +4799,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { }, { signal: { kind: 'pending_confirmation', - session: sessionUri, + chat: URI.parse(buildDefaultChatUri(sessionUri)), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tu_plan_ok', @@ -4916,9 +4917,9 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = makeResultSuccess(sessionId), ]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); ctx.configService.updateSessionConfig(created.session.toString(), { permissionMode: 'acceptEdits' }); - const p2 = ctx.agent.sendMessage(created.session, 'hi-2', undefined, 'turn-2'); + const p2 = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi-2', undefined, 'turn-2'); // Drain microtasks so `await entry.setPermissionMode('acceptEdits')` // resolves and the second prompt lands in the in-flight queue before // the iterator yields its `result(idx=2)` (see the multi-turn reuse @@ -4962,7 +4963,7 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const fakeQuery = ctx.sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -4990,7 +4991,7 @@ suite('ClaudeAgent (Phase 7 §3.7 — onElicitation cancel stub)', () => { const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const onElicitation = ctx.sdk.capturedStartupOptions[0]?.onElicitation; assert.ok(onElicitation, 'onElicitation callback was wired into Options'); @@ -5018,7 +5019,7 @@ suite('ClaudeAgent (Phase 8 — file edit tracking via SDK message stream)', () const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); return { ctx, sessionId, sessionUri: created.session }; } @@ -5093,7 +5094,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sessionId), ...(opts?.extraMessages ?? [makeResultSuccess(sessionId)]), ]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const warm = ctx.sdk.warmQueries[0]; const query = warm.produced!; return { ctx, sessionUri: created.session, sessionId, warm, query, advance }; @@ -5113,7 +5114,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { assert.strictEqual(ctx.sdk.startupCallCount, 0); const sid = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const opts = ctx.sdk.capturedStartupOptions[0]; assert.deepStrictEqual({ model: opts.model, effort: opts.effort }, { model: 'claude-sonnet-4-6', effort: 'medium' }); }); @@ -5122,7 +5123,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const { ctx, sessionUri, query, advance } = await materialize(); await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5141,7 +5142,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const { ctx, sessionUri, query, advance } = await materialize({ logService: log }); await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6', config: { thinkingLevel: 'max' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5154,7 +5155,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const { ctx, sessionUri, query, advance } = await materialize(); await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6' }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5171,7 +5172,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Start a long turn that parks at the gate so steering has // something to steer into. - const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-1', message: { text: 'switch topic', origin: { kind: MessageKind.User } } }, []); @@ -5207,7 +5208,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const signals: AgentSignal[] = []; disposables.add(ctx.agent.onDidSessionProgress(s => signals.push(s))); - const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-9', message: { text: 'steer', origin: { kind: MessageKind.User } } }, []); @@ -5244,7 +5245,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 0) { await stall.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); await tick(); await ctx.agent.abortSession(created.session); @@ -5258,7 +5259,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Next sendMessage rebuilds via resume mode. const startupBefore = ctx.sdk.startupCallCount; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'next', undefined, 'turn-2'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'next', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'rebind called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; @@ -5277,7 +5278,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Materialize the session by driving one full turn so canUseTool is wired into Options. ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool was wired into Options'); @@ -5305,7 +5306,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { throw new Error('subprocess crashed'); } }; await assert.rejects( - ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'), + ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'), (err: Error) => err.message.includes('subprocess crashed'), ); @@ -5313,7 +5314,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; const startupBefore = ctx.sdk.startupCallCount; - await ctx.agent.sendMessage(created.session, 'recover', undefined, 'turn-2'); + await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'recover', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'crash recovery called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; assert.strictEqual(resumeOpts.resume, sid); @@ -5325,7 +5326,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Hot-swap model + effort on the live query so the bijective // cache picks up the new values. await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'apply', undefined, 'turn-2'); + const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'apply', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5336,7 +5337,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { await ctx.agent.abortSession(sessionUri); ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(sessionUri, 'after-abort', undefined, 'turn-3'); + await ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'after-abort', undefined, 'turn-3'); const reboundQuery = ctx.sdk.warmQueries[1].produced!; assert.deepStrictEqual({ @@ -5367,7 +5368,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { await advance.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'long task', undefined, 'turn-1'); await tick(); // Subscribe BEFORE injecting steering so we capture the @@ -5435,7 +5436,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sid), // final (unblocked by advance2) ]; - const inFlight = ctx.agent.sendMessage(created.session, 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'long task', undefined, 'turn-1'); let inFlightResolved = false; void inFlight.then(() => { inFlightResolved = true; }, () => { inFlightResolved = true; }); await tick(); @@ -5741,7 +5742,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Customization sync flips dirty; the next sendMessage's @@ -5751,7 +5752,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const firstQuery = sdk.warmQueries[0].produced!; - const p2 = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + const p2 = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5779,7 +5780,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ]; pm.syncResult = [makeSyncedRef('https://x', '/p/x')]; await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://x', 'X')]); - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session)!; // First-turn materialize consumed the dirty bit from the sync // above (plugin path baked into `Options.plugins` of the @@ -5792,7 +5793,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const gate = new DeferredPromise(); sdk.queryAdvance = async (i: number) => { if (i === 2) { await gate.p; } }; - const inflight = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + const inflight = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); await new Promise(r => setImmediate(r)); // Toggle a SYNCED customization during the in-flight turn. The @@ -5821,7 +5822,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); // SDK snapshot failed → `sdk` stays undefined → unfiltered fallback: @@ -5847,7 +5848,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { sdk.supportedAgentsResult = []; sdk.mcpServerStatusResult = []; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); assert.strictEqual(customizations.length, 1); @@ -5890,7 +5891,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const init = makeSystemInitMessage(sessionId); init.plugins = [{ name: 'tg', path: root }]; sdk.nextQueryMessages = [init, makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); assert.deepStrictEqual( @@ -5912,7 +5913,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { assert.strictEqual(sdk.startupCallCount, 0, 'no SDK startup from changeAgent on provisional'); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'code-reviewer', 'agent name resolved from file URI basename'); }); @@ -5929,13 +5930,13 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, undefined, 'no agent on first startup'); // Mid-session agent change: flips dirty, next send rebinds // (SDK has no working runtime hook to swap the agent in place). await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/planner.md' }); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2, 'rebind on agent change'); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, 'planner', 'agent baked into rebuilt Options'); @@ -5956,11 +5957,11 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); await agent.changeAgent!(created.session, undefined); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, undefined, 'cleared agent omitted from rebuilt Options'); diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index 4ea7b392e7bd2..e2b57acf3a9a2 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -136,7 +136,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.strictEqual(out.length, 3); const start = out[0]; assert.ok(start.kind === 'action' && start.action.type === ActionType.ChatResponsePart); - assert.strictEqual(start.session.toString(), SESSION_STR); + assert.strictEqual(start.resource.toString(), SESSION_STR); assert.strictEqual(start.action.turnId, TURN_ID); assert.strictEqual(start.action.part.kind, ResponsePartKind.Markdown); const partId = start.action.part.id; @@ -145,7 +145,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(out.slice(1), [ { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatDelta, turnId: TURN_ID, @@ -155,7 +155,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { }, { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatDelta, turnId: TURN_ID, @@ -195,7 +195,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { ); assert.deepStrictEqual(deltaSignals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatReasoning, turnId: TURN_ID, @@ -223,7 +223,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallStart, turnId: TURN_ID, @@ -258,7 +258,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallStart, turnId: TURN_ID, @@ -290,7 +290,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallDelta, turnId: TURN_ID, @@ -317,7 +317,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallReady, turnId: TURN_ID, @@ -355,7 +355,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallComplete, turnId: TURN_ID, @@ -583,7 +583,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [ { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatUsage, turnId: TURN_ID, diff --git a/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts index 66cbed0b26a82..4c6b8dbfeb859 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts @@ -19,6 +19,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSignal } from '../../common/agentService.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { ClaudeSdkMessageRouter } from '../../node/claude/claudeSdkMessageRouter.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -55,6 +56,7 @@ function createRouter(disposables: Pick): IRouterHarness const router = disposables.add(inst.createInstance( ClaudeSdkMessageRouter, URI.parse('claude:/sess-1'), + URI.parse(buildDefaultChatUri('claude:/sess-1')), dbRef, subagents, undefined, diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index b2bd3a92d73a8..fa9b03d05c4ce 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -20,6 +20,7 @@ import { ServiceCollection } from '../../../instantiation/common/serviceCollecti import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { ClaudeSdkPipeline, IRematerializer } from '../../node/claude/claudeSdkPipeline.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -209,6 +210,7 @@ function createPipeline( ClaudeSdkPipeline, 'sess-1', URI.parse('claude:/sess-1'), + URI.parse(buildDefaultChatUri('claude:/sess-1')), warm, controller, dbRef, @@ -280,6 +282,7 @@ suite('ClaudeSdkPipeline', () => { ClaudeSdkPipeline, 'sess-2', URI.parse('claude:/sess-2'), + URI.parse(buildDefaultChatUri('claude:/sess-2')), warm, controller, dbRef, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 9f0dd2e2387a8..44586ee195a3b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -32,7 +32,7 @@ import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { CustomizationType, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -408,7 +408,7 @@ class TestableCopilotAgent extends CopilotAgent { emitInitialMarkdown: (content: string) => { emitter.fire({ kind: 'action', - session: sessionUri, + resource: sessionUri, action: { type: ActionType.ChatResponsePart, turnId, @@ -1176,7 +1176,7 @@ suite('CopilotAgent', () => { workingDirectory: URI.file('/workspace'), ...(model ? { model } : {}), }); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); return capturedConfig; } finally { await disposeAgent(agent); @@ -1330,6 +1330,32 @@ suite('CopilotAgent', () => { } }); + test('getSessionMetadata preserves legacy customizationDirectory without inferring workingDirectory', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'legacy-customization-directory'); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.customizationDirectory', URI.file('/legacy-workspace').toString()); + db.dispose(); + + const client = new TestCopilotClient([sdkSession('legacy-customization-directory')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + const metadata = await agent.getSessionMetadata(session); + assert.ok(metadata); + assert.deepStrictEqual(withoutUndefinedProperties(metadata), { + session, + startTime: 1000, + modifiedTime: 2000, + summary: 'SDK legacy-customization-directory', + customizationDirectory: URI.file('/legacy-workspace'), + }); + } finally { + await disposeAgent(agent); + } + }); + test('getSessionMetadata only returns sessions with a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const session = AgentSession.uri('copilotcli', 'external'); @@ -1822,6 +1848,30 @@ suite('CopilotAgent', () => { } }); + test('sendMessage on the default chat materializes the parent provisional session', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + let capturedConfig: CopilotCreateSessionOptions | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'prov-default-chat'), + workingDirectory: URI.file('/workspace'), + }); + + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + + assert.strictEqual(capturedConfig?.sessionId, 'prov-default-chat'); + } finally { + await disposeAgent(agent); + } + }); + test('disposeSession on provisional session does not touch SDK or worktree', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); @@ -1923,7 +1973,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); const systemMessage = capturedConfig.systemMessage; @@ -1960,7 +2010,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); assert.strictEqual(capturedConfig?.gitHubToken, 'gh-token-abc', 'createSession should receive the GitHub token at session level so the SDK can resolve a per-session GitHub identity'); @@ -1989,7 +2039,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); } finally { @@ -2665,7 +2715,7 @@ suite('CopilotAgent', () => { signals.push(s); })); - await agent.sendMessage(session, 'hello'); + await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'hello'); assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); const markdownSignals = signals.filter((s): s is IAgentActionSignal => @@ -2683,7 +2733,7 @@ suite('CopilotAgent', () => { // 3. Live path is one-shot: a second sendMessage must not re-emit. signals.length = 0; - await agent.sendMessage(session, 'follow-up'); + await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'follow-up'); const reemittedMarkdown = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -2741,7 +2791,7 @@ suite('CopilotAgent', () => { disposables.add(agent.onDidSessionProgress(s => { signals.push(s); })); - await agent.sendMessage(session, 'hello'); + await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'hello'); const markdownSignals = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -3012,7 +3062,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectory: originalFolder }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); } finally { await disposeAgent(agent); } @@ -3075,7 +3125,7 @@ suite('CopilotAgent', () => { activeClient: { clientId: 'c1', tools: [] }, }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index a95c4e3fa4849..52acb9e9153fd 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -26,7 +26,7 @@ import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedb import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; @@ -397,6 +397,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { CopilotAgentSession, { sessionUri, + chatChannelUri: URI.parse(buildDefaultChatUri(sessionUri)), rawSessionId: 'test-session-1', onDidSessionProgress: progressEmitter, sessionLauncher, @@ -3926,7 +3927,7 @@ suite('CopilotAgentSession', () => { const tools = runtime.createServerSdkTools(); const result = await invokeClientToolHandler(tools[0], 'tc-server-tool', { foo: 'bar' }); - const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); + const sessionUri = buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')); assert.deepStrictEqual(serverToolHost.executions, [{ sessionUri, toolName: tools[0].name, rawArgs: { foo: 'bar' } }]); assert.strictEqual(result.resultType, 'success'); assert.strictEqual(result.textResultForLlm, 'listed 2 comments'); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 5439e7b603f16..0e4cfb2e3cff2 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -14,7 +14,7 @@ import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryReco import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -132,10 +132,8 @@ export class MockAgent implements IAgent { return { items: [] }; } - async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { - // Only record `chat` when defined so existing single-chat assertions - // that compare against `{ session, prompt, attachments }` still match. - const call = chat ? { session, prompt, attachments, chat } : { session, prompt, attachments }; + async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + const call = { session, prompt, attachments, chat }; this.sendMessageCalls.push(call); this._onDidSendMessage.fire(call); if (turnId) { @@ -199,7 +197,7 @@ export class MockAgent implements IAgent { })); this._onDidSessionProgress.fire({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationsChanged, customizations: results.map(result => result.customization), @@ -404,31 +402,32 @@ export class ScriptedMockAgent implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async sendMessage(session: URI, prompt: string, _attachments?: readonly MessageAttachment[], turnId?: string): Promise { + async sendMessage(session: URI, chat: URI, prompt: string, _attachments?: readonly MessageAttachment[], turnId?: string): Promise { if (turnId) { this._activeTurnIds.set(uriKey(session), turnId); + this._activeTurnIds.set(uriKey(chat), turnId); } - const { sessionStr, turnId: tid } = this._ctx(session); + const { sessionStr, turnId: tid } = this._ctx(chat); switch (prompt) { case 'hello': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Hello, world!'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Hello, world!'), + _idle(chat, sessionStr, tid), ]); break; case 'use-tool': this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'), - _toolComplete(session, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }), - _markdown(session, sessionStr, tid, 'Tool done.'), - _idle(session, sessionStr, tid), + ..._toolStart(chat, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'), + _toolComplete(chat, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }), + _markdown(chat, sessionStr, tid, 'Tool done.'), + _idle(chat, sessionStr, tid), ]); break; case 'error': this._fireSequence([ - _error(session, sessionStr, tid, 'test_error', 'Something went wrong'), + _error(chat, sessionStr, tid, 'test_error', 'Something went wrong'), ]); break; @@ -436,17 +435,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start to create the tool, then pending_confirmation to request confirmation (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' })); })(); this._pendingPermissions.set('tc-perm-1', (approved) => { if (approved) { this._fireSequence([ - _markdown(session, sessionStr, tid, 'Allowed.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Allowed.'), + _idle(chat, sessionStr, tid), ]); } }); @@ -457,16 +456,16 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with write permission for a regular file (should be auto-approved) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); // Auto-approved writes resolve immediately — complete the tool and turn await timeout(10); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); })(); break; @@ -476,17 +475,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with write permission for .env (should be blocked) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); } }); @@ -497,16 +496,16 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with shell permission for an allowed command (should be auto-approved) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' })); // Auto-approved shell commands resolve immediately await timeout(10); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }), + _idle(chat, sessionStr, tid), ]); })(); break; @@ -516,17 +515,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with shell permission for a denied command (should require confirmation) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' })); })(); this._pendingPermissions.set('tc-shell-deny-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }), + _idle(chat, sessionStr, tid), ]); } }); @@ -551,31 +550,31 @@ export class ScriptedMockAgent implements IAgent { // never fires, and the session hangs. (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-orphan-initial', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-orphan-initial', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, tid, 'tc-orphan-initial', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true })); + this._onDidSessionProgress.fire(_toolComplete(chat, sessionStr, tid, 'tc-orphan-initial', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true })); await timeout(5); // Complete the turn — the state manager clears the active turn. - this._onDidSessionProgress.fire(_idle(session, sessionStr, tid)); + this._onDidSessionProgress.fire(_idle(chat, sessionStr, tid)); // Hook-triggered continuation: a new tool starts with an // empty turnId and `pending_confirmation` arrives while // there is no active turn. await timeout(10); - for (const s of _toolStart(session, sessionStr, '', 'tc-orphan', 'view', 'Read', 'Read file')) { + for (const s of _toolStart(chat, sessionStr, '', 'tc-orphan', 'view', 'Read', 'Read file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: '/workspace/file.ts' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: '/workspace/file.ts' })); })(); this._pendingPermissions.set('tc-orphan', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-orphan', { pastTenseMessage: 'Read file', content: [{ type: ToolResultContentType.Text, text: 'contents' }], success: true }), - _markdown(session, sessionStr, tid, 'continued-after-hook'), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-orphan', { pastTenseMessage: 'Read file', content: [{ type: ToolResultContentType.Text, text: 'contents' }], success: true }), + _markdown(chat, sessionStr, tid, 'continued-after-hook'), + _idle(chat, sessionStr, tid), ]); } }); @@ -584,47 +583,47 @@ export class ScriptedMockAgent implements IAgent { case 'with-usage': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Usage response.'), - _usage(session, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model', _meta: { cost: 0.5 } }), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Usage response.'), + _usage(chat, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model', _meta: { cost: 0.5 } }), + _idle(chat, sessionStr, tid), ]); break; case 'with-reasoning': { - const initialReasoning = _reasoning(session, sessionStr, tid, 'Let me think'); + const initialReasoning = _reasoning(chat, sessionStr, tid, 'Let me think'); const partId = initialReasoning.action.type === ActionType.ChatResponsePart && hasKey(initialReasoning.action.part, { id: true }) ? initialReasoning.action.part.id : ''; this._fireSequence([ initialReasoning, - _action(session, { + _action(chat, { type: ActionType.ChatReasoning, turnId: tid, partId, content: ' about this...', }), - _markdown(session, sessionStr, tid, 'Reasoned response.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Reasoned response.'), + _idle(chat, sessionStr, tid), ]); break; } case 'with-title': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Title response.'), + _markdown(chat, sessionStr, tid, 'Title response.'), _titleChanged(session, sessionStr, MOCK_AUTO_TITLE), - _idle(session, sessionStr, tid), + _idle(chat, sessionStr, tid), ]); break; case 'slow': { // Slow response for cancel testing — fires delta after a long delay const timer = setTimeout(() => { - const ctx = this._ctx(session); + const ctx = this._ctx(chat); this._fireSequence([ - _markdown(session, ctx.sessionStr, ctx.turnId, 'Slow response.'), - _idle(session, ctx.sessionStr, ctx.turnId), + _markdown(chat, ctx.sessionStr, ctx.turnId, 'Slow response.'), + _idle(chat, ctx.sessionStr, ctx.turnId), ]); }, 5000); this._pendingAborts.set(session.toString(), () => clearTimeout(timer)); @@ -639,7 +638,7 @@ export class ScriptedMockAgent implements IAgent { (async () => { await timeout(10); // Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start - this._onDidSessionProgress.fire(_action(session, { + this._onDidSessionProgress.fire(_action(chat, { type: ActionType.ChatToolCallStart, turnId: tid, toolCallId: 'tc-client-1', @@ -648,14 +647,14 @@ export class ScriptedMockAgent implements IAgent { contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }, })); await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-1', 'Running tests...', { toolInput: '{}' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-client-1', 'Running tests...', { toolInput: '{}' })); })(); // The tool stays pending — the client is responsible for dispatching toolCallComplete. // Once complete, fire a response delta and idle. this._pendingPermissions.set('tc-client-1', () => { this._fireSequence([ - _markdown(session, sessionStr, tid, 'Client tool done.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Client tool done.'), + _idle(chat, sessionStr, tid), ]); }); break; @@ -665,7 +664,7 @@ export class ScriptedMockAgent implements IAgent { // Fires tool_start with toolClientId followed by a permission request. (async () => { await timeout(10); - this._onDidSessionProgress.fire(_action(session, { + this._onDidSessionProgress.fire(_action(chat, { type: ActionType.ChatToolCallStart, turnId: tid, toolCallId: 'tc-client-perm-1', @@ -674,14 +673,14 @@ export class ScriptedMockAgent implements IAgent { contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }, })); await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' })); })(); this._pendingPermissions.set('tc-client-perm-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }), - _markdown(session, sessionStr, tid, 'Permission granted, tool done.'), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }), + _markdown(chat, sessionStr, tid, 'Permission granted, tool done.'), + _idle(chat, sessionStr, tid), ]); } }); @@ -694,14 +693,14 @@ export class ScriptedMockAgent implements IAgent { // child session, then an inner tool runs in the child session // (routed via `parentToolCallId`). this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }), - { kind: 'subagent_started', session, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' }, - ..._toolStart(session, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }), - _toolComplete(session, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'), - { kind: 'subagent_completed', session, toolCallId: 'tc-task-1' }, - _toolComplete(session, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }), - _markdown(session, sessionStr, tid, 'Subagent finished.'), - _idle(session, sessionStr, tid), + ..._toolStart(chat, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }), + { kind: 'subagent_started', chat, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' }, + ..._toolStart(chat, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }), + _toolComplete(chat, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'), + { kind: 'subagent_completed', chat, toolCallId: 'tc-task-1' }, + _toolComplete(chat, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }), + _markdown(chat, sessionStr, tid, 'Subagent finished.'), + _idle(chat, sessionStr, tid), ]); break; } @@ -713,28 +712,28 @@ export class ScriptedMockAgent implements IAgent { // git-driven diff path to pick this up. Format: `terminal-edit:`. const filePath = prompt.slice('terminal-edit:'.length); void (async () => { - for (const s of _toolStart(session, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) { this._onDidSessionProgress.fire(s); } const fs = await import('fs/promises'); await fs.writeFile(filePath, 'edited-from-terminal\n'); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); })().catch(err => { // Surface failures deterministically — an unhandled rejection // would make the test suite flaky. this._fireSequence([ - _markdown(session, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))), + _idle(chat, sessionStr, tid), ]); }); break; } this._fireSequence([ - _markdown(session, sessionStr, tid, 'Unknown prompt: ' + prompt), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Unknown prompt: ' + prompt), + _idle(chat, sessionStr, tid), ]); break; } @@ -744,7 +743,7 @@ export class ScriptedMockAgent implements IAgent { // When steering is set, consume it on the next tick if (steeringMessage) { timeout(20).then(() => { - this._onDidSessionProgress.fire({ kind: 'steering_consumed', session, id: steeringMessage.id }); + this._onDidSessionProgress.fire({ kind: 'steering_consumed', chat: isAhpChatChannel(session.toString()) ? session : URI.parse(buildDefaultChatUri(session)), id: steeringMessage.id }); }); } } @@ -872,7 +871,7 @@ let _mockPartIdCounter = 0; /** Wraps a session action into an {@link IAgentActionSignal}. */ function _action(session: URI, action: import('../../common/state/sessionActions.js').SessionAction | import('../../common/state/sessionActions.js').ChatAction, parentToolCallId?: string): IAgentActionSignal { - return { kind: 'action', session, action, parentToolCallId }; + return { kind: 'action', resource: session, action, parentToolCallId }; } /** Creates a markdown {@link ResponsePartKind.Markdown} response part signal. */ @@ -971,7 +970,7 @@ function _pendingConfirmation(session: URI, toolCallId: string, invocationMessag }): IAgentToolPendingConfirmationSignal { return { kind: 'pending_confirmation', - session, + chat: session, state: { status: ToolCallStatus.PendingConfirmation, toolCallId, diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts index 155c28ae2ab69..20521365a763c 100644 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts @@ -20,6 +20,7 @@ import assert from 'assert'; import { ToolCallContributorKind, ToolResultContentType, type ToolCallContributor } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, getActionEnvelope, IServerHandle, @@ -82,7 +83,7 @@ suite('Protocol WebSocket — Client Tools', function () { // Complete the client tool call client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallComplete', turnId: 'turn-ct', @@ -137,7 +138,7 @@ suite('Protocol WebSocket — Client Tools', function () { // Approve the permission client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-cp', @@ -170,7 +171,7 @@ suite('Protocol WebSocket — Client Tools', function () { // tool_ready that was generated by the event mapper. client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallComplete', turnId: 'turn-ra', diff --git a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts index 01f7c774832a0..a617d25df49be 100644 --- a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts @@ -281,7 +281,7 @@ suite('Protocol WebSocket — Multi-Client', function () { // Client B confirms the tool call client2.notify('dispatchAction', { - channel: sessionUri, + channel: buildDefaultChatUri(sessionUri), clientSeq: 1, action: { type: 'chat/toolCallConfirmed', diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index 4b4dd3a01c7c6..4a3136a81e3db 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -13,6 +13,7 @@ import { MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type import { MOCK_AUTO_TITLE } from '../mockAgent.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, fetchSessionWithChat, getActionEnvelope, @@ -136,7 +137,13 @@ suite('Protocol WebSocket — Session Features', function () { }, }); - await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/titleChanged')) { + return false; + } + const action = getActionEnvelope(n).action as ITitleChangedAction; + return action.title === 'Persisted Title'; + }); // Poll listSessions until the persisted title appears (async DB write) let session: { title: string } | undefined; @@ -159,7 +166,7 @@ suite('Protocol WebSocket — Session Features', function () { const sessionUri = await createAndSubscribeSession(client, 'test-message-model'); client.dispatch({ - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 1, action: { type: ActionType.ChatTurnStarted, @@ -228,7 +235,7 @@ suite('Protocol WebSocket — Session Features', function () { // Queue a message when the session is idle — server should immediately consume it client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 1, action: { type: 'chat/pendingMessageSet', @@ -264,7 +271,7 @@ suite('Protocol WebSocket — Session Features', function () { // Queue a message while the turn is in progress client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/pendingMessageSet', @@ -310,7 +317,7 @@ suite('Protocol WebSocket — Session Features', function () { // Set a steering message while the turn is in progress client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/pendingMessageSet', @@ -359,7 +366,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate: keep only turn-t1 client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 3, action: { type: 'chat/truncated', turnId: 'turn-t1' }, }); @@ -383,7 +390,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate all (no turnId) client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/truncated' }, }); @@ -410,7 +417,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate to turn-tr1 client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 3, action: { type: 'chat/truncated', turnId: 'turn-tr1' }, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index 36f7e81f837b0..863ed192efa7a 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { SubscribeResult, type DispatchActionParams } from '../../../common/state/protocol/commands.js'; import { ActionType, type ActionEnvelope } from '../../../common/state/sessionActions.js'; import type { SessionAddedParams } from '../../../common/state/protocol/notifications.js'; -import { MessageKind, buildDefaultChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type SessionState } from '../../../common/state/sessionState.js'; +import { MessageKind, buildDefaultChatUri, mergeSessionWithDefaultChat, parseDefaultChatUri, type ChatState, type ISessionWithDefaultChat, type SessionState } from '../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { AgentHostCodexAgentEnabledEnvVar } from '../../../common/agentService.js'; import { @@ -34,7 +34,7 @@ export class TestProtocolClient { private _nextId = 1; private readonly _pendingCalls = new Map(); private readonly _notifications: AhpNotification[] = []; - private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void }[] = []; + private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void; dispose: () => void }[] = []; constructor(port: number) { this._ws = new WebSocket(`ws://127.0.0.1:${port}`); @@ -68,13 +68,8 @@ export class TestProtocolClient { } } else if (isJsonRpcNotification(msg)) { const notif = msg; - for (let i = this._notifWaiters.length - 1; i >= 0; i--) { - if (this._notifWaiters[i].predicate(notif)) { - const waiter = this._notifWaiters.splice(i, 1)[0]; - waiter.resolve(notif); - } - } this._notifications.push(notif); + this._flushNotificationWaiters(); } } @@ -121,22 +116,44 @@ export class TestProtocolClient { } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); - if (idx >= 0) { - this._notifWaiters.splice(idx, 1); - } - reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); - }, timeoutMs); - - this._notifWaiters.push({ + const waiter = { predicate, - resolve: n => { clearTimeout(timer); resolve(n); }, + resolve, reject, - }); + dispose: () => clearTimeout(timer), + }; + const timer = setTimeout(() => { + this._removeNotificationWaiter(waiter); + const received = this._notifications.map(n => { + const action = n.method === 'action' ? (n.params as ActionEnvelope).action.type : undefined; + return action ? `${n.method}:${action}` : n.method; + }).join(', '); + reject(new Error(`Timeout waiting for notification (${timeoutMs}ms). Received: ${received}`)); + }, timeoutMs); + this._notifWaiters.push(waiter); + this._flushNotificationWaiters(); }); } + private _flushNotificationWaiters(): void { + for (let i = this._notifWaiters.length - 1; i >= 0; i--) { + const waiter = this._notifWaiters[i]; + const match = this._notifications.find(waiter.predicate); + if (match) { + this._notifWaiters.splice(i, 1); + waiter.dispose(); + waiter.resolve(match); + } + } + } + + private _removeNotificationWaiter(waiter: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void; dispose: () => void }): void { + const idx = this._notifWaiters.indexOf(waiter); + if (idx >= 0) { + this._notifWaiters.splice(idx, 1); + } + } + /** Return all received notifications matching a predicate. */ receivedNotifications(predicate?: (n: AhpNotification) => boolean): AhpNotification[] { return predicate ? this._notifications.filter(predicate) : [...this._notifications]; @@ -169,6 +186,7 @@ export class TestProtocolClient { close(): void { for (const w of this._notifWaiters) { + w.dispose(); w.reject(new Error('Client closed')); } this._notifWaiters.length = 0; @@ -300,6 +318,10 @@ export function nextSessionUri(): string { return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); } +export function defaultChatChannel(sessionUri: string): string { + return buildDefaultChatUri(sessionUri); +} + export function isActionNotification(n: AhpNotification, actionType: string): boolean { if (n.method !== 'action') { return false; @@ -336,7 +358,7 @@ export async function createAndSubscribeSession(c: TestProtocolClient, clientId: export function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { c.dispatch({ - channel: session, + channel: defaultChatChannel(session), clientSeq, action: { type: ActionType.ChatTurnStarted, @@ -354,8 +376,10 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn * requires merging the session snapshot with its default chat snapshot. */ export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string): Promise { - const sessionSnap = await c.call('subscribe', { channel: sessionUri }); - const chatSnap = await c.call('subscribe', { channel: buildDefaultChatUri(sessionUri) }); + const owningSession = parseDefaultChatUri(sessionUri) ?? sessionUri; + const chatUri = parseDefaultChatUri(sessionUri) ? sessionUri : buildDefaultChatUri(sessionUri); + const sessionSnap = await c.call('subscribe', { channel: owningSession }); + const chatSnap = await c.call('subscribe', { channel: chatUri }); return mergeSessionWithDefaultChat( sessionSnap.snapshot!.state as SessionState, chatSnap.snapshot?.state as ChatState | undefined, diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index c271f10757ef4..d239e142775d0 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -8,6 +8,7 @@ import type { IResponsePartAction } from '../../../common/state/sessionActions.j import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, getActionEnvelope, IServerHandle, @@ -55,7 +56,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm the tool call client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-perm', @@ -116,7 +117,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm it manually to let the turn complete client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-deny', @@ -173,7 +174,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm it manually to let the turn complete client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-shell-deny', diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index bdbe1561609af..71a9280401b70 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -7,9 +7,10 @@ import assert from 'assert'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import type { FetchTurnsResult, ListSessionsResult } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, ROOT_STATE_URI, buildSubagentSessionUri, isSubagentSession, type MarkdownResponsePart, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ResponsePartKind, ROOT_STATE_URI, buildSubagentChatUri, isSubagentSession, type MarkdownResponsePart, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, fetchSessionWithChat, getActionEnvelope, @@ -94,7 +95,7 @@ suite('Protocol WebSocket — Turn Execution', function () { dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/turnCancelled', turnId: 'turn-cancel' }, }); @@ -193,7 +194,7 @@ suite('Protocol WebSocket — Turn Execution', function () { // Subscribe to the child subagent session — its URI is derived from // the parent session URI + parent toolCallId. - const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); + const childUri = buildSubagentChatUri(sessionUri, 'tc-task-1'); const parentState = await fetchSessionWithChat(client, sessionUri); const childState = await fetchSessionWithChat(client, childUri); @@ -223,7 +224,7 @@ suite('Protocol WebSocket — Turn Execution', function () { await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete')); // Sanity: the subagent child session is live (subscribing succeeds). - const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); + const childUri = buildSubagentChatUri(sessionUri, 'tc-task-1'); const childSnapshot = await client.call('subscribe', { channel: childUri }); assert.ok(childSnapshot.snapshot, 'subagent child session should be live'); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 1c60959a878e9..7687a04c0c62b 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -238,6 +238,7 @@ suite('ProtocolServerHandler', () => { let logService: CountingLogService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); function makeSessionSummary(resource?: string): SessionSummary { return { @@ -439,11 +440,11 @@ suite('ProtocolServerHandler', () => { // Chat actions are emitted on the derived default-chat channel, so the // client must subscribe to it (as the real UI bridge does) to see echoes. - const transport = connectClient('client-1', [sessionUri, buildDefaultChatUri(sessionUri)]); + const transport = connectClient('client-1', [sessionUri, defaultChatUri]); transport.sent.length = 0; transport.simulateMessage(notification('dispatchAction', { - channel: sessionUri, + channel: defaultChatUri, clientSeq: 1, action: { type: ActionType.ChatTurnStarted, @@ -969,12 +970,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -982,7 +983,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1031,12 +1032,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1079,12 +1080,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1121,12 +1122,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1170,12 +1171,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1183,7 +1184,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1228,12 +1229,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1241,7 +1242,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1280,12 +1281,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1293,7 +1294,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1332,7 +1333,8 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + const chatUri = buildDefaultChatUri(sessionUri); + stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, @@ -1340,7 +1342,7 @@ suite('ProtocolServerHandler', () => { // Tool call stamped for a clientId that never connected (e.g. a // stale stamp from a long-dead window). No disconnect event ever // fires for it; the issuance-time orphan check must arm the timeout. - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1371,12 +1373,12 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1400,13 +1402,13 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); // First orphaned tool call (owner never connected) arms the grace timer. - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1421,7 +1423,7 @@ suite('ProtocolServerHandler', () => { // deadline — otherwise the first call could be kept alive // indefinitely. await new Promise(r => setTimeout(r, 20_000)); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-2', @@ -1451,12 +1453,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1464,7 +1466,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1505,12 +1507,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1518,7 +1520,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1569,12 +1571,12 @@ suite('ProtocolServerHandler', () => { tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 5fe32bed2bd6d..fdefc8c0babf0 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -398,6 +398,33 @@ tool-call confirmations, input requests) are threaded through the resolved chat URI so peer chats run concurrently without cross-talk. `_resolveSessionUri` ignores the fragment to find the parent session; `_resolveChatUri` returns the fragment's chat URI (or the default chat URI when there is no fragment). +Agent backends must emit chat progress signals against the chat channel that owns +the turn/tool call. `AgentSideEffects` treats that channel as authoritative; if a +permission request from an additional chat arrives on the parent session URI, that +is a producer bug because the peer-chat UI will not receive the AHP update. When +an `ahp-chat` channel is malformed, handlers throw instead of falling back to the +parent session URI so routing bugs are not hidden. +Tool-call confirmation bookkeeping (`_toolCallAgents`) is keyed by the same chat +channel that received `ChatToolCallStart`/`ChatToolCallReady`; confirmations sent +to the parent session URI are invalid and will not resolve the SDK permission +request. + +Subagents are modelled as additional chats on the parent session, not as separate +sessions. When a `subagent_started` signal arrives, the host adds a subagent chat +to the parent session and dispatches the subagent turn on that chat URI; restoring +a standalone subagent session would create only session state and leave chat +actions with no `_chatStates` entry. Subagent chat URIs use the stable +`ahp-chat://subagent/...` authority and store the case-sensitive tool call id in +the path (`buildSubagentChatUri`), because URI authorities are case-insensitive. +Subagent chats are created with `origin.kind === "tool"` and are hidden from the +chat tab strip; the parent tool invocation is their visible UI entry point. + +On the workbench side, `AgentHostSessionHandler` stores the upstream chat channel +in `_chatURIsBySessionResource` after hydrating the session state. For default +chats this URI comes from `SessionState.defaultChat`; for peer chats it is matched +from `SessionState.chats` by the resource fragment. The handler must not +reconstruct the default URI with `buildDefaultChatUri` before dispatching turns, +because providers are free to choose a different default-chat URI shape. #### Renaming: session vs chat are independent diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 9bbf8402686a1..4e48d90b67d51 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -23,7 +23,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -189,23 +189,24 @@ export class ChatCompositeBar extends Disposable { const mainChat = session.mainChat.read(reader); const activeChatUri = session.activeChat.read(reader)?.resource.toString() ?? ''; const mainChatUri = mainChat.resource.toString(); + const visibleOpenChats = openChats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool); // Keep the provider's order, but move untitled (in-composer) chats // to the end so a just-completed background chat never jumps last. // Partition so each chat's status is read exactly once (tracked) and // relative order is preserved by construction. const committedOpen: IChat[] = []; const untitledOpen: IChat[] = []; - for (const chat of openChats) { + for (const chat of visibleOpenChats) { (chat.status.read(reader) === SessionStatus.Untitled ? untitledOpen : committedOpen).push(chat); } - const orderedChats = untitledOpen.length === 0 ? openChats : [...committedOpen, ...untitledOpen]; + const orderedChats = untitledOpen.length === 0 ? visibleOpenChats : [...committedOpen, ...untitledOpen]; this._rebuildTabs(orderedChats, activeChatUri, mainChatUri); // Archived sessions are read-only, so disable the trailing New Chat // action (mirrors the header action's SessionIsArchivedContext gating). this._newChatAction.enabled = !session.isArchived.read(reader); - this._setVisible(session.isCreated.read(reader) && openChats.length > 1); + this._setVisible(session.isCreated.read(reader) && visibleOpenChats.length > 1); })); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 1463f14953b8e..e52d320bbc753 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -45,7 +45,7 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -197,6 +197,7 @@ class AdditionalChat extends Disposable { isRead: constObservable(true), description: this._description, lastTurnEnd: this._lastTurnEnd, + origin: summary.origin ? { kind: toSessionChatOriginKind(summary.origin.kind) } : undefined, }; } @@ -240,6 +241,17 @@ class AdditionalChat extends Disposable { * sessions UI. A single concrete class for both local and remote agent * hosts — variation flows through {@link IAgentHostAdapterOptions}. */ +export function toSessionChatOriginKind(kind: string): ChatOriginKind { + switch (kind) { + case ChatOriginKind.Tool: + return ChatOriginKind.Tool; + case ChatOriginKind.Fork: + return ChatOriginKind.Fork; + default: + return ChatOriginKind.User; + } +} + export class AgentHostSessionAdapter extends Disposable implements ISession { readonly sessionId: string; diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index c5cedd29cf8e4..67381be730633 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -269,6 +269,16 @@ export interface IChatCheckpoints { readonly lastCheckpointRef: string; } +export const enum ChatOriginKind { + Tool = 'tool', + User = 'user', + Fork = 'fork', +} + +export interface IChatOrigin { + readonly kind: ChatOriginKind; +} + /** * A single chat within a session, produced by the sessions management layer. */ @@ -302,6 +312,8 @@ export interface IChat { readonly description: IObservable; /** Timestamp of when the last agent turn ended, if any. */ readonly lastTurnEnd: IObservable; + /** How the chat came into existence, if provided by the backend. */ + readonly origin?: IChatOrigin; } /** diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 34fea7dcbae6d..23736a0a95e64 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -23,7 +23,7 @@ import { SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, } from '../../../common/contextkeys.js'; -import { ISession, SessionStatus } from './session.js'; +import { ChatOriginKind, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; /** @@ -145,10 +145,10 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, // real chat. Counts the whole chat list (open or closed) so a committed chat // that was closed still keeps the menu available to reopen it. const committedChatCount = session?.chats.read(reader) - .reduce((count, chat) => chat.status.read(reader) === SessionStatus.Untitled ? count : count + 1, 0) ?? 0; + .reduce((count, chat) => chat.status.read(reader) === SessionStatus.Untitled || chat.origin?.kind === ChatOriginKind.Tool ? count : count + 1, 0) ?? 0; keys.hasMultipleCommittedChats.set(committedChatCount > 1); // More than one open chat (incl. drafts) means the tab strip is shown; the // header then hides its own New Chat button. - keys.hasMultipleOpenChats.set((session?.openChats.read(reader).length ?? 0) > 1); + keys.hasMultipleOpenChats.set((session?.openChats.read(reader).filter(chat => chat.origin?.kind !== ChatOriginKind.Tool).length ?? 0) > 1); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ec401cf33f7fc..35742addc6396 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -32,7 +32,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp import { ConfirmationOptionKind, JsonPrimitive, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, buildChatUri, buildDefaultChatUri, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -108,10 +108,10 @@ interface IObserveTurnOptions { * The chat channel URI (as a string) this turn's conversation actions * (turn lifecycle, tool calls, input answers) dispatch to. For a session's * default chat this is the default chat URI; for an additional peer chat it - * is that chat's URI. Resolved from {@link sessionResource} via - * {@link AgentHostSessionHandler._resolveChatUri}. + * is that chat's URI. Resolved from the upstream session/chat state and + * stored in {@link AgentHostSessionHandler._chatURIsBySessionResource}. */ - readonly chatChannel: string; + readonly chatURI: string; readonly turnId: string; readonly sink: (parts: IChatProgress[]) => void; readonly cancellationToken: CancellationToken; @@ -565,6 +565,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private static readonly DRAFT_SYNC_DEBOUNCE_MS = 500; private readonly _activeSessions = new ResourceMap(); + private readonly _chatURIsBySessionResource = new ResourceMap(); /** Per-session subscription to chat model pending request changes. */ private readonly _pendingMessageSubscriptions = this._register(new DisposableResourceMap()); /** Per-session debounced sync from chat input state to AHP draft state. */ @@ -593,7 +594,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Active subscriptions for additional (non-default) peer chats, keyed by * the chat channel URI string. Populated when a chat widget is opened for - * a resource that carries a chatId fragment (see {@link _resolveChatUri}). + * a resource that carries a chatId fragment. */ private readonly _additionalChatSubscriptions = new Map>>(); @@ -802,8 +803,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // arrives so the user-selected model is available. The chat resource still // carries the raw session id that will be used when createSession runs. const resolvedSession = this._resolveSessionUri(sessionResource); - const chatChannel = this._resolveChatUri(sessionResource); - const chatKey = chatChannel.toString(); + let chatURI: string | undefined; // The point of this is to check with the session provider or controller // whether this session resource represents a new session that hasn't yet @@ -823,23 +823,27 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!isNewSession) { try { const sub = this._ensureSessionSubscription(resolvedSession.toString()); - const chatSub = this._ensureChatSubscription(resolvedSession.toString(), chatKey); // Wait for both the session summary and its default-chat // conversation state to hydrate from the server. After the // multi-chat protocol adoption, turns/activeTurn live on the // separate chat channel, so reading them before the chat // subscription lands would yield an empty history. - await Promise.all([ - this._whenSubscriptionHydrated(sub, token), - this._whenSubscriptionHydrated(chatSub, token), - ]); - const sessionState = this._getSessionState(resolvedSession.toString(), chatKey); + await this._whenSubscriptionHydrated(sub, token); + const rawState = this._getRawSessionState(resolvedSession.toString()); + if (!rawState) { + throw new Error(`Session state did not hydrate for ${resolvedSession.toString()}`); + } + chatURI = this._resolveChatUriFromState(sessionResource, rawState); + this._setChatURI(sessionResource, chatURI); + const chatSub = this._ensureChatSubscription(resolvedSession.toString(), chatURI); + await this._whenSubscriptionHydrated(chatSub, token); + const sessionState = this._getSessionState(resolvedSession.toString(), chatURI); if (sessionState) { sessionTitle = sessionState.title; const draft = sessionState.draft ?? emptyDraftFromLastTurn(sessionState); draftInputState = this._draftToInputState(sessionResource, draft); if (!sessionState.draft && draft) { - this._config.connection.dispatch(chatKey, { type: ActionType.ChatDraftChanged, draft }); + this._config.connection.dispatch(chatURI, { type: ActionType.ChatDraftChanged, draft }); } const fallbackRawModelId = lastTurnModelSelection(sessionState)?.id; const lookup = this._createTurnModelLookup(sessionResource, fallbackRawModelId); @@ -935,17 +939,25 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._draftSyncSubscriptions.deleteAndDispose(sessionResource); this._serverTurnWatchers.deleteAndDispose(sessionResource); this._pendingHistoryTurns.delete(sessionResource); - this._releaseChatSessionSubscriptions(resolvedSession.toString(), chatKey); + const chatURI = this._chatURIsBySessionResource.get(sessionResource); + this._chatURIsBySessionResource.delete(sessionResource); + if (chatURI) { + this._releaseChatSessionSubscriptions(resolvedSession.toString(), chatURI); + } }, () => { const sessionKey = resolvedSession.toString(); - const turnId = this._getSessionState(sessionKey, chatKey)?.activeTurn?.id; + const chatURI = this._chatURIsBySessionResource.get(sessionResource); + if (!chatURI) { + return true; + } + const turnId = this._getSessionState(sessionKey, chatURI)?.activeTurn?.id; if (!turnId) { // No active turn (likely a race with completion). Noop-success. return true; } this._logService.info(`[AgentHost] Cancellation requested for ${sessionKey}, dispatching turnCancelled`); - this._config.connection.dispatch(this._resolveTurnDispatchChannel(sessionResource), { + this._config.connection.dispatch(chatURI, { type: ActionType.ChatTurnCancelled, turnId, }); @@ -956,7 +968,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!isNewSession) { this._ensurePendingMessageSubscription(sessionResource, resolvedSession); - this._ensureDraftSyncSubscription(sessionResource, resolvedSession, chatKey); + if (chatURI !== undefined) { + this._ensureDraftSyncSubscription(sessionResource, resolvedSession, chatURI); + } // Eagerly create the snapshot controller once the ChatModel for // this session is available so that "Restore Checkpoint" works @@ -987,7 +1001,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // For existing sessions, start watching for server-initiated turns // immediately. For new sessions, this is deferred to _createAndSubscribe. - this._watchForServerInitiatedTurns(resolvedSession, sessionResource); + if (chatURI !== undefined) { + this._watchForServerInitiatedTurns(resolvedSession, sessionResource); + } } return session; @@ -1073,6 +1089,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // session, then wire up the per-turn machinery that // `_createAndSubscribe` would normally set up. this._ensureSessionSubscription(sessionKey); + this._setChatURI(request.sessionResource, this._resolveChatUriFromState(request.sessionResource, existingState)); this._ensurePendingMessageSubscription(request.sessionResource, resolvedSession); this._watchForServerInitiatedTurns(resolvedSession, request.sessionResource); @@ -1193,9 +1210,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } const session = backendSession.toString(); - const chatKey = this._resolveChatUri(sessionResource).toString(); + const chatURI = this._getChatURI(sessionResource); const pending = chatModel.getPendingRequests(); - const protocolState = this._getSessionState(session, chatKey); + const protocolState = this._getSessionState(session, chatURI); const prevSteering = protocolState?.steeringMessage; const prevQueued = protocolState?.queuedMessages ?? []; @@ -1223,14 +1240,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC kind: PendingMessageKind.Steering, id: currentSteering.id, message: userOriginMessage(currentSteering.text, currentSteering.attachments), - }, chatKey); + }, chatURI); } } else if (prevSteering) { this._dispatchAction(backendSession, { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: prevSteering.id, - }, chatKey); + }, chatURI); } // --- Queued: removals --- @@ -1241,7 +1258,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Queued, id: prev.id, - }, chatKey); + }, chatURI); } } @@ -1255,14 +1272,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC kind: PendingMessageKind.Queued, id: q.id, message: userOriginMessage(q.text, q.attachments), - }, chatKey); + }, chatURI); } } // --- Queued: reordering --- // After additions/removals, check if the remaining common items changed order. // Re-read protocol state since dispatches above may have mutated it. - const updatedProtocol = this._getSessionState(session, chatKey); + const updatedProtocol = this._getSessionState(session, chatURI); const updatedQueued = updatedProtocol?.queuedMessages ?? []; if (updatedQueued.length > 1 && currentQueued.length === updatedQueued.length) { const needsReorder = currentQueued.some((q, i) => q.id !== updatedQueued[i].id); @@ -1270,40 +1287,49 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._dispatchAction(backendSession, { type: ActionType.ChatQueuedMessagesReordered, order: currentQueued.map(q => q.id), - }, chatKey); + }, chatURI); } } } - private _dispatchAction(channel: URI, action: ClientSessionAction | ClientChatAction, chatChannel?: string): void { - // Conversation actions (turns, tool calls, pending/queued messages, - // input) target a chat channel; session-scoped actions stay on the - // session channel. When an explicit chat channel is provided (e.g. for - // an additional peer chat) it is used; otherwise we fall back to the - // session's default chat. + private _dispatchAction(channel: URI, action: ClientSessionAction | ClientChatAction, chatURI?: string): void { const target = isChatAction(action) - ? (chatChannel ?? this._resolveDefaultChatUri(channel.toString())) + ? this._requireChatURI(chatURI, action.type) : channel.toString(); this._config.connection.dispatch(target, action); } - /** - * Resolve the channel URI of a session's default chat. - * - * MIGRATION: VS Code currently models every session as having a single - * "default chat". The authoritative URI for that chat is published by the - * backend via {@link SessionState.defaultChat}, so we read it from the live - * session state whenever it is available rather than assuming a URI - * structure. Before the session state has hydrated (e.g. immediately after - * subscribing, when no snapshot has arrived yet) we fall back to - * deterministically deriving the URI from the session URI via - * {@link buildDefaultChatUri}. This fallback — and the single-default-chat - * assumption it encodes — is temporary scaffolding and should be removed - * once multi-chat sessions are modelled directly. - */ - private _resolveDefaultChatUri(sessionUri: string): string { - const defaultChat = this._getSessionState(sessionUri)?.defaultChat; - return defaultChat ?? buildDefaultChatUri(sessionUri); + private _requireChatURI(chatURI: string | undefined, actionType: string): string { + if (!chatURI) { + throw new Error(`Cannot dispatch ${actionType} without a resolved AHP chat channel`); + } + return chatURI; + } + + private _resolveChatUriFromState(sessionResource: URI, state: SessionState): string { + if (sessionResource.fragment) { + const match = state.chats.find(summary => parseChatUri(summary.resource)?.chatId === sessionResource.fragment); + if (!match) { + throw new Error(`Cannot resolve chat '${sessionResource.fragment}' from session state for ${sessionResource.toString()}`); + } + return match.resource.toString(); + } + if (!state.defaultChat) { + throw new Error(`Session ${sessionResource.toString()} has no default chat`); + } + return state.defaultChat.toString(); + } + + private _setChatURI(sessionResource: URI, chatURI: string): void { + this._chatURIsBySessionResource.set(sessionResource, chatURI); + } + + private _getChatURI(sessionResource: URI): string { + const chatURI = this._chatURIsBySessionResource.get(sessionResource); + if (!chatURI) { + throw new Error(`No AHP chat URI mapped for ${sessionResource.toString()}`); + } + return chatURI; } private _getCurrentActiveClient(): SessionActiveClient { @@ -1348,11 +1374,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ private _watchForServerInitiatedTurns(backendSession: URI, sessionResource: URI): void { const sessionStr = backendSession.toString(); - const chatKey = this._resolveChatUri(sessionResource).toString(); + const chatURI = this._getChatURI(sessionResource); // Seed from the current state so we don't treat any pre-existing active // turn (e.g. one being handled by _reconnectToActiveTurn) as new. - const currentState = this._getSessionState(sessionStr, chatKey); + const currentState = this._getSessionState(sessionStr, chatURI); let lastSeenTurnId: string | undefined = currentState?.activeTurn?.id; let previousQueuedIds: Set | undefined; let previousSteeringId: string | undefined = currentState?.steeringMessage?.id; @@ -1365,12 +1391,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC disposables.add(turnProgressDisposable); const sessionSub = this._ensureSessionSubscription(sessionStr); - const chatSub = this._ensureChatSubscription(sessionStr, chatKey); + const chatSub = this._ensureChatSubscription(sessionStr, chatURI); // Conversation contents now live on the default chat, while title and // other session-scoped fields stay on the session. Re-evaluate on a // change to either channel, reading the merged view. const onChange = () => { - const state = this._getSessionState(sessionStr, chatKey); + const state = this._getSessionState(sessionStr, chatURI); if (!state) { return; } @@ -1460,7 +1486,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnDisposables.add(this._observeTurn({ backendSession, sessionResource: chatSession.sessionResource, - chatChannel: this._resolveChatUri(chatSession.sessionResource).toString(), + chatURI: this._getChatURI(chatSession.sessionResource), turnId, sink: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, @@ -1482,8 +1508,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnId = request.requestId; this._clientDispatchedTurnIds.add(turnId); - const chatKey = this._resolveChatUri(request.sessionResource).toString(); - const turnChannel = this._resolveTurnDispatchChannel(request.sessionResource); + const chatURI = this._getChatURI(request.sessionResource); + const turnChannel = chatURI; const messageAttachments = await this._convertVariablesToAttachments(request); if (cancellationToken.isCancellationRequested) { return; @@ -1506,7 +1532,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // turns, a checkpoint was restored or a message was edited. Dispatch // session/truncated so the server drops the stale tail. const chatModel = this._chatService.getSession(request.sessionResource); - const protocolState = this._getSessionState(session.toString(), chatKey); + const protocolState = this._getSessionState(session.toString(), chatURI); if (chatModel && protocolState?.turns.length) { // -2 since -1 will already be the current request const previousRequestIndex = chatModel.getRequests().findIndex(i => i.id === request.requestId) - 1; @@ -1566,7 +1592,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC store.add(this._observeTurn({ backendSession: session, sessionResource: request.sessionResource, - chatChannel: chatKey, + chatURI, turnId, sink: progress, cancellationToken, @@ -1600,7 +1626,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId: string, cancellationToken: CancellationToken, protocolOptions?: ConfirmationOption[], - chatChannel?: string, + chatURI?: string, ): void { IChatToolInvocation.awaitConfirmation(invocation, cancellationToken).then(reason => { // When the user picked a custom button, resolve the matching @@ -1616,8 +1642,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC : reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; this._logService.info(`[AgentHost] Tool confirmation: toolCallId=${toolCallId}, approved=${approved}, selectedOptionId=${selectedOption?.id}`); + const target = this._requireChatURI(chatURI, ActionType.ChatToolCallConfirmed); if (approved) { - this._config.connection.dispatch(chatChannel ?? session.toString(), { + this._config.connection.dispatch(target, { type: ActionType.ChatToolCallConfirmed, turnId, toolCallId, @@ -1626,7 +1653,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ...(selectedOption ? { selectedOptionId: selectedOption.id } : {}), }); } else { - this._config.connection.dispatch(chatChannel ?? session.toString(), { + this._config.connection.dispatch(target, { type: ActionType.ChatToolCallConfirmed, turnId, toolCallId, @@ -1666,8 +1693,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // history hydration code) share the same instance and would lose // their state if we tore it down. const sub = this._ensureSessionSubscription(sessionKey); - const chatKey = opts.chatChannel; - const chatSub = this._ensureChatSubscription(sessionKey, chatKey); + const chatURI = opts.chatURI; + const chatSub = this._ensureChatSubscription(sessionKey, chatURI); const sessionState$ = observableFromSubscription(this, sub); const chatState$ = observableFromSubscription(this, chatSub); @@ -2016,9 +2043,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (subagentContext.observedToolIds.has(toolCallId)) { return; } - const isSub = isSubagentTool(tc) - || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)); - if (!isSub) { + const subagentContent = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) ? getToolSubagentContent(tc) : undefined; + if (!subagentContent && !isSubagentTool(tc)) { + return; + } + if (!subagentContent) { return; } subagentContext.observedToolIds.add(toolCallId); @@ -2058,7 +2087,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // `WaitingForConfirmation`. Without this explicit call, no listener // would observe the user's confirmation answer. if (initial.status === ToolCallStatus.PendingConfirmation && !IChatToolInvocation.isComplete(invocation)) { - this._awaitToolConfirmation(invocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, initial.options, opts.chatChannel); + this._awaitToolConfirmation(invocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, initial.options, opts.chatURI); } tryObserveSubagent(initial); @@ -2086,7 +2115,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const confirmInvocation = toolCallStateToInvocation(tc, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority); opts.sink([confirmInvocation]); invocation = confirmInvocation; - this._awaitToolConfirmation(confirmInvocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, tc.options, opts.chatChannel); + this._awaitToolConfirmation(confirmInvocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, tc.options, opts.chatURI); } else if (status === ToolCallStatus.Running || status === ToolCallStatus.PendingResultConfirmation) { invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession); @@ -2153,7 +2182,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC pastTenseMessage: `Tool "${toolName}" is not available`, error: { message: `Tool "${toolName}" is not available on this client` }, }, - }, opts.chatChannel); + }, opts.chatURI); return; } @@ -2175,7 +2204,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC pastTenseMessage: `Failed to start ${toolName}`, error: { message: `Could not create invocation for client tool "${toolName}"` }, }, - }, opts.chatChannel); + }, opts.chatURI); return; } @@ -2211,7 +2240,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC toolCallId, approved: true, confirmed: confirmedReasonToProtocol(state.confirmed), - }, opts.chatChannel); + }, opts.chatURI); } else if (state.type === IChatToolInvocation.StateKind.Cancelled) { // Pre-execution cancellation. If the server already knows // (cts cancelled), suppress the dispatch — the server @@ -2226,7 +2255,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC toolCallId, approved: false, reason: ToolCallCancellationReason.Denied, - }, opts.chatChannel); + }, opts.chatURI); } })); @@ -2252,7 +2281,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId: opts.turnId, toolCallId, result: toolResultToProtocol(result ?? { content: [] }, toolName), - }, opts.chatChannel); + }, opts.chatURI); }; // React to part$ updates: route external cancellation, and try to @@ -2310,7 +2339,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC pastTenseMessage: `Failed to execute ${toolName}`, error: { message: `Invalid tool input for "${toolName}": expected JSON object parameters` }, }, - }, opts.chatChannel); + }, opts.chatURI); return; } @@ -2430,7 +2459,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // carousel's `data` so it reflects the server's authoritative answer // even if the user already locally submitted (mirrors legacy // `_applyCompletedInputRequest` behavior). - const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatChannel); + const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatURI); store.add(sub.onWillApplyAction(envelope => { const action = envelope.action as ChatAction; if (action.type !== ActionType.ChatInputCompleted || action.requestId !== inputReq.id) { @@ -2454,14 +2483,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } if (!result.answers) { - this._config.connection.dispatch(opts.chatChannel, { + this._config.connection.dispatch(opts.chatURI, { type: ActionType.ChatInputCompleted, requestId: inputReq.id, response: ChatInputResponseKind.Cancel, }); } else { const answers = convertCarouselAnswers(result.answers); - this._config.connection.dispatch(opts.chatChannel, { + this._config.connection.dispatch(opts.chatURI, { type: ActionType.ChatInputCompleted, requestId: inputReq.id, response: ChatInputResponseKind.Accept, @@ -2531,7 +2560,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._chatWidgetService.getWidgetBySessionResource(opts.sessionResource)?.input.clearPlanReview(undefined, inputReq.id); }; - const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatChannel); + const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatURI); store.add(sub.onWillApplyAction(envelope => { const action = envelope.action as ChatAction; if (action.type !== ActionType.ChatInputCompleted || action.requestId !== inputReq.id) { @@ -2554,7 +2583,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const completion = result ? convertPlanReviewResult(planReview, result) : { response: ChatInputResponseKind.Cancel }; - this._config.connection.dispatch(opts.chatChannel, { + this._config.connection.dispatch(opts.chatURI, { type: ActionType.ChatInputCompleted, requestId: inputReq.id, ...completion, @@ -2604,7 +2633,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } settled = true; - this._config.connection.dispatch(opts.chatChannel, { + this._config.connection.dispatch(opts.chatURI, { type: ActionType.ChatInputCompleted, requestId: inputReq.id, response, @@ -2657,7 +2686,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Server-side completion (e.g. another client answered or the // agent observed completion). Mark settled so disposal doesn't // re-dispatch a Cancel, and hide the part from the UI. - const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatChannel); + const sub = this._ensureChatSubscription(opts.backendSession.toString(), opts.chatURI); store.add(sub.onWillApplyAction(envelope => { const action = envelope.action as ChatAction; if (action.type === ActionType.ChatInputCompleted && action.requestId === inputReq.id) { @@ -2774,28 +2803,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const childStateByUri = new Map>(); - const getChildState = (childSessionUri: string): Promise => { - let existing = childStateByUri.get(childSessionUri); + const getChildState = (childChatUri: string): Promise => { + let existing = childStateByUri.get(childChatUri); if (!existing) { - existing = this._loadSubagentState(childSessionUri); - childStateByUri.set(childSessionUri, existing); + existing = this._loadSubagentState(parentSessionStr, childChatUri); + childStateByUri.set(childChatUri, existing); } return existing; }; const enrichedInsertions = await Promise.all(subagentInsertions.map(async ({ item, index, toolCallId }) => { - const childSessionUri = buildSubagentSessionUri(parentSessionStr, toolCallId); + const childChatUri = buildSubagentChatUri(parentSessionStr, toolCallId); try { - const childState = await getChildState(childSessionUri); + const childState = await getChildState(childChatUri); if (childState) { // Surface this subagent's accumulated cost (AIC) and model on // its tool's hover after a reload by writing them onto the // serialized subagent tool call. this._applySubagentUsageToHistoryPart(item.parts[index], sessionResource, childState); } - return { item, index, innerParts: childState ? this._getSubagentInnerParts(childSessionUri, toolCallId, childState) : [] }; + return { item, index, innerParts: childState ? this._getSubagentInnerParts(childChatUri, toolCallId, childState) : [] }; } catch (err) { - this._logService.warn(`[AgentHost] Failed to enrich history with subagent calls: ${childSessionUri}`, err); + this._logService.warn(`[AgentHost] Failed to enrich history with subagent calls: ${childChatUri}`, err); return { item, index, innerParts: [] }; } })); @@ -2807,30 +2836,21 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } - private async _loadSubagentState(childSessionUri: string): Promise { - const childSub = this._ensureSessionSubscription(childSessionUri); - // `_ensureSessionSubscription` already subscribes to the child's default - // chat in lockstep; grab a handle so we can await it too. After the - // multi-chat protocol split, `turns` live on the chat channel, so we - // must wait for BOTH the session summary and its default-chat state to - // hydrate. Awaiting only the session subscription would read the merged - // state while `turns` is still empty, yielding no subagent inner tool - // calls. This mirrors the main `provideChatSessionContent` path. - const childChatSub = this._ensureDefaultChatSubscription(childSessionUri); + private async _loadSubagentState(parentSessionUri: string, childChatUri: string): Promise { + const childSub = this._ensureSessionSubscription(parentSessionUri); try { - await Promise.all([ - this._whenSubscriptionHydrated(childSub, CancellationToken.None), - this._whenSubscriptionHydrated(childChatSub, CancellationToken.None), - ]); + await this._whenSubscriptionHydrated(childSub, CancellationToken.None); if (childSub.value instanceof Error) { throw childSub.value; } + const childChatSub = this._ensureChatSubscription(parentSessionUri, childChatUri); + await this._whenSubscriptionHydrated(childChatSub, CancellationToken.None); if (childChatSub.value instanceof Error) { throw childChatSub.value; } - return this._getSessionState(childSessionUri); + return this._getSessionState(parentSessionUri, childChatUri); } finally { - this._releaseSessionSubscription(childSessionUri); + this._releaseChatSessionSubscriptions(parentSessionUri, childChatUri); } } @@ -2911,16 +2931,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC perInvocationCreditsAccumulator: ISettableObservable, perInvocationModel: ISettableObservable, ): void { - const childSessionUri = buildSubagentSessionUri(parentSession.toString(), parentToolCallId); - const childUri = URI.parse(childSessionUri); + const parentSessionUri = parentSession.toString(); + const childChatUri = buildSubagentChatUri(parentSessionUri, parentToolCallId); const cts = new CancellationTokenSource(); disposables.add(toDisposable(() => cts.dispose(true))); try { - const childSub = this._ensureSessionSubscription(childSessionUri); - const childChatSub = this._ensureDefaultChatSubscription(childSessionUri); - disposables.add(toDisposable(() => this._releaseSessionSubscription(childSessionUri))); + const childSub = this._ensureSessionSubscription(parentSessionUri); + const childChatSub = this._ensureChatSubscription(parentSessionUri, childChatUri); + disposables.add(toDisposable(() => this._releaseChatSessionSubscriptions(parentSessionUri, childChatUri))); const childSessionState$ = observableFromSubscription(this, childSub); const childChatState$ = observableFromSubscription(this, childChatSub); @@ -2932,10 +2952,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return mergeSessionWithDefaultChat(session, childChatState$.read(reader)); }); - // All turn ids observed in the child session: completed turns - // plus any active turn that is not also already in `turns`. Each - // id is keyed so `autorunPerKeyedItem` discovers new turns - // incrementally and creates a fresh observer for each. const childTurnIds$ = derived(reader => { const state = childState$.read(reader); if (!state) { @@ -2954,9 +2970,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC t => t.id, (turnId, _t$, turnStore) => { turnStore.add(this._observeTurn({ - backendSession: childUri, + backendSession: parentSession, sessionResource, - chatChannel: this._resolveDefaultChatUri(childSessionUri), + chatURI: childChatUri, turnId, sink: emitProgress, cancellationToken: cts.token, @@ -2969,7 +2985,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } catch (err) { // Remove from observed set so a later state change can retry subagentContext.observedToolIds.delete(parentToolCallId); - this._logService.warn(`[AgentHost] Failed to subscribe to subagent session: ${childSessionUri}`, err); + this._logService.warn(`[AgentHost] Failed to subscribe to subagent chat: ${childChatUri}`, err); } } @@ -2988,7 +3004,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC initialProgress: IChatProgress[], ): void { const sessionKey = backendSession.toString(); - const chatKey = this._resolveChatUri(chatSession.sessionResource).toString(); + const chatURI = this._getChatURI(chatSession.sessionResource); // Extract live ChatToolInvocation objects from the initial progress // array so per-tool setup adopts the same instances the chat UI holds. @@ -3003,7 +3019,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // per-part setup only emits content beyond what `activeTurnToProgress` // already produced. const seedEmittedLengths = new Map(); - const currentState = this._getSessionState(sessionKey, chatKey); + const currentState = this._getSessionState(sessionKey, chatURI); if (currentState?.activeTurn) { for (const rp of currentState.activeTurn.responseParts) { if (rp.kind === ResponsePartKind.Markdown || rp.kind === ResponsePartKind.Reasoning) { @@ -3018,7 +3034,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC reconnectStore.add(this._observeTurn({ backendSession, sessionResource: chatSession.sessionResource, - chatChannel: chatKey, + chatURI, turnId, sink: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, @@ -3121,38 +3137,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return AgentSession.uri(this._config.provider, rawId); } - /** - * Resolve the chat channel URI a chat widget resource maps to. A resource - * whose URI carries a `chatId` fragment addresses an additional peer chat - * (see the agent-host sessions provider); otherwise it addresses the - * session's default chat. Conversation subscription, history and turn - * dispatch all target this URI, while session-scoped operations continue - * to use {@link _resolveSessionUri}. - */ - private _resolveChatUri(sessionResource: URI): URI { - const session = this._resolveSessionUri(sessionResource); - if (sessionResource.fragment) { - return URI.parse(buildChatUri(session.toString(), sessionResource.fragment)); - } - return URI.parse(this._resolveDefaultChatUri(session.toString())); - } - - /** - * Channel that turn-lifecycle actions (turnStarted, truncated, - * turnCancelled) dispatch to. Unlike conversation side-channel actions — - * which target the resolved chat URI (see {@link _resolveChatUri}) — the - * default chat's turn lifecycle targets the owning session URI directly: - * the server maps a session-scoped turn dispatch to that session's default - * chat, and subagent session URIs are derived from this channel. Peer chats - * carry a chatId fragment and target their own chat channel URI. - */ - private _resolveTurnDispatchChannel(sessionResource: URI): string { - const session = this._resolveSessionUri(sessionResource); - return sessionResource.fragment - ? buildChatUri(session.toString(), sessionResource.fragment) - : session.toString(); - } - private _isNewSessionResource(sessionResource: URI): boolean { return !!this._config.isNewSession?.(sessionResource) || this._workingDirectoryResolver.isNewSession(sessionResource); @@ -3302,6 +3286,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } + const rawState = this._requireRawSessionState(session.toString()); + const chatURI = this._resolveChatUriFromState(sessionResource, rawState); + this._setChatURI(sessionResource, chatURI); + this._ensureChatSubscription(session.toString(), chatURI); + // Start syncing the chat model's pending requests to the protocol this._ensurePendingMessageSubscription(sessionResource, session); @@ -3877,9 +3866,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ref = this._config.connection.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'AgentHostSessionHandler'); this._sessionSubscriptions.set(sessionUri, ref); } - // Subscribe to the session's default chat in lockstep so the merged - // view exposed by `_getSessionState` carries conversation contents. - this._ensureDefaultChatSubscription(sessionUri); return ref.object; } @@ -3895,36 +3881,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ref = undefined; } if (!ref) { - const chatUri = URI.parse(this._resolveDefaultChatUri(sessionUri)); + const state = this._requireRawSessionState(sessionUri); + const defaultChat = state.defaultChat; + if (!defaultChat) { + throw new Error(`Session ${sessionUri} has no default chat`); + } + const chatUri = URI.parse(defaultChat.toString()); ref = this._config.connection.getSubscription(StateComponents.Chat, chatUri, 'AgentHostSessionHandler'); this._defaultChatSubscriptions.set(sessionUri, ref); } return ref.object; } - /** - * Release a session subscription, decrementing refcount and unsubscribing - * when it reaches zero. - */ - private _releaseSessionSubscription(sessionUri: string): void { - const ref = this._sessionSubscriptions.get(sessionUri); - if (ref) { - this._sessionSubscriptions.delete(sessionUri); - ref.dispose(); - } - const chatRef = this._defaultChatSubscriptions.get(sessionUri); - if (chatRef) { - this._defaultChatSubscriptions.delete(sessionUri); - chatRef.dispose(); - } - for (const [chatUri, ref] of [...this._additionalChatSubscriptions]) { - if (parseChatUri(chatUri)?.session === sessionUri) { - this._additionalChatSubscriptions.delete(chatUri); - ref.dispose(); - } - } - } - /** * Release the subscriptions held by a single chat session on dispose. * @@ -3942,7 +3910,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Release this chat's own conversation subscription. The default chat's // subscription is keyed by session URI and torn down together with the // shared session subscription below; peer chats own a dedicated entry. - if (chatUri !== this._resolveDefaultChatUri(sessionUri)) { + if (chatUri !== this._getRawSessionState(sessionUri)?.defaultChat?.toString()) { const chatRef = this._additionalChatSubscriptions.get(chatUri); if (chatRef) { this._additionalChatSubscriptions.delete(chatUri); @@ -4003,26 +3971,49 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const store = new DisposableStore(); const settle = () => { store.dispose(); resolve(); }; store.add(sub.onDidChange(() => { if (sub.value !== undefined) { settle(); } })); + const onDidError = sub.onDidError; + if (onDidError) { + store.add(onDidError(settle)); + } store.add(token.onCancellationRequested(settle)); if (sub.value !== undefined) { settle(); } }); } private _getSessionState(sessionUri: string, chatUri?: string): ISessionWithDefaultChat | undefined { - const ref = this._sessionSubscriptions.get(sessionUri); - if (!ref) { + const value = this._getRawSessionState(sessionUri); + if (!value) { return undefined; } - const value = ref.object.value; - if (!value || value instanceof Error) { - return undefined; - } - const chatState = chatUri && chatUri !== this._resolveDefaultChatUri(sessionUri) + const defaultChat = value.defaultChat?.toString(); + const chatState = chatUri && chatUri !== defaultChat ? this._getAdditionalChatState(chatUri) : this._getDefaultChatState(sessionUri); return mergeSessionWithDefaultChat(value, chatState); } + private _getRawSessionState(sessionUri: string): SessionState | undefined { + const ref = this._sessionSubscriptions.get(sessionUri); + const value = ref?.object.value; + return value && !(value instanceof Error) ? value : undefined; + } + + private _requireRawSessionState(sessionUri: string): SessionState { + const state = this._getRawSessionState(sessionUri); + if (!state) { + throw new Error(`Session state is not hydrated for ${sessionUri}`); + } + return state; + } + + private _requireDefaultChatUri(sessionUri: string): string { + const defaultChat = this._requireRawSessionState(sessionUri).defaultChat; + if (!defaultChat) { + throw new Error(`Session ${sessionUri} has no default chat`); + } + return defaultChat.toString(); + } + /** Read the current optimistic default-chat state for a backend session URI. */ private _getDefaultChatState(sessionUri: string): ChatState | undefined { const ref = this._defaultChatSubscriptions.get(sessionUri); @@ -4068,7 +4059,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * subscription (fragment-less resource) or to an additional peer chat. */ private _ensureChatSubscription(sessionUri: string, chatUri: string): IAgentSubscription { - return chatUri === this._resolveDefaultChatUri(sessionUri) + return chatUri === this._requireDefaultChatUri(sessionUri) ? this._ensureDefaultChatSubscription(sessionUri) : this._ensureAdditionalChatSubscription(chatUri); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index df89f94767a90..532455c62ec56 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -23,7 +23,7 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -154,7 +154,7 @@ class MockAgentHostService extends mock() { workingDirectory: (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(), }; const state: SessionState = { - ...createSessionState(summary), + ...this._withDefaultChatCatalog(createSessionState(summary), session.toString()), lifecycle: SessionLifecycle.Ready, activeClients: [config.activeClient], }; @@ -182,7 +182,7 @@ class MockAgentHostService extends mock() { const resourceStr = resource.toString(); const existingState = this.sessionStates.get(resourceStr); if (existingState) { - return { resource: resourceStr, state: existingState, fromSeq: 0 }; + return { resource: resourceStr, state: this._withDefaultChatCatalog(existingState, resourceStr), fromSeq: 0 }; } // Root state subscription if (isAhpRootChannel(resourceStr)) { @@ -205,7 +205,7 @@ class MockAgentHostService extends mock() { }; return { resource: resourceStr, - state: { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }, + state: { ...this._withDefaultChatCatalog(createSessionState(summary), resourceStr), lifecycle: SessionLifecycle.Ready }, fromSeq: 0, }; } @@ -278,7 +278,7 @@ class MockAgentHostService extends mock() { } else { const existingState = this.sessionStates.get(resourceStr); if (existingState) { - initialState = existingState; + initialState = this._withDefaultChatCatalog(existingState, resourceStr); } else { const summary: SessionSummary = { resource: resourceStr, @@ -288,7 +288,7 @@ class MockAgentHostService extends mock() { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), }; - initialState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + initialState = { ...this._withDefaultChatCatalog(createSessionState(summary), resourceStr), lifecycle: SessionLifecycle.Ready }; } } @@ -392,7 +392,7 @@ class MockAgentHostService extends mock() { * conversation fields a test attached to the session's {@link SeededSessionState}. */ private _buildDefaultChatState(sessionUriStr: string, chatUriStr: string): ChatState { - const seeded = this.sessionStates.get(sessionUriStr); + const seeded = this.sessionStates.get(chatUriStr) ?? this.sessionStates.get(sessionUriStr); const sessionSummary: SessionSummary = { resource: sessionUriStr, provider: seeded?.provider ?? 'copilot', @@ -415,6 +415,31 @@ class MockAgentHostService extends mock() { }; } + private _withDefaultChatCatalog(state: T, resource: string): T { + if (state.defaultChat && state.chats.length > 0) { + return state; + } + const summary: SessionSummary = { + resource, + provider: state.provider, + title: state.title, + status: state.status, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + workingDirectory: state.workingDirectory, + project: state.project, + }; + const chatUri = buildDefaultChatUri(resource); + const additionalChats = [...this.sessionStates.keys()] + .filter(uri => uri !== chatUri && parseDefaultChatUri(uri) === resource) + .map(uri => createDefaultChatSummary(summary, uri)); + return { + ...state, + defaultChat: chatUri, + chats: [createDefaultChatSummary(summary, chatUri), ...additionalChats], + }; + } + addSession(meta: IAgentSessionMetadata): void { this._sessions.set(meta.session.toString(), meta); } @@ -1446,6 +1471,24 @@ suite('AgentHostChatContribution', () => { const backendSession = AgentSession.uri('copilot', 'multi'); const peerResource = URI.from({ scheme: 'agent-host-copilot', path: '/multi', fragment: 'peer-1' }); const peerChatUri = URI.parse(buildChatUri(backendSession.toString(), 'peer-1')); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + const defaultChatUri = buildDefaultChatUri(backendSession.toString()); + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat: defaultChatUri, + chats: [ + createDefaultChatSummary(summary, defaultChatUri), + createDefaultChatSummary(summary, peerChatUri.toString()), + ], + }); // Open the session's default chat and an additional peer chat. const defaultSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -2224,7 +2267,9 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); assert.strictEqual(agentHostService.turnActions[0].action.type, 'chat/turnStarted'); assert.strictEqual((agentHostService.turnActions[0].action as ITurnStartedAction).message.text, 'Hello'); - assert.strictEqual(AgentSession.id(URI.parse(session)), 'new-turntest'); + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + assert.strictEqual(AgentSession.id(URI.parse(parentSession)), 'new-turntest'); })); test('reuses SDK session for same resource on second message', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -2281,7 +2326,9 @@ suite('AgentHostChatContribution', () => { fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); await turnPromise; - assert.strictEqual(AgentSession.id(URI.parse(session)), 'existing-session-42'); + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + assert.strictEqual(AgentSession.id(URI.parse(parentSession)), 'existing-session-42'); })); test('recovers from stale failed subscription before first send', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -2543,7 +2590,9 @@ suite('AgentHostChatContribution', () => { // Spawn a subagent tool call. const parentToolCallId = 'tc-sub-cost'; - const childSessionUri = buildSubagentSessionUri(session.toString(), parentToolCallId); + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', @@ -2554,6 +2603,11 @@ suite('AgentHostChatContribution', () => { toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); await timeout(50); @@ -6476,7 +6530,9 @@ suite('AgentHostChatContribution', () => { // call fires, so that when the handler subscribes to it the inner tool // is already present. const parentToolCallId = 'tc-parent-task'; - const childSessionUri = buildSubagentSessionUri(session.toString(), parentToolCallId); + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); agentHostService.sessionStates.set(childSessionUri, makeChildState(childSessionUri, 'tc-child-1')); // Fire the parent task tool call with toolKind=subagent metadata. @@ -6490,6 +6546,11 @@ suite('AgentHostChatContribution', () => { toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); // Allow the throttler/observation flow to flush. await timeout(50); @@ -6518,7 +6579,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); const parentToolCallId = 'tc-parent-task'; - const childSessionUri = buildSubagentSessionUri(session.toString(), parentToolCallId); + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); + agentHostService.sessionStates.set(childSessionUri, makeChildState(childSessionUri, 'tc-child-1')); // Fire the parent task tool — this should cause the handler to subscribe // to the (still-empty) child subagent session. @@ -6532,6 +6596,11 @@ suite('AgentHostChatContribution', () => { toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); // Allow the subscription to be set up. await timeout(50); @@ -6603,12 +6672,14 @@ suite('AgentHostChatContribution', () => { // Now the SDK emits subagent_started → handler dispatches a content // change with a Subagent content block carrying the agent name. + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); fire({ type: 'chat/toolCallContentChanged', session, turnId, toolCallId: parentToolCallId, content: [{ type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(session.toString(), parentToolCallId), + resource: buildSubagentChatUri(parentSession, parentToolCallId), title: 'Subagent', agentName: 'explore', }], diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 69d736feb84a0..126395f34ef80 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -17,7 +17,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { isChatAction, isSessionAction, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildDefaultChatUri, buildSubagentSessionUri, createChatState, createDefaultChatSummary, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, type ChatState, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildDefaultChatUri, buildSubagentChatUri, createChatState, createDefaultChatSummary, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, parseDefaultChatUri, type ChatState, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { chatReducer, sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -341,7 +341,8 @@ suite('AgentHostClientTools', () => { applySessionAction(channel: string | URI, action: SessionAction | ChatAction): void { const channelStr = typeof channel === 'string' ? channel : channel.toString(); if (isChatAction(action)) { - const chatChannel = channelStr.startsWith('ahp-chat:') ? channelStr : buildDefaultChatUri(channelStr); + const chatChannel = parseDefaultChatUri(channelStr) !== undefined ? channelStr : undefined; + assert.ok(chatChannel, `chat actions must be dispatched on an ahp-chat channel: ${action.type}`); const entry = this._ensureLiveSubscription(StateComponents.Chat, chatChannel); entry.state = chatReducer(entry.state as ChatState, action as Parameters[1], () => { }); entry.emitter.fire(entry.state); @@ -388,17 +389,25 @@ suite('AgentHostClientTools', () => { return entry; } const emitter = disposables.add(new Emitter()); + const sessionResource = kind === StateComponents.Chat ? parseDefaultChatUri(resourceStr) : resourceStr; + assert.ok(sessionResource, `chat subscriptions must use an ahp-chat channel: ${resourceStr}`); const summary: SessionSummary = { - resource: resourceStr, + resource: sessionResource, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), }; + const defaultChat = buildDefaultChatUri(sessionResource); const initialState = kind === StateComponents.Chat ? createChatState(createDefaultChatSummary(summary, resourceStr)) - : { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + : { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [createDefaultChatSummary(summary, defaultChat)], + }; entry = { state: initialState, emitter }; this._liveSubscriptions.set(resourceStr, entry); return entry; @@ -572,12 +581,12 @@ suite('AgentHostClientTools', () => { const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -585,7 +594,7 @@ suite('AgentHostClientTools', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -619,12 +628,12 @@ suite('AgentHostClientTools', () => { const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -632,7 +641,7 @@ suite('AgentHostClientTools', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -690,12 +699,12 @@ suite('AgentHostClientTools', () => { const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -703,7 +712,7 @@ suite('AgentHostClientTools', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, } as ChatAction); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-call-1', @@ -746,15 +755,15 @@ suite('AgentHostClientTools', () => { const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); const parentToolCallId = 'tc-parent-task'; - const subagentBackendSession = buildSubagentSessionUri(backendSession, parentToolCallId); + const subagentChat = buildSubagentChatUri(backendSession, parentToolCallId); // Parent turn with a `task` tool that spawns a subagent. - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'do work', origin: { kind: MessageKind.User } }, }); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: parentToolCallId, @@ -762,7 +771,7 @@ suite('AgentHostClientTools', () => { displayName: 'Task', _meta: { toolKind: 'subagent' }, }); - connection.applySessionAction(URI.parse(backendSession), { + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: parentToolCallId, @@ -770,16 +779,22 @@ suite('AgentHostClientTools', () => { toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallContentChanged, + turnId: 'turn-1', + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: subagentChat, title: 'Subagent' }], + }); // Subagent turn carrying a client-provided tool call (toolClientId // matches the renderer's clientId so the renderer owns the // invocation). - connection.applySessionAction(URI.parse(subagentBackendSession), { + connection.applySessionAction(URI.parse(subagentChat), { type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', message: { text: '', origin: { kind: MessageKind.User } }, }); - connection.applySessionAction(URI.parse(subagentBackendSession), { + connection.applySessionAction(URI.parse(subagentChat), { type: ActionType.ChatToolCallStart, turnId: 'sub-turn-1', toolCallId: 'inner-tool-call-1', @@ -787,7 +802,7 @@ suite('AgentHostClientTools', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, }); - connection.applySessionAction(URI.parse(subagentBackendSession), { + connection.applySessionAction(URI.parse(subagentChat), { type: ActionType.ChatToolCallReady, turnId: 'sub-turn-1', toolCallId: 'inner-tool-call-1', @@ -818,7 +833,7 @@ suite('AgentHostClientTools', () => { assert.ok(completionEntry, 'completion for the inner client tool should be dispatched'); assert.strictEqual( completionEntry.channel.toString(), - buildDefaultChatUri(subagentBackendSession), + subagentChat, 'completion should target the subagent default chat URI' ); });