diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 1b2663b..62cfabf 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -1785,10 +1785,24 @@ export class Session { // because the loop never exits. if (effectivePriority !== "later") this.#pendingMidTurnCount++; this.#activeRun.pushMidTurn(effectivePrompt, effectivePriority); - // Keep waiting_approval visible — the approval is still pending and - // every frontend keys its approval bar off it; the queued text is - // consumed after the user answers. - if (this.#status !== "waiting_approval") this.#setStatus("thinking"); + // Keep waiting_approval AND tool_running visible. Both are real states the + // push does not end, and both are load-bearing in two places: + // + // • Frontends key off them — the approval bar off waiting_approval, the + // running-tool card off tool_running. Flipping to "thinking" made the + // session header disagree with the tool card still showing "executing". + // • #watchdogPaused() keys off them. A tool that is genuinely executing + // is a LEGITIMATELY silent run, which is why the stall watchdog pauses + // there. Overwriting the status silently re-armed that watchdog against + // a healthy long tool — a big sweep or build would be force-recovered + // mid-flight at the stall window, taking the user's just-queued message + // down with it. Typing while a tool runs must never shorten its leash. + // + // Anything else (thinking, idle) becomes thinking: the push does start + // model work there. + if (this.#status !== "waiting_approval" && this.#status !== "tool_running") { + this.#setStatus("thinking"); + } this.#broadcastInfoUpdate(); return; } diff --git a/src/tests/session-integration.test.ts b/src/tests/session-integration.test.ts index fe71ab1..8884e4e 100644 --- a/src/tests/session-integration.test.ts +++ b/src/tests/session-integration.test.ts @@ -1706,6 +1706,107 @@ describe("T10 – send during waiting_approval", () => { await session.interrupt(TEST_AUTH); }); + it("a mid-turn push during a RUNNING tool leaves the status at tool_running", async () => { + // The reported symptom: type while Claude is working and the session goes + // quiet. A tool that is genuinely executing is a legitimately-silent state, + // and the stall watchdog pauses on `tool_running` for exactly that reason + // (#watchdogPaused). Flipping the status to "thinking" on a mid-turn push + // silently UNPAUSES it, so a long tool — a Snowflake sweep, a big build — + // gets force-recovered at the stall window with the user's queued message + // still in it. It also makes every frontend disagree with itself: the tool + // card says "executing" while the session header says "thinking". + // + // waiting_approval already had this carve-out (test above). tool_running is + // the same situation for the same reason. + const provider = new MockSessionProvider( + "claude", + [ + [ + { + type: "tool_start", + toolId: "long-sweep", + sdkToolUseId: "sdk-long-sweep", + name: "Read", + input: { file_path: "/big/repo/sweep-results.csv" }, + approvalId: "ap-long-sweep", + }, + ], + ], + { stall: true, midTurn: true }, + ); + const session = makeSession(provider, "t10-midturn-tool"); + const { client } = makeClient(); + session.attach(client); + + await session.send("run the sweep", TEST_AUTH); + await waitForStatus(session, "tool_running", 4000); + + // The user adds context while the tool is still running. + await session.send("also get me the per-client numbers", TEST_AUTH); + + // Queued into the live run, as designed… + expect(provider.midTurnPushes.length).toBe(1); + expect(provider.midTurnPushes[0]!.content).toContain("per-client numbers"); + expect(provider.capturedOpts.length).toBe(1); + expect(provider.teardownCount).toBe(0); + + // …and the tool is still executing, so the status must still say so — + // otherwise the watchdog starts counting against a healthy tool. + expect(session.status).toBe("tool_running"); + + await session.interrupt(TEST_AUTH); + }); + + it("a mid-turn push does not re-arm the stall watchdog against a running tool", async () => { + // The damage the status flip actually caused. The watchdog pauses while a + // tool executes because silence there is expected. Flipping to "thinking" + // on a mid-turn push re-armed it, so once the stall window lapsed the run + // was force-recovered — killing a healthy tool AND the message the user had + // just queued into it. Short stall window so the test can outlive it. + const provider = new MockSessionProvider( + "claude", + [ + [ + { + type: "tool_start", + toolId: "long-sweep-2", + sdkToolUseId: "sdk-long-sweep-2", + name: "Read", + input: { file_path: "/big/repo/huge.csv" }, + approvalId: "ap-long-sweep-2", + }, + ], + ], + { stall: true, midTurn: true }, + ); + const session = makeSession(provider, "t10-midturn-nostall", stallConfig(80)); + const { client, received } = makeClient(); + session.attach(client); + + await session.send("read the huge file", TEST_AUTH); + await waitForStatus(session, "tool_running", 4000); + + await session.send("and summarize the totals", TEST_AUTH); + expect(provider.midTurnPushes.length).toBe(1); + + // Sit well past the stall window with the tool still running. + await new Promise((r) => setTimeout(r, 400)); + + // The run must be untouched: no stall recovery, no provider teardown, no + // second turn started behind the user's back. + const stalledMsg = received.find( + (m) => + m.type === "session.message" && + /timed out|stalled/i.test((m as { content?: string }).content ?? ""), + ); + expect(stalledMsg).toBeUndefined(); + expect(provider.teardownCount).toBe(0); + expect(provider.capturedOpts.length).toBe(1); + expect(session.status).toBe("tool_running"); + + await session.interrupt(TEST_AUTH); + }); + it("rejects the send loudly on a backend without mid-turn injection (never auto-denies)", async () => { const provider = new MockSessionProvider("claude", approvalToolStart("ap-t10-b"), { stall: true,