diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 02:41:37 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 02:41:37 +0900 |
| commit | 1144a8027a3d0446e407f98c5cddc3a8c78831d5 (patch) | |
| tree | ac7d0039262cbc73ed418795524d17a16799ba47 /src/core | |
| parent | 1973da082ea69e123433e12a560cf1e3cbb04376 (diff) | |
| download | dispatch-web-1144a8027a3d0446e407f98c5cddc3a8c78831d5.tar.gz dispatch-web-1144a8027a3d0446e407f98c5cddc3a8c78831d5.zip | |
fix: optimistic user message echo + tabs persistence
Bug 1 (sent message didn't appear until turn end): the transcript only folded
assistant AgentEvents, so the user's own message showed only after turn-sealed
resync. Add core/chunks appendUserMessage() (provisional user chunk, superseded
on history sync) and call it in chat send() — the message now renders instantly.
Bug 2 (tabs didn't persist on refresh): the app passed { storage: undefined }
to createLocalStore, which the adapter treats as a no-op store, so nothing was
saved. Default to globalThis.localStorage. Regression test exercises the
non-injected path.
Also updated app store tests for the echo (assistant-vs-user chunk filtering).
Verified: svelte-check 0/0, vitest 288 (stable x2), biome clean, build ok.
Diffstat (limited to 'src/core')
| -rw-r--r-- | src/core/chunks/index.ts | 2 | ||||
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 63 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 16 |
3 files changed, 79 insertions, 2 deletions
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 36ba7f4..67739bc 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,4 +1,4 @@ -export { applyHistory, foldEvent, initialState } from "./reducer"; +export { appendUserMessage, applyHistory, foldEvent, initialState } from "./reducer"; export { selectChunks, selectMessages } from "./selectors"; export type { AccumulatingChunk, diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index f83edb4..b7165e4 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -11,7 +11,7 @@ import type { TurnUsageEvent, } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyHistory, foldEvent, initialState } from "./reducer"; +import { appendUserMessage, applyHistory, foldEvent, initialState } from "./reducer"; import { selectChunks, selectMessages } from "./selectors"; const turnStart = (turnId: string): TurnStartEvent => ({ @@ -404,3 +404,64 @@ describe("selectMessages", () => { expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" }); }); }); + +describe("appendUserMessage", () => { + it("adds a provisional user text chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hello from user"); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBeNull(); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" }); + expect(chunks[0]?.provisional).toBe(true); + }); + + it("selectMessages includes the optimistic user message", () => { + let s = initialState(); + s = appendUserMessage(s, "what is 2+2?"); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(1); + expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" }); + }); + + it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hi"); + expect(selectChunks(s)).toHaveLength(1); + + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello back")); + s = foldEvent(s, turnSealed("t1")); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "assistant", { type: "text", text: "hello back" }), + ]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" }); + expect(chunks[0]?.provisional).toBe(false); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.provisional).toBe(false); + }); + + it("flushes accumulating chunk before appending user message", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + + s = appendUserMessage(s, "user msg"); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.role).toBe("assistant"); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" }); + expect(s.provisional[1]?.role).toBe("user"); + expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" }); + }); +}); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 0a8ea54..d3b999d 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -166,3 +166,19 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript } } } + +/** + * Optimistically append a user message to the provisional list. + * Flushes any in-progress accumulating chunk first (defensively). + * The provisional user chunk is superseded when applyHistory receives + * the authoritative committed chunks after a turn seals. + */ +export function appendUserMessage(state: TranscriptState, text: string): TranscriptState { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const userChunk: Chunk = { type: "text", text }; + return { + ...state, + provisional: [...provisional, { role: "user", chunk: userChunk }], + accumulating: null, + }; +} |
