diff --git a/.changeset/planning-stop-pending-initial-turn.md b/.changeset/planning-stop-pending-initial-turn.md new file mode 100644 index 0000000000..93455ac90c --- /dev/null +++ b/.changeset/planning-stop-pending-initial-turn.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stopping a plan now also cancels generations that haven't started streaming yet. +category: fix +dev: `stopGeneration` discards a still-pending initial turn (registered by start-streaming but not yet consumed by a stream connect) instead of returning false and letting the "stopped" generation restart on the next connect; stops remain strictly per-session when multiple plans generate concurrently. diff --git a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts index 2a3315a593..aacccc44da 100644 --- a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts @@ -103,4 +103,47 @@ describe("planning generation cancellation", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(promptResolvedAfterAbort).toBe(true); }); + + /* + FNXC:PlanningStopMultiSession 2026-07-23-23:50: + Stop must cancel a session whose initial turn is still PENDING (start-streaming registered + it, but no stream connect consumed it yet) and must never touch other sessions' generations. + Previously stop returned false here and the "stopped" turn sprang back to life on the next + stream connect. + */ + it("cancels a pending initial turn and leaves other sessions' generations untouched", async () => { + const prompt = vi.fn(async () => {}); + __setCreateFnAgent(vi.fn(async () => ({ + session: { state: { messages: [] }, prompt, dispose: vi.fn() }, + })) as any); + + const stoppedSessionId = await createSessionWithAgent( + "10.0.2.11", + "Plan that gets stopped before its stream connects", + "/tmp/project", + MOCK_TASK_STORE, + ); + const survivorSessionId = await createSessionWithAgent( + "10.0.2.12", + "Plan that keeps generating", + "/tmp/project", + MOCK_TASK_STORE, + ); + + expect(planningStreamManager.hasPendingInitialTurn(stoppedSessionId)).toBe(true); + expect(stopGeneration(stoppedSessionId)).toBe(true); + + // The discarded turn must not fire on a later stream connect. + expect(planningStreamManager.hasPendingInitialTurn(stoppedSessionId)).toBe(false); + expect(planningStreamManager.consumeInitialTurn(stoppedSessionId)).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + + // The other session's pending turn is untouched and still runs normally. + expect(planningStreamManager.hasPendingInitialTurn(survivorSessionId)).toBe(true); + planningStreamManager.consumeInitialTurn(survivorSessionId)?.(); + for (let i = 0; i < 20 && prompt.mock.calls.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(prompt).toHaveBeenCalled(); + }); });