Guard app-server client reuse by context

This commit is contained in:
murashit 2026-06-22 16:34:51 +09:00
parent 71feebcde0
commit 98ec2aa6a3
6 changed files with 60 additions and 3 deletions

View file

@ -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());
}

View file

@ -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<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
const client = this.graph.connection.manager.currentClient();
if (!client) throw new Error("Codex app-server is not connected.");

View file

@ -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<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T>;
}

View file

@ -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;

View file

@ -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 () => {

View file

@ -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<typeof threadListClient>) => Promise<unknown>) =>
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")]))))