diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 07:51:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 07:51:23 +0900 |
| commit | dbc3b36f6d94d719cb1a07074e5d74ce22d2fad3 (patch) | |
| tree | 8454bd10191ce1ccbe9740b5778ee1e9679f6339 /packages/frontend/tests | |
| parent | 8b9533c22a47bbf6f916667e2c25d8e8e419da37 (diff) | |
| download | dispatch-dbc3b36f6d94d719cb1a07074e5d74ce22d2fad3.tar.gz dispatch-dbc3b36f6d94d719cb1a07074e5d74ce22d2fad3.zip | |
fix(queue): consume queued messages after a turn ends (start a new turn)
A message queued while the agent was mid-turn was only handled if it
arrived DURING a tool batch (injected as a [USER INTERRUPT]). If it
landed after the last tool call — or the turn had no tools — the agent
silently appended it to history and ended the turn with no response, so
it sat there unanswered. This affected both user-queued messages and
agent-queued ones (send_to_tab).
- agent.ts: stop the end-of-turn drain that swallowed trailing queued
messages into history. They now stay on the queue.
- agent-manager: after a CLEAN turn settles, continueFromQueue() drains
the queue and starts a fresh turn to answer it. Skipped on a
user-stopped or errored turn (queue preserved for the next send).
- Loop safety: continuation draws from the existing autoWakeBudget, so a
runaway agent<->agent chain is bounded; human sends refill it, so human
conversations are never throttled.
- dequeueMessages now tags message-consumed with reason
"interrupt" | "continuation"; the frontend collapses continuation-
consumed queued bubbles into the next turn's initiator row (avoids the
linger/dup traps documented in queue-interrupt-reconcile-edge-cases.md).
- Tests: agent (no-swallow + interrupt regression), agent-manager
(continuation, no-op when empty, user-stop preserves queue, bounded
loop), frontend (continuation bubble becomes next initiator).
- wishlist: remove the now-fixed item.
Diffstat (limited to 'packages/frontend/tests')
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 82 |
1 files changed, 82 insertions, 0 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index b9d37f2..ea718eb 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1460,6 +1460,88 @@ describe("tabStore — chunk-native eviction / pagination / reconcile", () => { expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]); }); + it("a continuation-consumed queued message becomes the next turn's initiator", async () => { + // The turn-end fix: a message queued during turn-a is drained AFTER the + // turn ends (reason: "continuation") to START turn-b. Its optimistic + // `queued-` bubble must collapse into a single UNTAGGED user row so the + // imminent turn-b `turn-start` tags it as that turn's initiator — and it + // then folds cleanly into turn-b's sealed chunks (no linger, no dup). + const sealedB = [ + chunkRow("ua", "cc", 0, "turn-a", "user", "text", { text: "first" }), + chunkRow("aa", "cc", 1, "turn-a", "assistant", "text", { text: "first answer" }), + chunkRow("ub", "cc", 2, "turn-b", "user", "text", { text: "next please" }), + chunkRow("ab", "cc", 3, "turn-b", "assistant", "text", { text: "second answer" }), + ]; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.split("?")[0]?.endsWith("/tabs/cc/chunks")) + return Promise.resolve(chunksResponse(sealedB, 4)); + return Promise.reject(new Error(`unexpected ${url}`)); + }), + ); + const store = createTabStore(); + store.handleEvent({ + type: "tab-created", + id: "cc", + title: "CC", + keyId: null, + modelId: null, + parentTabId: null, + }); + // Turn A streams; user queues a follow-up while it runs. + store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "cc" }); + store.handleEvent({ type: "text-delta", delta: "first answer", tabId: "cc" }); + store.handleEvent({ + type: "message-queued", + tabId: "cc", + messageId: "q1", + message: "next please", + }); + let tab = store.tabs.find((t) => t.id === "cc"); + expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true); + + // Turn A ends. The backend drains the queue as a CONTINUATION (not an + // interrupt) and emits message-consumed{reason:"continuation"}. + store.handleEvent({ + type: "message-consumed", + tabId: "cc", + messageIds: ["q1"], + reason: "continuation", + }); + tab = store.tabs.find((t) => t.id === "cc"); + // The queued- bubble collapsed into ONE plain (untagged, un-prefixed) user row. + expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(false); + const initiator = tab?.live.find((m) => m.role === "user"); + expect(initiator).toBeTruthy(); + expect(initiator?.id.startsWith("queued-")).toBe(false); + expect(initiator?.turnId).toBeUndefined(); + expect(tab?.queuedMessages.some((m) => m.id === "q1")).toBe(false); + + // turn-a seals first (it was the running turn when the queue drained). + store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "cc" }); + // Now turn-b starts — it must tag the collapsed initiator row. + store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "cc" }); + tab = store.tabs.find((t) => t.id === "cc"); + const taggedInitiator = tab?.live.find((m) => m.role === "user" && m.turnId === "turn-b"); + expect(taggedInitiator).toBeTruthy(); + + store.handleEvent({ type: "text-delta", delta: "second answer", tabId: "cc" }); + store.handleEvent({ type: "turn-sealed", turnId: "turn-b", tabId: "cc" }); + await tick(); + tab = store.tabs.find((t) => t.id === "cc"); + // Both turns are durable; the live tail is empty (initiator folded into + // turn-b, no lingering/duplicated user bubble). + expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1, 2, 3]); + expect(tab?.live.length).toBe(0); + expect(tab?.renderGroups.map((m) => m.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + }); + it("preserves a concurrent newer turn when an earlier deferred reconcile flushes", async () => { const sealedA = [ chunkRow("ua", "c", 0, "turn-a", "user", "text", { text: "A?" }), |
