| 1 | import assert from "node:assert/strict"; |
| 2 | import test from "node:test"; |
| 3 | import { initialState, reducer, type Item } from "../lib/useController"; |
| 4 | import { ChatSource } from "../lib/chatViewSource"; |
| 5 | import { orderedLocalSubmissions } from "../lib/localSubmissionState"; |
| 6 | import type { TranscriptSnapshot } from "../lib/transcriptProtocol"; |
| 7 | |
| 8 | const user = (messageId: string, submissionId?: string): Item => ({ kind: "user", id: `m:${messageId}`, messageId, submissionId, text: "question" }); |
| 9 | const projection = (items: Item[]) => ({ items, removeIds: [], startTurn: 1, endTurn: 1, totalTurns: 1, |
| 10 | hasOlder: false, hasNewer: false, revision: 1, revisionKnown: true, digest: "cut" }); |
| 11 | const sent = () => reducer({ ...initialState, transcriptProtocol: 2 }, { type: "user", seq: 0, text: "question", submissionId: "send" }); |
| 12 | const event = (messageId = "durable") => ({ type: "event" as const, e: { kind: "user_message" as const, source: "executor" as const, submissionId: "send", messageId } }); |
| 13 | const input = { running: true, hydrating: false, hasOlder: false, loadingOlder: false }; |
| 14 | |
| 15 | test("a batched bound handoff reserves its displayed key before a conflicting canonical row", () => { |
| 16 | const source = new ChatSource("batched-conflict"); |
| 17 | let state = sent(); |
| 18 | source.update({ ...input, items: [], localSubmissions: orderedLocalSubmissions(state) }); |
| 19 | const original = source.getOrderSnapshot().find(key => source.getNodeSnapshot(key)?.kind === "user"); |
| 20 | state = reducer(state, event("bound")); |
| 21 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], |
| 22 | projection: projection([user("conflict", "send"), user("bound", "send")]) }); |
| 23 | source.update({ ...input, items: state.items, localSubmissions: orderedLocalSubmissions(state), visibleSubmissionHandoffs: state.visibleSubmissionHandoffs }); |
| 24 | const rows = source.getOrderSnapshot().map(key => source.getNodeSnapshot(key)).filter(node => node?.kind === "user"); |
| 25 | assert.equal(rows.find(node => node?.item.messageId === "bound")?.key, original); |
| 26 | assert.equal(rows.find(node => node?.item.messageId === "conflict")?.key, "m:conflict"); |
| 27 | source.dispose(); |
| 28 | }); |
| 29 | |
| 30 | test("offscreen formal confirmation retires an echo without changing the reader page", () => { |
| 31 | const old = user("old"); |
| 32 | const state = { ...sent(), items: [old], historyHasNewer: true }; |
| 33 | const action = { type: "transcript_records" as const, projection: { ...projection([old]), hasNewer: true }, |
| 34 | confirmedUsers: [{ messageId: "durable", submissionId: "send" }] }; |
| 35 | const next = reducer(state, action); |
| 36 | assert.equal(next.localSubmissionOrder.length, 0); |
| 37 | assert.equal(next.items[0], old); |
| 38 | assert.equal(next.localSubmissionSendRevision, state.localSubmissionSendRevision); |
| 39 | }); |
| 40 | |
| 41 | for (const order of ["erc", "ecr", "rec", "rce", "cer", "cre"]) test(`handoff permutation ${order}`, () => { |
| 42 | let state = sent(); |
| 43 | for (const step of order) { |
| 44 | state = step === "e" ? reducer(state, event()) : step === "c" ? reducer(state, { type: "send_confirmed", submissionId: "send" }) |
| 45 | : reducer(state, { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable", "send")]) }); |
| 46 | assert.equal(state.items.filter(x => x.kind === "user").length + state.localSubmissionOrder.length, 1); |
| 47 | } |
| 48 | assert.equal(state.items[0].id, "m:durable"); |
| 49 | }); |
| 50 | |
| 51 | test("records without submission identity are reconciled when the binding event arrives later", () => { |
| 52 | let state = reducer(sent(), { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable")]) }); |
| 53 | state = reducer(state, event()); |
| 54 | assert.equal(state.localSubmissionOrder.length, 0); |
| 55 | }); |
| 56 | |
| 57 | test("batched binding and formal install preserve the mounted display key", () => { |
| 58 | const source = new ChatSource("batched-handoff"); |
| 59 | let state = sent(); |
| 60 | source.update({ ...input, items: [], localSubmissions: orderedLocalSubmissions(state) }); |
| 61 | const before = source.getOrderSnapshot(); |
| 62 | state = reducer(reducer(state, event()), { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable")]) }); |
| 63 | const next = { ...input, items: state.items, localSubmissions: orderedLocalSubmissions(state), |
| 64 | visibleSubmissionHandoffs: (state as typeof state & { visibleSubmissionHandoffs: Record<string, { submissionId: string }> }).visibleSubmissionHandoffs }; |
| 65 | source.update(next); |
| 66 | assert.deepEqual(source.getOrderSnapshot(), before); |
| 67 | source.dispose(); |
| 68 | }); |
| 69 | |
| 70 | test("a bound echo cannot be rebound or hidden by a different message sharing the submission", () => { |
| 71 | let state = reducer(sent(), event("first")); |
| 72 | state = reducer(state, event("other")); |
| 73 | assert.equal(state.localSubmissions.send.messageId, "first"); |
| 74 | const source = new ChatSource("conflict"); |
| 75 | source.update({ ...input, items: [user("other", "send")], localSubmissions: orderedLocalSubmissions(state) }); |
| 76 | assert.equal(source.getOrderSnapshot().filter(key => source.getNodeSnapshot(key)?.kind === "user").length, 2); |
| 77 | source.dispose(); |
| 78 | }); |
| 79 | |
| 80 | test("a reclaimed anchor does not insert a pending bubble before unrelated history", () => { |
| 81 | const source = new ChatSource("missing-anchor"); |
| 82 | const state = reducer({ ...initialState, items: [user("old-anchor")] }, { type: "user", seq: 0, text: "question", submissionId: "send" }); |
| 83 | source.update({ ...input, items: [user("unrelated")], localSubmissions: orderedLocalSubmissions(state) }); |
| 84 | assert.equal(source.getOrderSnapshot().includes("submission:send"), false); |
| 85 | source.dispose(); |
| 86 | }); |
| 87 | |
| 88 | test("a session-start echo stays outside a window that no longer contains the start", () => { |
| 89 | const source = new ChatSource("reclaimed-start"); |
| 90 | source.update({ ...input, items: [user("later")], hasOlder: true, historyStartTurn: 500, |
| 91 | localSubmissions: orderedLocalSubmissions(sent()) }); |
| 92 | assert.equal(source.getOrderSnapshot().includes("submission:send"), false); |
| 93 | source.dispose(); |
| 94 | }); |
| 95 | |
| 96 | test("late management acknowledgement removes its own echo after request acceptance", () => { |
| 97 | let state = reducer(sent(), { type: "send_confirmed", submissionId: "send" }); |
| 98 | state = reducer(state, { type: "user", seq: 1, text: "next", submissionId: "next" }); |
| 99 | state = reducer(state, { type: "management_confirmed", submissionId: "send" }); |
| 100 | assert.equal(state.localSubmissions.send, undefined); |
| 101 | assert.ok(state.localSubmissions.next); |
| 102 | assert.equal(state.pendingSubmissionId, "next"); |
| 103 | assert.equal(state.running, true); |
| 104 | }); |
| 105 | |
| 106 | test("old rejection cannot stop a newer accepted submission awaiting its turn identity", () => { |
| 107 | let state = reducer(sent(), { type: "turn_submit_unknown", submissionId: "send", error: "timeout" }); |
| 108 | state = reducer(state, { type: "user", seq: 1, text: "next", submissionId: "next" }); |
| 109 | state = reducer(state, { type: "send_confirmed", submissionId: "next" }); |
| 110 | state = reducer(state, { type: "transcript_connection", status: "connected" }); |
| 111 | state = reducer(state, { type: "turn_submit_unknown", submissionId: "send", error: "late timeout" }); |
| 112 | assert.equal(state.transcriptConnection, "connected", "old timeout cannot disconnect the newer submission"); |
| 113 | state = reducer(state, { type: "send_failed", submissionId: "send", error: "late rejection" }); |
| 114 | assert.equal(state.localSubmissions.send.status, "failed"); |
| 115 | assert.equal(state.localSubmissions.next.status, "accepted"); |
| 116 | assert.equal(state.running, true); |
| 117 | }); |
| 118 | |
| 119 | for (const status of ["send_failed", "turn_submit_unknown"] as const) test(`${status} converges on a late formal record`, () => { |
| 120 | let state = reducer(sent(), { type: status, submissionId: "send", error: "fixture" }); |
| 121 | assert.equal(state.localSubmissions.send.status, status === "send_failed" ? "failed" : "unknown"); |
| 122 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable", "send")]) }); |
| 123 | assert.equal(state.localSubmissionOrder.length, 0); |
| 124 | state = reducer(state, { type: status, submissionId: "send", error: "late" }); |
| 125 | assert.equal(state.localSubmissionOrder.length, 0); |
| 126 | }); |
| 127 | |
| 128 | test("remove plus upsert is atomic and reclaimed rows cannot be revived by a content patch", () => { |
| 129 | let state = reducer(sent(), { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable", "send")]) }); |
| 130 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: { ...projection([user("durable", "send")]), removeIds: ["m:durable"] } }); |
| 131 | assert.equal(state.items.length, 1); |
| 132 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: { ...projection([]), removeIds: ["m:durable"] } }); |
| 133 | assert.equal(Object.keys(state.visibleSubmissionHandoffs).length, 0); |
| 134 | state = reducer(state, { type: "history_items_patch", patches: { "m:durable": user("durable", "send") } }); |
| 135 | assert.equal(state.items.length, 0); |
| 136 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable", "send")]) }); |
| 137 | assert.equal(state.items.length, 1); |
| 138 | assert.equal(Object.keys(state.visibleSubmissionHandoffs).length, 0); |
| 139 | }); |
| 140 | |
| 141 | test("a delayed user record precedes its already streaming answer", () => { |
| 142 | let state = reducer(sent(), { type: "event", e: { kind: "text", messageId: "answer", turnId: "turn", text: "answer" } }); |
| 143 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: projection([{ ...user("durable", "send"), turnId: "turn" }]) }); |
| 144 | assert.deepEqual(state.items.map(item => item.id), ["m:durable", "m:answer"]); |
| 145 | }); |
| 146 | |
| 147 | test("reset clears echoes and presentation mappings", () => { |
| 148 | let state = reducer(sent(), { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable", "send")]) }); |
| 149 | state = reducer(state, { type: "user", seq: 1, submissionId: "pending", text: "pending" }); |
| 150 | state = reducer(state, { type: "reset" }); |
| 151 | assert.equal(state.localSubmissionOrder.length, 0); |
| 152 | assert.equal(Object.keys(state.visibleSubmissionHandoffs).length, 0); |
| 153 | }); |
| 154 | |
| 155 | test("late unknown transport result cannot downgrade terminal delivery evidence", () => { |
| 156 | const state = reducer(sent(), { type: "event", e: { kind: "turn_done", submissionId: "send", checkpointTurn: 1 } }); |
| 157 | assert.equal(reducer(state, { type: "turn_submit_unknown", submissionId: "send", error: "late timeout" }), state); |
| 158 | }); |
| 159 | |
| 160 | test("a batch hands off once and retains all distinct formal identities", () => { |
| 161 | const state = reducer(sent(), { type: "transcript_records", confirmedUsers: [], |
| 162 | projection: projection([user("first", "send"), user("second", "send")]) }); |
| 163 | assert.deepEqual(state.items.map(item => item.id), ["m:first", "m:second"]); |
| 164 | assert.deepEqual(state.visibleSubmissionHandoffs, { first: { submissionId: "send" } }); |
| 165 | const source = new ChatSource("multiple-formal"); |
| 166 | source.update({ ...input, items: [], localSubmissions: orderedLocalSubmissions(sent()) }); |
| 167 | source.update({ ...input, items: state.items, visibleSubmissionHandoffs: state.visibleSubmissionHandoffs }); |
| 168 | assert.deepEqual(source.getOrderSnapshot().filter(key => source.getNodeSnapshot(key)?.kind === "user"), ["submission:send", "m:second"]); |
| 169 | source.dispose(); |
| 170 | }); |
| 171 | |
| 172 | test("legacy installs preserve handoffs for other resident formal messages", () => { |
| 173 | let state = reducer({ ...sent(), transcriptProtocol: undefined }, event()); |
| 174 | assert.deepEqual(state.visibleSubmissionHandoffs, { durable: { submissionId: "send" } }); |
| 175 | state = reducer(state, { type: "event", e: { kind: "user_message", source: "executor", messageId: "another", text: "other" } }); |
| 176 | assert.deepEqual(state.visibleSubmissionHandoffs, { durable: { submissionId: "send" } }); |
| 177 | }); |
| 178 | |
| 179 | test("binding and admission cannot erase a failed echo before its formal confirmation", () => { |
| 180 | let state = reducer(sent(), { type: "send_failed", submissionId: "send", error: "rejected" }); |
| 181 | state = reducer(state, event()); |
| 182 | state = reducer(state, { type: "turn_admitted", submissionId: "send", turnId: "turn" }); |
| 183 | assert.equal(state.localSubmissions.send.status, "failed"); |
| 184 | state = reducer(state, { type: "transcript_records", confirmedUsers: [], projection: projection([user("durable")]) }); |
| 185 | assert.equal(state.localSubmissionOrder.length, 0); |
| 186 | }); |
| 187 | |
| 188 | test("same-session recovery retains pending echoes, a different snapshot owner clears them", () => { |
| 189 | const snapshot: TranscriptSnapshot = { protocolVersion: 1, snapshotId: "snapshot", identity: { |
| 190 | sessionId: "session-a", runtimeEpoch: "runtime", rewriteEpoch: 0, headId: "" }, projectionRevision: 1, |
| 191 | coveredThroughSeq: 1, records: [], activeRecords: [], activeAttempts: [], runtime: { status: "completed", pendingEvents: [] }, |
| 192 | before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false }; |
| 193 | let state = reducer(initialState, { type: "transcript_snapshot", snapshot }); |
| 194 | state = reducer(state, { type: "user", seq: 0, submissionId: "send", text: "question" }); |
| 195 | state = reducer(state, { type: "transcript_snapshot", snapshot }); |
| 196 | assert.ok(state.localSubmissions.send); |
| 197 | const offscreenSnapshot = { ...snapshot, totalRecords: 1, records: [{ id: "m:durable", order: 0, refs: [], |
| 198 | message: { role: "user" as const, messageId: "durable", submissionId: "send", content: "question" } }] }; |
| 199 | const confirmed = reducer(state, { type: "transcript_v2_snapshot", snapshot: offscreenSnapshot, projection: projection([user("old")]) }); |
| 200 | assert.equal(confirmed.localSubmissionOrder.length, 0, "snapshot confirmations precede window filtering"); |
| 201 | assert.deepEqual(confirmed.items.map(item => item.id), ["m:old"]); |
| 202 | assert.equal(Object.keys(confirmed.visibleSubmissionHandoffs).length, 0); |
| 203 | const generation = state.sessionGen; |
| 204 | state = reducer(state, { type: "transcript_snapshot", snapshot: { ...snapshot, identity: { ...snapshot.identity, sessionId: "session-b" } } }); |
| 205 | assert.equal(state.localSubmissionOrder.length, 0); |
| 206 | assert.equal(Object.keys(state.visibleSubmissionHandoffs).length, 0); |
| 207 | assert.equal(state.pendingSubmissionId, undefined); |
| 208 | assert.equal(state.sessionGen, generation + 1); |
| 209 | assert.equal(state.running, false); |
| 210 | }); |
| 211 |