diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 18:34:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 18:34:26 +0900 |
| commit | 7f1381c4452846e5a2689d868ab0ee2bc90042c9 (patch) | |
| tree | d62ca06e1ab670ef02ffffa680f77310f9b89d23 | |
| parent | 254d3c2a1a564a96d455c61ae988eddcf89e1429 (diff) | |
| download | dispatch-7f1381c4452846e5a2689d868ab0ee2bc90042c9.tar.gz dispatch-7f1381c4452846e5a2689d868ab0ee2bc90042c9.zip | |
fix(conversation-store): msgIdx collision merges messages across turns + reconcile drops thinking-only messages
Root cause of tool-calls-in-thinking bug: append() assigns msgIdx as a LOCAL
index (reset to 0 per call), but load() grouped chunks by msgIdx alone. Since
the orchestrator persists messages one at a time (append([user]) at turn start,
then append([assistant, ...toolResults]) per step), all single-message appends
share msgIdx=0 and collapse into one giant user-role message. The model loses
its prior assistant responses and tool-call history, falls back to text-based
tool-call syntax inside reasoning_content, and the turn ends with finish_reason
stop (no structured tool_calls detected).
Fix 1 (store.ts load()): split message boundaries on role change too, not just
msgIdx. Handles the alternating user/assistant/tool pattern correctly.
Fix 2 (reconcile.ts hasContent): include thinking chunks as valid content so
thinking-only assistant messages are not silently dropped on load. The buggy
seq-14 output (assistant, thinking-only) was being deleted by reconcile,
destroying evidence of the bug.
Verified: load() on the affected conversation now produces 9 correct messages
(was 3 merged). All 1999 tests pass. See notes/tool-call-in-thinking-bug.md.
| -rw-r--r-- | packages/conversation-store/src/reconcile.test.ts | 14 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.ts | 2 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 62 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 10 |
4 files changed, 86 insertions, 2 deletions
diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts index b1926e9..25b47d5 100644 --- a/packages/conversation-store/src/reconcile.test.ts +++ b/packages/conversation-store/src/reconcile.test.ts @@ -417,4 +417,18 @@ describe("reconcile", () => { expect(report.repairedCount).toBe(1); expect(report.repairedToolCallIds).toEqual(["call_orph"]); }); + + it("reconcile preserves a thinking-only assistant message", () => { + // Regression: an assistant message with only a thinking chunk (no text, + // no tool-call) was being dropped by the hasContent check. Thinking IS + // valid content — the model's reasoning must survive a load/reconcile + // cycle so it appears in the conversation history. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "thinking", text: "just thinking..." }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual(messages); + expect(report.droppedEmptyMessages).toBe(0); + }); }); diff --git a/packages/conversation-store/src/reconcile.ts b/packages/conversation-store/src/reconcile.ts index 9023415..2d2b68d 100644 --- a/packages/conversation-store/src/reconcile.ts +++ b/packages/conversation-store/src/reconcile.ts @@ -46,7 +46,7 @@ export function reconcileWithReport(messages: readonly ChatMessage[]): Reconcile for (const msg of stripped) { if (msg.role === "assistant") { const hasContent = msg.chunks.some( - (chunk) => chunk.type === "text" || chunk.type === "tool-call", + (chunk) => chunk.type === "text" || chunk.type === "tool-call" || chunk.type === "thinking", ); if (!hasContent) { droppedEmptyMessages++; diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 6b12d0a..d336e0e 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -143,6 +143,68 @@ describe("ConversationStore", () => { expect(result).toEqual([...turn1, ...turn2]); }); + it("preserves message boundaries across single-message appends (orchestrator pattern)", async () => { + // Regression: the orchestrator persists messages one at a time — + // append([user]) at turn start, then append([assistant]) or + // append([assistant, ...toolResults]) via onStepComplete. Since each + // append() call assigns msgIdx starting at 0, single-message appends all + // share msgIdx=0. load() must split on role changes too, not just msgIdx, + // or messages from different turns collapse into one. + const store = createConversationStore(storage); + const user1: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + const asst1: ChatMessage = { + role: "assistant", + chunks: [ + { type: "thinking", text: "greeting" }, + { type: "text", text: "Hi!" }, + ], + }; + const user2: ChatMessage = { role: "user", chunks: [{ type: "text", text: "read a file" }] }; + const asst2: ChatMessage = { + role: "assistant", + chunks: [{ type: "text", text: "Sure." }], + }; + const user3: ChatMessage = { role: "user", chunks: [{ type: "text", text: "thanks" }] }; + + // Each message appended individually — the real orchestrator pattern. + await store.append("conv1", [user1]); + await store.append("conv1", [asst1]); + await store.append("conv1", [user2]); + await store.append("conv1", [asst2]); + await store.append("conv1", [user3]); + + const result = await store.load("conv1"); + expect(result).toEqual([user1, asst1, user2, asst2, user3]); + }); + + it("preserves message boundaries with single-message appends + multi-message step (tool calls)", async () => { + // Regression: the full orchestrator pattern including tool calls. + // Turn: append([user]) → step: append([assistant{thinking,text,tool-call}, toolResult]) + // All single-message appends get msgIdx=0; the multi-message step gets 0,1. + const store = createConversationStore(storage); + const user: ChatMessage = { role: "user", chunks: [{ type: "text", text: "do it" }] }; + const asst: ChatMessage = { + role: "assistant", + chunks: [ + { type: "thinking", text: "calling a tool" }, + { type: "text", text: "ok" }, + { type: "tool-call", toolCallId: "c1", toolName: "t", input: {} }, + ], + }; + const toolResult: ChatMessage = { + role: "tool", + chunks: [ + { type: "tool-result", toolCallId: "c1", toolName: "t", content: "done", isError: false }, + ], + }; + + await store.append("conv1", [user]); + await store.append("conv1", [asst, toolResult]); + + const result = await store.load("conv1"); + expect(result).toEqual([user, asst, toolResult]); + }); + it("preserves message ordering", async () => { const store = createConversationStore(storage); const messages: ChatMessage[] = []; diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index ed39d8e..41df92f 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -681,7 +681,15 @@ export function createConversationStore( continue; } - if (entry.msgIdx !== currentMsgIdx) { + // A message boundary is detected when EITHER the msgIdx changes OR the + // role changes. The msgIdx alone is insufficient because append() assigns + // it as a LOCAL index (reset to 0 for each call) — so consecutive + // single-message appends (e.g. the orchestrator's per-step persistence: + // append([user]) then append([assistant]) then append([user])...) all + // share msgIdx=0 and would collapse into one message without the role + // check. The role check restores correct boundaries for the common + // alternating user/assistant/tool pattern. + if (entry.msgIdx !== currentMsgIdx || entry.role !== currentRole) { if (currentMsgIdx >= 0 && currentRole !== undefined) { messages.push({ role: currentRole, chunks: currentChunks }); } |
