From 4b9575d6fbf1bae3b87f64560d3754025021b67c Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 21 Jun 2026 15:55:02 +0900 Subject: [PATCH] Track MCP elicitations in panel reuse guards --- src/features/chat/panel/snapshot.ts | 1 + src/workspace/panel-coordinator.ts | 1 + tests/features/threads-view/renderer.test.ts | 5 +++- tests/main.test.ts | 26 ++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/features/chat/panel/snapshot.ts b/src/features/chat/panel/snapshot.ts index 11f78f87..60af446d 100644 --- a/src/features/chat/panel/snapshot.ts +++ b/src/features/chat/panel/snapshot.ts @@ -9,6 +9,7 @@ export interface ChatPanelSnapshot { turnLifecycle: OpenCodexPanelTurnLifecycle; pendingApprovals: number; pendingUserInputs: number; + pendingMcpElicitations: number; hasComposerDraft: boolean; connected: boolean; } diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index 9686bdb3..4e2d5fe6 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -375,6 +375,7 @@ function isIdleEmptyPanelSnapshot(snapshot: ChatPanelSnapshot): boolean { snapshot.turnLifecycle.kind === "idle" && snapshot.pendingApprovals === 0 && snapshot.pendingUserInputs === 0 && + snapshot.pendingMcpElicitations === 0 && !snapshot.hasComposerDraft ); } diff --git a/tests/features/threads-view/renderer.test.ts b/tests/features/threads-view/renderer.test.ts index 11b42512..6092f6ce 100644 --- a/tests/features/threads-view/renderer.test.ts +++ b/tests/features/threads-view/renderer.test.ts @@ -22,6 +22,7 @@ function openPanelSnapshot( turnLifecycle: OpenCodexPanelSnapshot["turnLifecycle"]; pendingApprovals: number; pendingUserInputs: number; + pendingMcpElicitations: number; hasComposerDraft: boolean; connected: boolean; lastFocused: boolean; @@ -34,6 +35,7 @@ function openPanelSnapshot( turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, + pendingMcpElicitations: 0, hasComposerDraft: false, connected: true, ...overrides, @@ -91,10 +93,11 @@ describe("threads view renderer decisions", () => { openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), + openPanelSnapshot({ viewId: "mcp", threadId: "thread", pendingMcpElicitations: 1 }), ], new Map(), )[0]?.live, - ).toMatchObject({ status: "needs-input", label: "Needs input", viewId: "input", openPanels: 4 }); + ).toMatchObject({ status: "needs-input", label: "Needs input", viewId: "input", openPanels: 5 }); expect( threadRows( diff --git a/tests/main.test.ts b/tests/main.test.ts index d06dd24d..a78ec04c 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -113,6 +113,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, + pendingMcpElicitations: 0, hasComposerDraft: false, connected: true, }); @@ -140,6 +141,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, + pendingMcpElicitations: 0, hasComposerDraft: false, connected: true, }); @@ -152,6 +154,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, + pendingMcpElicitations: 0, hasComposerDraft: false, connected: true, }); @@ -163,6 +166,28 @@ describe("CodexPanelPlugin boot restored panel loading", () => { expect(openEmptyThread).toHaveBeenCalledWith("thread-1"); }); + it("does not reuse an idle panel with a pending MCP elicitation as empty", async () => { + const { CodexChatView } = await import("../src/features/chat/host/view"); + const pendingLeaf = leaf(); + pendingLeaf.view = chatView(CodexChatView, pendingLeaf); + const pendingView = pendingLeaf.view as CodexChatView; + vi.spyOn(pendingView.surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "pending-view", threadId: null, pendingMcpElicitations: 1 }), + ); + const openPendingThread = vi.spyOn(pendingView.surface, "openThread").mockResolvedValue(undefined); + const emptyLeaf = leaf(); + emptyLeaf.view = chatView(CodexChatView, emptyLeaf); + const emptyView = emptyLeaf.view as CodexChatView; + vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "empty-view", threadId: null })); + const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([pendingLeaf, emptyLeaf]); + + await panels(plugin).openThreadInAvailableView("thread-1"); + + expect(openPendingThread).not.toHaveBeenCalled(); + expect(openEmptyThread).toHaveBeenCalledWith("thread-1"); + }); + it("opens picker Enter selections in the most recent panel when the thread is not already open", async () => { const { CodexChatView } = await import("../src/features/chat/host/view"); const olderLeaf = leaf(); @@ -740,6 +765,7 @@ function panelSnapshot( turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, + pendingMcpElicitations: 0, hasComposerDraft: false, connected: true, ...overrides,