| 1 | import type { HistoryContentChunk, HistoryContentRef, HistoryEntry, HistoryMessage, HistorySlice, HistorySliceRequest } from "../../lib/types"; |
| 2 | function deferred<T>() { |
| 3 | let resolve!: (value: T) => void; |
| 4 | let reject!: (reason?: unknown) => void; |
| 5 | const promise = new Promise<T>((res, rej) => { |
| 6 | resolve = res; |
| 7 | reject = rej; |
| 8 | }); |
| 9 | return { promise, resolve, reject }; |
| 10 | } |
| 11 | |
| 12 | export type RefTable = Map<string, string>; // `${entryId}:${field}` -> full content |
| 13 | |
| 14 | export class FakeBackend { |
| 15 | sliceCalls: HistorySliceRequest[] = []; |
| 16 | contentCalls: Array<{ ref: HistoryContentRef; chunk: number }> = []; |
| 17 | sliceGate: ReturnType<typeof deferred<HistorySlice>> | undefined; |
| 18 | contentGate: ReturnType<typeof deferred<HistoryContentChunk>> | undefined; |
| 19 | staleNextCursor = false; |
| 20 | revision = 1; |
| 21 | digest = "digest-1"; |
| 22 | |
| 23 | constructor( |
| 24 | private readonly messages: HistoryMessage[], |
| 25 | private readonly refs: RefTable = new Map(), |
| 26 | private readonly sessionId = "s1", |
| 27 | ) {} |
| 28 | |
| 29 | private entryId(index: number): string { |
| 30 | return `${this.sessionId}:r0:m${index}:o0`; |
| 31 | } |
| 32 | |
| 33 | private entriesFor(lo: number, hi: number): HistoryEntry[] { |
| 34 | let turn = 0; |
| 35 | const turnsOf: number[] = []; |
| 36 | for (const message of this.messages) { |
| 37 | if (message.role === "user") turn += 1; |
| 38 | turnsOf.push(turn); |
| 39 | } |
| 40 | return this.messages.slice(lo, hi).map((message, offset) => { |
| 41 | const index = lo + offset; |
| 42 | const entryId = this.entryId(index); |
| 43 | const refs: HistoryContentRef[] = []; |
| 44 | let msg = message; |
| 45 | const full = this.refs.get(`${entryId}:content`); |
| 46 | if (full !== undefined) { |
| 47 | msg = { ...message, content: full.slice(0, 16) }; |
| 48 | refs.push({ entryId, field: "content", size: full.length, chunks: 2, revision: 1, digest: "d" }); |
| 49 | } |
| 50 | return { entryId, turn: turnsOf[index], order: index, message: msg, refs }; |
| 51 | }); |
| 52 | } |
| 53 | |
| 54 | slice(lo: number, hi: number): HistorySlice { |
| 55 | const entries = this.entriesFor(lo, hi); |
| 56 | const turns = entries.map((entry) => entry.turn).filter((value) => value > 0); |
| 57 | return { |
| 58 | entries, |
| 59 | nextCursor: lo > 0 ? btoa(JSON.stringify({ v: 1, before: lo })) : "", |
| 60 | hasOlder: lo > 0, |
| 61 | newerCursor: hi < this.messages.length ? btoa(JSON.stringify({ v: 1, after: hi })) : "", |
| 62 | hasNewer: hi < this.messages.length, |
| 63 | totalTurns: this.messages.filter((message) => message.role === "user").length, |
| 64 | startTurn: turns.length > 0 ? Math.min(...turns) : 0, |
| 65 | endTurn: turns.length > 0 ? Math.max(...turns) : 0, |
| 66 | stale: false, |
| 67 | revision: this.revision, |
| 68 | revisionKnown: true, |
| 69 | digest: this.digest, |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | // Turn- and entry-budgeted windowing, mirroring the Go slice semantics for |
| 74 | // the compact stress fixtures used below. |
| 75 | async HistorySliceForTab(_tabID: string, req: HistorySliceRequest): Promise<HistorySlice> { |
| 76 | this.sliceCalls.push(req); |
| 77 | if (this.sliceGate) { |
| 78 | const gate = this.sliceGate; |
| 79 | this.sliceGate = undefined; |
| 80 | return gate.promise; |
| 81 | } |
| 82 | if (req.newer) { |
| 83 | // Window paging toward newer history: `after` is an exclusive position. |
| 84 | const decoded = JSON.parse(atob(req.cursor)) as { after?: number }; |
| 85 | const lo = Math.min(this.messages.length, decoded.after ?? this.messages.length); |
| 86 | const entries = Math.max(1, Math.floor(req.entries || 120)); |
| 87 | return this.slice(lo, Math.min(this.messages.length, lo + entries)); |
| 88 | } |
| 89 | let before = this.messages.length; |
| 90 | if (req.cursor) { |
| 91 | if (this.staleNextCursor) return { entries: [], nextCursor: "", hasOlder: false, totalTurns: 0, startTurn: 0, endTurn: 0, stale: true, revision: this.revision, revisionKnown: true, digest: this.digest }; |
| 92 | const decoded = JSON.parse(atob(req.cursor)) as { before?: number }; |
| 93 | before = Math.min(before, decoded.before ?? before); |
| 94 | } |
| 95 | if (before <= 0 || this.messages.length === 0) return this.slice(0, 0); |
| 96 | let turn = 0; |
| 97 | const turnsOf: number[] = []; |
| 98 | for (const message of this.messages) { |
| 99 | if (message.role === "user") turn += 1; |
| 100 | turnsOf.push(turn); |
| 101 | } |
| 102 | const turns = Math.max(1, Math.floor(req.turns || 12)); |
| 103 | const newestTurn = turnsOf[before - 1]; |
| 104 | const oldestTurn = newestTurn > 0 ? Math.max(newestTurn - turns + 1, 1) : 0; |
| 105 | let lo = 0; |
| 106 | if (oldestTurn > 1) { |
| 107 | lo = before; |
| 108 | for (let i = 0; i < before; i += 1) { |
| 109 | if (turnsOf[i] >= oldestTurn) { lo = i; break; } |
| 110 | } |
| 111 | } |
| 112 | const entries = Math.max(1, Math.floor(req.entries || 120)); |
| 113 | lo = Math.max(lo, before - entries); |
| 114 | return this.slice(lo, before); |
| 115 | } |
| 116 | |
| 117 | async HistoryContentForTab(_tabID: string, ref: HistoryContentRef, chunkIndex: number): Promise<HistoryContentChunk> { |
| 118 | this.contentCalls.push({ ref, chunk: chunkIndex }); |
| 119 | if (this.contentGate) { |
| 120 | const gate = this.contentGate; |
| 121 | this.contentGate = undefined; |
| 122 | return gate.promise; |
| 123 | } |
| 124 | const full = this.refs.get(`${ref.entryId}:${ref.field}`) ?? ""; |
| 125 | const half = Math.ceil(full.length / 2); |
| 126 | const data = chunkIndex === 0 ? full.slice(0, half) : full.slice(half); |
| 127 | return { entryId: ref.entryId, field: ref.field, chunk: chunkIndex, chunks: 2, data, done: chunkIndex >= 1, stale: false }; |
| 128 | } |
| 129 | } |
| 130 |