mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Guard app-server client reuse by context
This commit is contained in:
parent
71feebcde0
commit
98ec2aa6a3
6 changed files with 60 additions and 3 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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")]))))
|
||||
|
|
|
|||
Loading…
Reference in a new issue