summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/reducer.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 18:08:05 +0900
committerAdam Malczewski <[email protected]>2026-06-22 18:08:05 +0900
commitc95cc77b658edd072785d3ac93856de3ab9ad2ec (patch)
tree1a8c8a84c0c233ebe0aea0d9309ab3a84c4856be /src/core/chunks/reducer.ts
parent2a7708bd492a5a78794c76ee43355cabe786943e (diff)
downloaddispatch-web-dev.tar.gz
dispatch-web-dev.zip
fix: duplicate user message on first send in a new tabdev
When a draft is promoted to a real conversation, send() appends the user message as a provisional chunk (optimistic echo). But load() also fires syncTail, which fetches the CR-6 persisted user message as a committed chunk — showing the message twice until turn seal. Fix: applyHistory now removes provisional chunks that duplicate the last committed chunk (matching role + text content) when new committed chunks arrive during generation. The optimistic echo is dropped once the authoritative committed version arrives. 684 tests green.
Diffstat (limited to 'src/core/chunks/reducer.ts')
-rw-r--r--src/core/chunks/reducer.ts22
1 files changed, 22 insertions, 0 deletions
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 035846c..0783c22 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -54,8 +54,10 @@ export function applyHistory(
): TranscriptState {
const seqMap = new Map<number, StoredChunk>();
for (const c of state.committed) seqMap.set(c.seq, c);
+ let addedNew = false;
for (const c of chunks) {
if (c.seq < state.hiddenBeforeSeq) continue;
+ if (!seqMap.has(c.seq)) addedNew = true;
seqMap.set(c.seq, c);
}
const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq);
@@ -70,6 +72,26 @@ export function applyHistory(
};
}
+ // During generation: if new committed chunks arrived, the provisional
+ // array may contain duplicates — the optimistic echo from `appendUserMessage`
+ // is now backed by a committed chunk (CR-6: user message persisted at turn
+ // start). Remove provisional chunks that match the last committed chunk
+ // (role + chunk content), keeping only the accumulating (streaming) chunk.
+ if (addedNew && state.generating && state.provisional.length > 0) {
+ const lastCommitted = committed[committed.length - 1];
+ if (lastCommitted !== undefined) {
+ const provisional = state.provisional.filter((p) => {
+ if (p.role !== lastCommitted.role) return true;
+ if (p.chunk.type !== lastCommitted.chunk.type) return true;
+ if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") {
+ return p.chunk.text !== lastCommitted.chunk.text;
+ }
+ return true;
+ });
+ return { ...state, committed, provisional, accumulating: state.accumulating };
+ }
+ }
+
return { ...state, committed };
}