diff --git a/ts/packages/copilot-plugin/README.md b/ts/packages/copilot-plugin/README.md index d1ca8cc681..ee8cf3a926 100644 --- a/ts/packages/copilot-plugin/README.md +++ b/ts/packages/copilot-plugin/README.md @@ -300,19 +300,28 @@ pending prompts/unsupported interaction rather than pretending completion. ### Explicit binding, reconnect, and trust -NL and structured calls use the same conversation selection in every routing -mode. With no explicit ID, the first caller resolves the server default and -saves its **concrete conversation ID** under the plugin data directory's -`conversation-bindings` folder, keyed by server URL. Later hooks, MCP processes, -and reconnects reuse that ID even if the server default changes. Concurrent -first callers atomically adopt the same saved ID. Routing mode does not change -which conversation data is visible. - -This is shared plugin/server context, not one conversation per Copilot chat: -stdio does not provide an intrinsic Copilot session identity. Sessions using the -same plugin data directory and server share the saved default, as NL callers -already shared the server default. Separate plugin data directories or explicit -IDs select separate context. +NL, direct/dev hooks, extension history, and structured calls share automatic +conversation selection **within a Copilot session**. The first caller resolves +the server default and saves its concrete ID, keyed by server URL and host +session ID. Later calls, reconnects, and resumed sessions retain that selection +even if the server default changes. Concurrent first callers atomically adopt +the same saved ID. A fresh Copilot session resolves the current server default, +not another session's saved selection. This does not create a private TypeAgent +conversation per chat: sessions resolving the same default still share its data. + +Copilot CLI supplies `COPILOT_AGENT_SESSION_ID` to MCP children. Hooks use their +input `sessionId` instead of a potentially inherited environment value; extension +history uses the joined SDK session ID. Hosts without an MCP session ID share +automatic NL/structured selection only inside that MCP process. Cross-process +alignment on those hosts requires an explicit existing conversation ID. + +Session bindings live in `conversation-bindings/sessions` under +`TYPEAGENT_PLUGIN_DATA`, or `~/.typeagent-copilot` by default. Unlike configuration +lookup, binding storage does not use `CLAUDE_PLUGIN_DATA`: Copilot injects that +variable into hooks but not MCP processes. Explicit `TYPEAGENT_PLUGIN_DATA` +isolates both configuration and bindings and must be consistent across callers. +Legacy server-only `conversation-bindings/*.json` defaults are ignored and left +untouched; they are not explicit user pins and must not bind unrelated new chats. The two routes keep separate connections. Structured calls explicitly join the selected ID with `structuredActions: {}` to obtain an independent owner; the @@ -331,12 +340,14 @@ before changing selection, then start fresh sessions; pending work is not automatically moved or replayed. Two fresh processes using the same public ID still get isolated structured owners. -A missing/deleted conversation or an unreadable/corrupt saved binding is an -error, not a reason to silently choose a new default. To select another existing -conversation, configure its ID. To intentionally resolve the default again, -close sessions, remove only the matching server's saved binding file, and start -fresh sessions with no explicit ID. Configuration fields such as selected -skills are not rewritten when the default ID is saved. +A missing/deleted conversation or an unreadable/corrupt binding in the current +session is an error, not a reason to silently choose a new default. To select +another existing conversation, configure its ID and start a fresh session. +Without an explicit pin, starting a **new** Copilot session resolves the current +default; resuming the old session intentionally retains its selection and may +still fail. No request is automatically retried, and pending approvals, scopes, +and resume capabilities never move to another conversation. Configuration fields +such as selected skills are not rewritten when a default ID is saved. The server's structured resume token is retained only in private volatile connector memory. It is never logged, printed, persisted, put in config, or sent @@ -829,14 +840,14 @@ The plugin stores config at `%USERPROFILE%\.typeagent-copilot\config.json` (Wind **Environment variable overrides** (take precedence over config file): -| Variable | Default | Description | -| --------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | -| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` | -| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host | -| `TYPEAGENT_PORT` | `8999` | TypeAgent server port | -| `TYPEAGENT_CONVERSATION_ID` | Dedicated per-process conversation | Optional existing public conversation ID for structured tools; overrides config `conversationId` | -| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory | -| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter | +| Variable | Default | Description | +| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` | +| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host | +| `TYPEAGENT_PORT` | `8999` | TypeAgent server port | +| `TYPEAGENT_CONVERSATION_ID` | Session-selected server default | Optional existing public conversation ID for all routes; overrides config `conversationId` and automatic selection | +| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory | +| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter | --- diff --git a/ts/packages/copilot-plugin/src/extension/session-capture.ts b/ts/packages/copilot-plugin/src/extension/session-capture.ts index dcbbeafd70..6d93a9aeb1 100644 --- a/ts/packages/copilot-plugin/src/extension/session-capture.ts +++ b/ts/packages/copilot-plugin/src/extension/session-capture.ts @@ -53,9 +53,10 @@ function resultText(result: unknown): string { } async function withDispatcher( + sessionId: string, operation: (dispatcher: Dispatcher) => Promise, ): Promise { - const dispatcher = await connectToTypeAgent(createClientIO({})); + const dispatcher = await connectToTypeAgent(createClientIO({}), sessionId); try { await operation(dispatcher); } finally { @@ -63,7 +64,10 @@ async function withDispatcher( } } -async function insertToolHistory(event: ExtensionSessionEvent): Promise { +async function insertToolHistory( + event: ExtensionSessionEvent, + sessionId: string, +): Promise { if (event.data.success !== true) return; const toolName = typeof event.data.toolName === "string" @@ -89,7 +93,7 @@ async function insertToolHistory(event: ExtensionSessionEvent): Promise { source: "copilot-cli", }, }; - await withDispatcher(async (dispatcher) => { + await withDispatcher(sessionId, async (dispatcher) => { await awaitCommand( dispatcher, `@history insert ${JSON.stringify(message)}`, @@ -99,6 +103,7 @@ async function insertToolHistory(event: ExtensionSessionEvent): Promise { async function insertTurnHistory( trace: RecordedInteractionTrace, + sessionId: string, ): Promise { if ( trace.toolCalls.some((tool) => @@ -116,7 +121,7 @@ async function insertTurnHistory( source: "copilot-cli", }, }; - await withDispatcher(async (dispatcher) => { + await withDispatcher(sessionId, async (dispatcher) => { await awaitCommand( dispatcher, `@history insert ${JSON.stringify(message)}`, @@ -200,10 +205,13 @@ export class SessionCapture { const metadata = this.toolMetadata.get(key); this.toolMetadata.delete(key); if (metadata) { - await this.dependencies.insertToolHistory({ - ...event, - data: { ...event.data, ...metadata }, - }); + await this.dependencies.insertToolHistory( + { + ...event, + data: { ...event.data, ...metadata }, + }, + this.sessionId, + ); } } if (event.type === "session.idle") { @@ -249,7 +257,10 @@ export class SessionCapture { } } if (trace && !aborted) { - await this.dependencies.insertTurnHistory(trace); + await this.dependencies.insertTurnHistory( + trace, + this.sessionId, + ); writeDemoState({ event: "turnComplete", turnId: makeTurnId(this.sessionId), diff --git a/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts b/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts index d2deea8e91..6cefad72d4 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts @@ -159,7 +159,10 @@ export async function handleDevActions( try { abortSignal?.throwIfAborted(); - dispatcher = await dependencies.connectToTypeAgent(clientIO); + dispatcher = await dependencies.connectToTypeAgent( + clientIO, + input.sessionId, + ); abortSignal?.throwIfAborted(); submissionStarted = true; const submitResult = await dispatcher.submitCommand( diff --git a/ts/packages/copilot-plugin/src/hooks/hook-direct.ts b/ts/packages/copilot-plugin/src/hooks/hook-direct.ts index 26b91dfd9e..746b493b7b 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-direct.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-direct.ts @@ -142,7 +142,10 @@ export async function handleDirect( dependencies.emitProgress("Connecting to TypeAgent...", { temporary: true, }); - dispatcher = await dependencies.connectToTypeAgent(clientIO); + dispatcher = await dependencies.connectToTypeAgent( + clientIO, + input.sessionId, + ); dependencies.emitProgress("Processing command...", { temporary: true, }); diff --git a/ts/packages/copilot-plugin/src/mcp/server.ts b/ts/packages/copilot-plugin/src/mcp/server.ts index 89ced9d1aa..becf9903ee 100644 --- a/ts/packages/copilot-plugin/src/mcp/server.ts +++ b/ts/packages/copilot-plugin/src/mcp/server.ts @@ -18,7 +18,7 @@ const server = : new TypeAgentMcpServer(); if (server instanceof TypeAgentMcpServer) { - // Stdio has no intrinsic Copilot session identity. This process retains one + // The host session selects public context; this process alone retains the // private structured owner until shutdown, without cancelling pending work. process.once("SIGINT", () => void server.close()); process.once("SIGTERM", () => void server.close()); diff --git a/ts/packages/copilot-plugin/src/shared/conversation-selection.ts b/ts/packages/copilot-plugin/src/shared/conversation-selection.ts index 047933a6fc..82049296df 100644 --- a/ts/packages/copilot-plugin/src/shared/conversation-selection.ts +++ b/ts/packages/copilot-plugin/src/shared/conversation-selection.ts @@ -4,16 +4,36 @@ import { createHash, randomUUID } from "node:crypto"; import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { homedir } from "node:os"; import type { AgentServerConnection, ClientIO, } from "@typeagent/agent-server-client"; -import { getConfigDir, getConversationId } from "./plugin-config.js"; +import { getConversationId } from "./plugin-config.js"; -function bindingPath(url: string): string { +const processBindings = new Map(); + +function bindingKey(url: string, sessionId: string | undefined): string { const server = new URL(url).href; - const key = createHash("sha256").update(server).digest("hex"); - return join(getConfigDir(), "conversation-bindings", `${key}.json`); + return createHash("sha256") + .update(JSON.stringify([server, sessionId])) + .digest("hex"); +} + +function bindingLocation(url: string, sessionId: string | undefined) { + // Copilot supplies CLAUDE_PLUGIN_DATA to hooks but not to MCP children. + const directory = + process.env.TYPEAGENT_PLUGIN_DATA ?? + join(homedir(), ".typeagent-copilot"); + if (sessionId !== undefined && !sessionId.trim()) { + throw new Error("TypeAgent conversation session ID must not be empty."); + } + return join( + directory, + "conversation-bindings", + "sessions", + `${bindingKey(url, sessionId)}.json`, + ); } function hasCode(error: unknown, code: string): boolean { @@ -51,22 +71,26 @@ async function readBinding(path: string): Promise { return requireId(value.conversationId); } -/** Public context only, shared by hook/MCP processes using this config and server. */ +/** Public context only; automatic selections belong to a host session, not a server. */ export async function readSelectedConversationId( url: string, + sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID, ): Promise { const configured = getConversationId(); - return configured === undefined - ? readBinding(bindingPath(url)) - : requireId(configured); + if (configured !== undefined) return requireId(configured); + const path = bindingLocation(url, sessionId); + return sessionId === undefined + ? processBindings.get(path) + : readBinding(path); } export async function selectConversationId( connection: AgentServerConnection, clientIO: ClientIO, url: string, + sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID, ): Promise { - const selected = await readSelectedConversationId(url); + const selected = await readSelectedConversationId(url, sessionId); if (selected !== undefined) return selected; const joined = await connection.joinConversation(clientIO, { @@ -75,7 +99,13 @@ export async function selectConversationId( }); await connection.leaveConversation(joined.conversationId); const conversationId = requireId(joined.conversationId); - const path = bindingPath(url); + const path = bindingLocation(url, sessionId); + if (sessionId === undefined) { + // Unknown hosts share NL/structured context only inside this process. + const winner = processBindings.get(path) ?? conversationId; + processBindings.set(path, winner); + return winner; + } await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.${randomUUID()}.tmp`; await writeFile(temporary, JSON.stringify({ conversationId }), { @@ -92,7 +122,7 @@ export async function selectConversationId( } finally { await unlink(temporary); } - const winner = await readSelectedConversationId(url); + const winner = await readSelectedConversationId(url, sessionId); if (winner === undefined) { throw new Error( "TypeAgent conversation binding disappeared during selection.", diff --git a/ts/packages/copilot-plugin/src/shared/structured-action-client.ts b/ts/packages/copilot-plugin/src/shared/structured-action-client.ts index 49f8fe864a..7466783233 100644 --- a/ts/packages/copilot-plugin/src/shared/structured-action-client.ts +++ b/ts/packages/copilot-plugin/src/shared/structured-action-client.ts @@ -18,6 +18,7 @@ export function createStructuredActionClient( connect?: StructuredActionClientOptions["connect"], ): StructuredActionClient { const conversationId = getConversationId(); + const sessionId = process.env.COPILOT_AGENT_SESSION_ID; const clientIO = createClientIO({}); return new StructuredActionClient({ url: TYPEAGENT_URL, @@ -25,10 +26,15 @@ export function createStructuredActionClient( ...(connect === undefined ? {} : { connect }), ...(conversationId === undefined ? {} : { conversationId }), resolveConversationId: (connection) => - selectConversationId(connection, clientIO, TYPEAGENT_URL), + selectConversationId( + connection, + clientIO, + TYPEAGENT_URL, + sessionId, + ), validateConversationId: async (conversationId) => { if ( - (await readSelectedConversationId(TYPEAGENT_URL)) !== + (await readSelectedConversationId(TYPEAGENT_URL, sessionId)) !== conversationId ) { throw new StructuredActionClientError( diff --git a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts index d7395e0952..a8b4403a56 100644 --- a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts +++ b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts @@ -123,8 +123,12 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { */ export async function connectToTypeAgent( clientIO: ClientIO, + sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID, ): Promise { - let conversationId = await readSelectedConversationId(TYPEAGENT_URL); + let conversationId = await readSelectedConversationId( + TYPEAGENT_URL, + sessionId, + ); if (conversationId === undefined) { const connection = await connectToAgentServer(); try { @@ -132,6 +136,7 @@ export async function connectToTypeAgent( connection, clientIO, TYPEAGENT_URL, + sessionId, ); } finally { await connection.close(); diff --git a/ts/packages/copilot-plugin/test/conversationSelection.spec.ts b/ts/packages/copilot-plugin/test/conversationSelection.spec.ts index f103b60427..6307235c0e 100644 --- a/ts/packages/copilot-plugin/test/conversationSelection.spec.ts +++ b/ts/packages/copilot-plugin/test/conversationSelection.spec.ts @@ -2,7 +2,15 @@ // Licensed under the MIT License. import { jest } from "@jest/globals"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFile } from "node:child_process"; @@ -19,7 +27,7 @@ import { writeConfig } from "../src/shared/plugin-config.js"; const io = createClientIO({}); const url = `ws://${process.env.TYPEAGENT_HOST || "localhost"}:${process.env.TYPEAGENT_PORT || "8999"}`; -function connection(defaultId: string) { +function connection(defaultId: string, resumeToken = "never-persist-this") { const searchActions = jest.fn(async () => ({ protocolVersion: 1, scopeId: "scope", @@ -36,7 +44,7 @@ function connection(defaultId: string) { ) => ({ conversationId: options?.conversationId ?? defaultId, dispatcher: { searchActions, executeAction }, - structuredActions: { resumeToken: "never-persist-this" }, + structuredActions: { resumeToken }, }), ); const leaveConversation = jest.fn(async () => {}); @@ -61,10 +69,13 @@ describe("shared conversation selection across routes and processes", () => { TYPEAGENT_PLUGIN_DATA: process.env.TYPEAGENT_PLUGIN_DATA, TYPEAGENT_CONVERSATION_ID: process.env.TYPEAGENT_CONVERSATION_ID, TYPEAGENT_MODE: process.env.TYPEAGENT_MODE, + COPILOT_AGENT_SESSION_ID: process.env.COPILOT_AGENT_SESSION_ID, + CLAUDE_PLUGIN_DATA: process.env.CLAUDE_PLUGIN_DATA, }; beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), "conversation-selection-")); process.env.TYPEAGENT_PLUGIN_DATA = directory; + process.env.COPILOT_AGENT_SESSION_ID = "session-1"; delete process.env.TYPEAGENT_CONVERSATION_ID; delete process.env.TYPEAGENT_MODE; }); @@ -85,12 +96,19 @@ describe("shared conversation selection across routes and processes", () => { ]); expect(selected[0]).toBe(selected[1]); expect(["first", "second"]).toContain(selected[0]); - const files = await readdir(join(directory, "conversation-bindings")); + const files = await readdir( + join(directory, "conversation-bindings", "sessions"), + ); expect(files).toHaveLength(1); expect( JSON.parse( await readFile( - join(directory, "conversation-bindings", files[0]), + join( + directory, + "conversation-bindings", + "sessions", + files[0], + ), "utf8", ), ), @@ -246,8 +264,13 @@ describe("shared conversation selection across routes and processes", () => { it("does not re-resolve or overwrite a corrupt persisted binding", async () => { const fake = connection("first"); await selectConversationId(fake.connection, io, url); - const [file] = await readdir(join(directory, "conversation-bindings")); - await writeFile(join(directory, "conversation-bindings", file), "{}"); + const [file] = await readdir( + join(directory, "conversation-bindings", "sessions"), + ); + await writeFile( + join(directory, "conversation-bindings", "sessions", file), + "{}", + ); await expect( selectConversationId(fake.connection, io, url), ).rejects.toThrow("Invalid TypeAgent conversation binding"); @@ -260,4 +283,250 @@ describe("shared conversation selection across routes and processes", () => { "non-empty string", ); }); + + it("resolves fresh sessions independently and preserves each selection on resume", async () => { + const first = connection("original"); + const second = connection("current-default"); + expect( + await selectConversationId(first.connection, io, url, "session-1"), + ).toBe("original"); + expect( + await selectConversationId(second.connection, io, url, "session-2"), + ).toBe("current-default"); + expect( + await selectConversationId(second.connection, io, url, "session-1"), + ).toBe("original"); + expect(second.joinConversation).toHaveBeenCalledTimes(1); + }); + + it("ignores and preserves the legacy server-only binding, even when corrupt", async () => { + const legacy = join( + directory, + "conversation-bindings", + `${createHash("sha256").update(new URL(url).href).digest("hex")}.json`, + ); + await mkdir(join(directory, "conversation-bindings"), { + recursive: true, + }); + await writeFile(legacy, "{stale legacy data"); + expect( + await selectConversationId( + connection("current-default").connection, + io, + url, + ), + ).toBe("current-default"); + expect(await readFile(legacy, "utf8")).toBe("{stale legacy data"); + }); + + it("uses hook input identity over inherited environment and ignores hook-only storage injection", async () => { + process.env.COPILOT_AGENT_SESSION_ID = "outer-session"; + process.env.CLAUDE_PLUGIN_DATA = join(directory, "hook-only"); + expect( + await selectConversationId( + connection("hook-context").connection, + io, + url, + "inner-session", + ), + ).toBe("hook-context"); + delete process.env.CLAUDE_PLUGIN_DATA; + process.env.COPILOT_AGENT_SESSION_ID = "inner-session"; + const mcp = connection("changed-default"); + expect(await selectConversationId(mcp.connection, io, url)).toBe( + "hook-context", + ); + expect(mcp.joinConversation).not.toHaveBeenCalled(); + expect( + await readSelectedConversationId(url, "outer-session"), + ).toBeUndefined(); + }); + + it("uses process-local automatic context without host identity and writes no binding", async () => { + delete process.env.COPILOT_AGENT_SESSION_ID; + const selected = await Promise.all([ + selectConversationId(connection("first").connection, io, url), + selectConversationId(connection("second").connection, io, url), + ]); + expect(selected[0]).toBe(selected[1]); + expect( + await selectConversationId( + connection("changed").connection, + io, + url, + ), + ).toBe(selected[0]); + expect(await readdir(directory)).toEqual([]); + }); + + it("shares default storage across hook/MCP processes without hook-only plugin data", async () => { + const moduleUrl = new URL( + "../shared/conversation-selection.js", + import.meta.url, + ).href; + const child = async (hook: boolean) => { + const env: NodeJS.ProcessEnv = { + ...process.env, + USERPROFILE: directory, + HOME: directory, + }; + delete env.TYPEAGENT_PLUGIN_DATA; + if (hook) + env.CLAUDE_PLUGIN_DATA = join(directory, "hook-injected-data"); + else delete env.CLAUDE_PLUGIN_DATA; + const result = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `import { selectConversationId } from ${JSON.stringify(moduleUrl)}; + import { homedir } from "node:os"; + if (homedir() !== ${JSON.stringify(directory)}) throw new Error("Home isolation failed"); + const connection = { + joinConversation: async () => ({conversationId: ${JSON.stringify(hook ? "hook-default" : "new-default")}}), + leaveConversation: async () => {} + }; + console.log(await selectConversationId(connection, {}, ${JSON.stringify(url)}));`, + ], + { env, timeout: 15000 }, + ); + return result.stdout.trim(); + }; + expect(await child(true)).toBe("hook-default"); + expect(await child(false)).toBe("hook-default"); + }); + + it("shares public data but never transfers private owners to a new client in the same session", async () => { + const first = connection("shared", "owner-one"); + const second = connection("changed-default", "owner-two"); + const a = createStructuredActionClient(async () => first.connection); + const b = createStructuredActionClient(async () => second.connection); + try { + await a.searchActions({ query: "first" }); + await b.searchActions({ query: "second" }); + expect(a.binding.conversationId).toBe("shared"); + expect(b.binding.conversationId).toBe("shared"); + expect(second.joinConversation).toHaveBeenCalledWith( + expect.anything(), + { + conversationId: "shared", + structuredActions: {}, + }, + ); + const [file] = await readdir( + join(directory, "conversation-bindings", "sessions"), + ); + expect( + JSON.parse( + await readFile( + join( + directory, + "conversation-bindings", + "sessions", + file, + ), + "utf8", + ), + ), + ).toEqual({ conversationId: "shared" }); + } finally { + await a.close(); + await b.close(); + } + }); + + it("does not replace a missing automatic selection or dispatch through a fresh owner on reconnect", async () => { + const fake = connection("original"); + let disconnect: (() => void) | undefined; + const client = createStructuredActionClient(async (onDisconnect) => { + disconnect = onDisconnect; + return fake.connection; + }); + try { + await client.searchActions({ query: "first" }); + disconnect!(); + fake.joinConversation.mockRejectedValueOnce( + new Error("Conversation not found: original"), + ); + await expect( + client.searchActions({ query: "after deletion" }), + ).rejects.toMatchObject({ + dispatched: false, + reason: "conversation_not_found", + }); + expect(fake.joinConversation).toHaveBeenCalledTimes(3); + expect(fake.joinConversation).toHaveBeenLastCalledWith( + expect.anything(), + { + conversationId: "original", + structuredActions: { resumeToken: "never-persist-this" }, + }, + ); + expect(fake.searchActions).toHaveBeenCalledTimes(1); + expect(await readSelectedConversationId(url)).toBe("original"); + } finally { + await client.close(); + } + }); + + it.each(["denied", "cancelled", "uncertain"])( + "does not replay a %s structured execution", + async (outcome) => { + const fake = connection("original"); + if (outcome === "uncertain") + fake.executeAction.mockRejectedValueOnce( + new Error("Lost result"), + ); + else fake.executeAction.mockResolvedValueOnce({ status: outcome }); + const client = createStructuredActionClient( + async () => fake.connection, + ); + try { + const request = client.executeAction({ + protocolVersion: 1, + scopeId: "scope", + schemaName: "list", + actionName: "listLists", + parameters: {}, + }); + if (outcome === "uncertain") + await expect(request).rejects.toMatchObject({ + dispatched: true, + reason: "delivery_uncertain", + }); + else expect(await request).toEqual({ status: outcome }); + expect(fake.executeAction).toHaveBeenCalledTimes(1); + expect(fake.joinConversation).toHaveBeenCalledTimes(2); + } finally { + await client.close(); + } + }, + ); + + it("does not move an existing structured owner when another session selects a new default", async () => { + const original = connection("original"); + const client = createStructuredActionClient( + async () => original.connection, + ); + try { + await client.searchActions({ query: "initial" }); + await selectConversationId( + connection("new-default").connection, + io, + url, + "other-session", + ); + await client.executeAction({ + protocolVersion: 1, + scopeId: "scope", + schemaName: "list", + actionName: "listLists", + parameters: {}, + }); + expect(client.binding.conversationId).toBe("original"); + expect(original.executeAction).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + } + }); }); diff --git a/ts/packages/copilot-plugin/test/extensionSessionCapture.spec.ts b/ts/packages/copilot-plugin/test/extensionSessionCapture.spec.ts index c97208e72e..32dcc7e307 100644 --- a/ts/packages/copilot-plugin/test/extensionSessionCapture.spec.ts +++ b/ts/packages/copilot-plugin/test/extensionSessionCapture.spec.ts @@ -72,14 +72,17 @@ describe("extension session capture", () => { await capture.flush(); expect(mocks.insertToolHistory).toHaveBeenCalledTimes(1); - expect(mocks.insertToolHistory).toHaveBeenCalledWith({ - ...completion, - data: { - ...completion.data, - toolName: "fetch_data", - mcpServerName: "sample", + expect(mocks.insertToolHistory).toHaveBeenCalledWith( + { + ...completion, + data: { + ...completion.data, + toolName: "fetch_data", + mcpServerName: "sample", + }, }, - }); + "session-1", + ); }); it.each(["session.idle", "session.shutdown"])( diff --git a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts index a8fbb7242c..16f963c018 100644 --- a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts +++ b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts @@ -62,6 +62,15 @@ function createDependencies( } describe("Copilot dev actions hook", () => { + it("passes the hook session ID to the shared connector", async () => { + const { dependencies } = createDependencies({}); + await handleDevActions(input, dependencies); + expect(dependencies.connectToTypeAgent).toHaveBeenCalledWith( + expect.anything(), + input.sessionId, + ); + }); + it("uses the PowerShell schema family for ordinary prompts", () => { expect(getDevActionCommandOptions("show running processes")).toEqual({ activeSchemaFamilies: ["powershell"], diff --git a/ts/packages/copilot-plugin/test/hookDirect.spec.ts b/ts/packages/copilot-plugin/test/hookDirect.spec.ts index e558b2b3a2..099a42939c 100644 --- a/ts/packages/copilot-plugin/test/hookDirect.spec.ts +++ b/ts/packages/copilot-plugin/test/hookDirect.spec.ts @@ -79,6 +79,15 @@ function setDisplay(content: DisplayContent): EmitDisplay { const forced = { forceHandled: true }; describe("direct TypeAgent hook", () => { + it("passes the hook session ID rather than relying on inherited environment", async () => { + const { dependencies } = createDependencies({}); + await handleDirect(input, forced, dependencies); + expect(dependencies.connectToTypeAgent).toHaveBeenCalledWith( + expect.anything(), + input.sessionId, + ); + }); + it.each([false, true])( "preserves pending user input instead of claiming completion (forced: %s)", async (forceHandled) => { diff --git a/ts/packages/copilot-plugin/test/naturalLanguageBinding.spec.ts b/ts/packages/copilot-plugin/test/naturalLanguageBinding.spec.ts index eedaed755f..c1e1474ee8 100644 --- a/ts/packages/copilot-plugin/test/naturalLanguageBinding.spec.ts +++ b/ts/packages/copilot-plugin/test/naturalLanguageBinding.spec.ts @@ -32,10 +32,12 @@ describe("shared natural-language conversation selection", () => { TYPEAGENT_PLUGIN_DATA: process.env.TYPEAGENT_PLUGIN_DATA, TYPEAGENT_MODE: process.env.TYPEAGENT_MODE, TYPEAGENT_CONVERSATION_ID: process.env.TYPEAGENT_CONVERSATION_ID, + COPILOT_AGENT_SESSION_ID: process.env.COPILOT_AGENT_SESSION_ID, }; beforeEach(() => { directory = mkdtempSync(join(tmpdir(), "mixed-nl-binding-")); process.env.TYPEAGENT_PLUGIN_DATA = directory; + process.env.COPILOT_AGENT_SESSION_ID = "session-1"; delete process.env.TYPEAGENT_MODE; delete process.env.TYPEAGENT_CONVERSATION_ID; connectDispatcher.mockClear(); @@ -153,4 +155,47 @@ describe("shared natural-language conversation selection", () => { ); expect(connectAgentServer).toHaveBeenCalledTimes(1); }); + + it("lets a fresh session select the current default after another session loses its conversation", async () => { + const io = createClientIO({}); + await connectToTypeAgent(io, "old-session"); + connectDispatcher.mockRejectedValueOnce( + new Error("Conversation not found: default-first"), + ); + await expect(connectToTypeAgent(io, "old-session")).rejects.toThrow( + "Conversation not found", + ); + expect(connectAgentServer).toHaveBeenCalledTimes(1); + joinConversation.mockResolvedValue({ + conversationId: "current-default", + }); + await connectToTypeAgent(io, "new-session"); + expect(connectDispatcher).toHaveBeenLastCalledWith( + io, + expect.any(String), + { + filter: true, + clientType: "shell", + conversationId: "current-default", + }, + ); + expect(connectAgentServer).toHaveBeenCalledTimes(2); + }); + + it.each(["config", "environment"] as const)( + "never resolves a default for a missing explicit %s pin", + async (source) => { + if (source === "config") + writeConfig({ mode: "mcp", conversationId: "missing" }); + else process.env.TYPEAGENT_CONVERSATION_ID = "missing"; + connectDispatcher.mockRejectedValueOnce( + new Error("Conversation not found: missing"), + ); + await expect( + connectToTypeAgent(createClientIO({}), "fresh-session"), + ).rejects.toThrow("Conversation not found: missing"); + expect(connectAgentServer).not.toHaveBeenCalled(); + expect(connectDispatcher).toHaveBeenCalledTimes(1); + }, + ); });