From ae9ed6a252b7b40b57e2ff12b01c44ff4a906e28 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 12 Jul 2026 23:02:59 +0800 Subject: [PATCH] test: guard fork-while-mid-turn in the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork clicked while the model is still streaming should still fire — the daemon accepts session.fork mid-turn (it snapshots the history-so-far and the parent keeps streaming), and the fork button is not gated on turn status. Adds a SessionControls test that renders a focused session with status "thinking" (interrupt button armed to prove it's mid-turn) and asserts the fork button is enabled and dispatches session.fork. Extends the sess() helper with an optional status (defaults to "idle", so all existing cases are unchanged). Co-Authored-By: Claude Opus 4.8 --- web/src/components/SessionControls.test.tsx | 27 +++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/web/src/components/SessionControls.test.tsx b/web/src/components/SessionControls.test.tsx index e4849eb..b6fb219 100644 --- a/web/src/components/SessionControls.test.tsx +++ b/web/src/components/SessionControls.test.tsx @@ -26,12 +26,12 @@ import { } from "../state/sessions"; import type { SessionInfo } from "../protocol/types"; -function sess(providerId?: string): SessionInfo { +function sess(providerId?: string, status: SessionInfo["status"] = "idle"): SessionInfo { return { id: "s", name: "s", workdir: "/tmp", - status: "idle", + status, mode: "guarded", createdBy: "u", createdAt: "2026-05-04T08:00:00Z", @@ -355,6 +355,29 @@ describe("ForkButton", () => { expect(requestMock.mock.calls[0]![0]).not.toHaveProperty("providerId"); }); + it("forks WHILE the session is mid-turn (status=thinking) — not gated on the turn", async () => { + // Repro guard for "clicked fork while the model was streaming and nothing + // happened". The daemon accepts fork mid-turn (snapshots history-so-far, + // parent keeps streaming); the button must fire regardless of status. + requestMock.mockResolvedValueOnce({ id: "fork-mid", name: "s (fork)", providerId: "claude" }); + mockAuth(["claude"]); + ingestSessionList([sess("claude", "thinking")]); + focusSession("s"); + const { getByTitle } = render(() => ); + // Sanity: the session really is mid-turn — the interrupt button is enabled. + const interrupt = getByTitle(/Interrupt the running turn/) as HTMLButtonElement; + expect(interrupt.disabled).toBe(false); + // Fork must still fire (button is not gated on turn status). + const fork = getByTitle(PLAIN_TITLE) as HTMLButtonElement; + expect(fork.disabled).toBe(false); + fireEvent.click(fork); + await waitFor(() => + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ type: "session.fork", sessionId: "s" }), + ), + ); + }); + it("multi-backend daemon: dropdown offers fork-onto each OTHER backend", async () => { requestMock.mockResolvedValueOnce({ id: "fork-2", name: "s (fork)", providerId: "codex" }); mockAuth(["claude", "codex", "pi"]);