diff --git a/src/app-server/connection/connection-manager.ts b/src/app-server/connection/connection-manager.ts index 89bf6569..736ab7b5 100644 --- a/src/app-server/connection/connection-manager.ts +++ b/src/app-server/connection/connection-manager.ts @@ -13,6 +13,11 @@ export interface ConnectionManagerHandlers { export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient; +export interface AppServerConnectionContext { + codexPath: string; + cwd: string; +} + type ConnectionLifecycleState = | { kind: "idle"; generation: number } | { @@ -48,6 +53,13 @@ export class ConnectionManager { : null; } + currentConnectionContext(): AppServerConnectionContext | null { + if (this.state.kind !== "connected" || !this.state.client.isConnected() || !this.stateMatchesCurrentContext(this.state)) { + return null; + } + return { codexPath: this.state.codexPath, cwd: this.state.cwd }; + } + isConnected(): boolean { return Boolean(this.currentClient()); } diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 01f63824..03dbeb6e 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -1,5 +1,5 @@ import type { AppServerClient } from "../../../app-server/connection/client"; -import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys"; +import { appServerQueryContextMatches, appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys"; import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title"; import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; @@ -77,6 +77,20 @@ export class ChatPanelSession implements ChatSurfaceHandle { return this.graph.actions.refreshSharedThreads(); } + canServeAppServerContext(context: AppServerQueryContext): boolean { + const connectionContext = this.graph.connection.manager.currentConnectionContext(); + return Boolean( + connectionContext && + appServerQueryContextMatches( + { + codexPath: connectionContext.codexPath, + vaultPath: connectionContext.cwd, + }, + context, + ), + ); + } + async runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise { const client = this.graph.connection.manager.currentClient(); if (!client) throw new Error("Codex app-server is not connected."); diff --git a/src/features/chat/host/surface-handle.ts b/src/features/chat/host/surface-handle.ts index 70714497..814a3672 100644 --- a/src/features/chat/host/surface-handle.ts +++ b/src/features/chat/host/surface-handle.ts @@ -1,4 +1,5 @@ import type { AppServerClient } from "../../../app-server/connection/client"; +import type { AppServerQueryContext } from "../../../app-server/query/keys"; import type { ChatPanelSnapshot } from "../panel/snapshot"; export interface ChatViewLifecycleSurface { @@ -27,6 +28,7 @@ export interface ChatSharedThreadSurface { } export interface ChatPanelClientSurface { + canServeAppServerContext(context: AppServerQueryContext): boolean; runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise; } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index eb7ebe8a..5c5fc3c4 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -232,16 +232,17 @@ export class CodexPanelRuntime implements AppServerClientAccess { if (options.serverRequests?.kind === "reject") { return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options); } - const chatSurface = this.connectedClientSurface(); + const chatSurface = this.connectedClientSurface(context); if (chatSurface) return chatSurface.runWithAppServerClient(operation); return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options); } - private connectedClientSurface(): ChatPanelClientSurface | null { + private connectedClientSurface(context: AppServerQueryContext): ChatPanelClientSurface | null { for (const view of this.panels.panelViews()) { const workspaceSurface: ChatWorkspacePanelSurface = view.surface; if (!workspaceSurface.openPanelSnapshot().connected) continue; const clientSurface: ChatPanelClientSurface = view.surface; + if (!clientSurface.canServeAppServerContext(context)) continue; return clientSurface; } return null; diff --git a/tests/app-server/connection-manager.test.ts b/tests/app-server/connection-manager.test.ts index a17ab3b6..db0fa267 100644 --- a/tests/app-server/connection-manager.test.ts +++ b/tests/app-server/connection-manager.test.ts @@ -107,10 +107,12 @@ describe("ConnectionManager", () => { transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex-a" } }); await expect(first).resolves.toMatchObject({ codexHome: "/tmp/codex-a" }); expect(manager.currentClient()).toBeInstanceOf(AppServerClient); + expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-a", cwd: "/vault" }); codexPath = "/bin/codex-b"; expect(manager.currentClient()).toBeNull(); + expect(manager.currentConnectionContext()).toBeNull(); const second = manager.connect(silentConnectionHandlers()); transports[1]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex-b" } }); @@ -118,6 +120,7 @@ describe("ConnectionManager", () => { expect(transports).toHaveLength(2); expect(transports[0]?.running).toBe(false); expect(manager.currentClient()).toBeInstanceOf(AppServerClient); + expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-b", cwd: "/vault" }); }); it("rejects app-server exit during initialization without reporting a connected-client exit", async () => { diff --git a/tests/main.test.ts b/tests/main.test.ts index 34fe16d6..af0f6183 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -554,6 +554,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); + vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); let resolveThreads!: (threads: Thread[]) => void; const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockImplementation((operation) => operation( @@ -586,6 +587,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); + vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); let resolveFirst!: (threads: Thread[]) => void; const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient"); const plugin = await pluginWithLeaves([connectedLeaf]); @@ -619,12 +621,35 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("first")]); }); + it("does not reuse a connected panel whose app-server context does not match the shared query", async () => { + const { CodexChatView } = await import("../src/features/chat/host/view"); + const connectedLeaf = leaf(); + connectedLeaf.view = chatView(CodexChatView, connectedLeaf); + const connectedView = connectedLeaf.view as CodexChatView; + vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); + vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(false); + const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue([thread("wrong-context")]); + withShortLivedAppServerClientMock.mockImplementation( + (_codexPath: string, _vaultPath: string, operation: (client: ReturnType) => Promise) => + operation(threadListClient(() => Promise.resolve([thread("matching-context")]))), + ); + const plugin = await pluginWithLeaves([connectedLeaf]); + plugin.settings.codexPath = "codex-b"; + + await expect(threadCatalog(plugin).refreshActive()).resolves.toEqual([thread("matching-context")]); + + expect(runWithAppServerClient).not.toHaveBeenCalled(); + expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex-b", "/vault", expect.any(Function), {}); + expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("matching-context")]); + }); + it("keeps the previous shared thread list when refresh fails", async () => { const { CodexChatView } = await import("../src/features/chat/host/view"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); + vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); const runWithAppServerClient = vi .spyOn(connectedView.surface, "runWithAppServerClient") .mockImplementationOnce((operation) => operation(threadListClient(() => Promise.resolve([thread("cached")]))))