Skip to content

Commit 2dd8905

Browse files
committed
test(app): cover full question recovery chain
End-to-end test wires snapshot reducer + clock + reverify against the same harness state to lock the recovery contract as a whole: edge into missingRunning arms a timer, server hydration on fire writes back and skips halt, transient list() failure recovers on the bounded follow-up, and snapshot flipping out of missingRunning before fire cancels cleanly. Refs #419.
1 parent 99b87fb commit 2dd8905

1 file changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createRoot, createSignal } from "solid-js"
3+
import type { Message, Part, QuestionRequest, ToolState } from "@opencode-ai/sdk/v2"
4+
import { createQuestionRecoveryClock, HEAL_DELAY_MS } from "./question-recovery-clock"
5+
import { resolveQuestionRecoverySnapshot, type QuestionRecoverySnapshot } from "./question-recovery-snapshot"
6+
import { questionRecoveryReverify, type ReverifyDeps } from "./question-recovery-reverify"
7+
8+
// End-to-end auto-heal chain: snapshot reducer + clock + reverify wired
9+
// against the same mutable harness state. Tests the recovery contract as a
10+
// whole — snapshot edge → clock arm → reverify → halt or hydrate — instead
11+
// of trusting that three correct pieces compose correctly. See #419.
12+
13+
interface FakeTimer {
14+
cb: () => void
15+
fireAt: number
16+
cancelled: boolean
17+
}
18+
19+
function fakeClock() {
20+
let nowMs = 0
21+
const timers: FakeTimer[] = []
22+
return {
23+
now: () => nowMs,
24+
advance(by: number) {
25+
nowMs += by
26+
for (const t of timers) {
27+
if (t.cancelled) continue
28+
if (t.fireAt <= nowMs) {
29+
t.cancelled = true
30+
t.cb()
31+
}
32+
}
33+
},
34+
setTimer: (cb: () => void, ms: number) => {
35+
const t: FakeTimer = { cb, fireAt: nowMs + ms, cancelled: false }
36+
timers.push(t)
37+
return t
38+
},
39+
clearTimer: (handle: unknown) => {
40+
;(handle as FakeTimer).cancelled = true
41+
},
42+
pending: () => timers.filter((t) => !t.cancelled).length,
43+
}
44+
}
45+
46+
const flush = async () => {
47+
for (let i = 0; i < 5; i++) await Promise.resolve()
48+
}
49+
50+
const message = (id: string): Message => ({ id }) as Message
51+
52+
const toolState = (status: ToolState["status"], input: Record<string, unknown> = {}): ToolState =>
53+
({
54+
status,
55+
input,
56+
title: "",
57+
metadata: {},
58+
time: { start: 0 },
59+
}) as ToolState
60+
61+
const runningQuestionPart = (callID: string, messageID: string): Part =>
62+
({
63+
id: callID,
64+
type: "tool",
65+
tool: "question",
66+
state: toolState("running", { id: "q1" }),
67+
callID,
68+
messageID,
69+
}) as Part
70+
71+
const SID = "ses_chain"
72+
const DIR = "/dir"
73+
74+
interface Harness {
75+
// Inputs that drive the snapshot reducer + reverify.
76+
setSyncQuestions: (q: ReadonlyArray<QuestionRequest>) => void
77+
setMessages: (m: ReadonlyArray<Message>) => void
78+
setParts: (p: Record<string, ReadonlyArray<Part>>) => void
79+
setBusy: (b: boolean) => void
80+
setActiveSid: (s: string | undefined) => void
81+
setDirectory: (d: string) => void
82+
setListImpl: (impl: () => Promise<readonly QuestionRequest[]>) => void
83+
setHaltImpl: (impl: () => Promise<unknown>) => void
84+
// Observed effects.
85+
haltCalls: string[]
86+
hydrationCalls: { sid: string; questions: readonly QuestionRequest[] }[]
87+
warnCalls: { message: string; payload: Record<string, unknown> }[]
88+
fk: ReturnType<typeof fakeClock>
89+
// Drive snapshot recompute on input change (production goes via memo).
90+
recompute: () => void
91+
dispose: () => void
92+
}
93+
94+
const setupChain = (initial?: {
95+
syncQuestions?: ReadonlyArray<QuestionRequest>
96+
messages?: ReadonlyArray<Message>
97+
parts?: Record<string, ReadonlyArray<Part>>
98+
busy?: boolean
99+
activeSid?: string
100+
directory?: string
101+
listImpl?: () => Promise<readonly QuestionRequest[]>
102+
haltImpl?: () => Promise<unknown>
103+
}): Harness => {
104+
const fk = fakeClock()
105+
const haltCalls: string[] = []
106+
const hydrationCalls: { sid: string; questions: readonly QuestionRequest[] }[] = []
107+
const warnCalls: { message: string; payload: Record<string, unknown> }[] = []
108+
109+
let setSync!: (q: ReadonlyArray<QuestionRequest>) => void
110+
let setMsgs!: (m: ReadonlyArray<Message>) => void
111+
let setPartsSig!: (p: Record<string, ReadonlyArray<Part>>) => void
112+
let setBusySig!: (b: boolean) => void
113+
let setSid!: (s: string | undefined) => void
114+
let setDir!: (d: string) => void
115+
let recompute!: () => void
116+
117+
let listImpl: () => Promise<readonly QuestionRequest[]> = initial?.listImpl ?? (async () => [])
118+
let haltImpl: () => Promise<unknown> =
119+
initial?.haltImpl ??
120+
(async () => {
121+
// default
122+
})
123+
124+
const dispose = createRoot((d) => {
125+
const [sync, sSync] = createSignal<ReadonlyArray<QuestionRequest>>(initial?.syncQuestions ?? [])
126+
const [msgs, sMsgs] = createSignal<ReadonlyArray<Message>>(initial?.messages ?? [])
127+
const [parts, sParts] = createSignal<Record<string, ReadonlyArray<Part>>>(initial?.parts ?? {})
128+
const [busy, sBusy] = createSignal(initial?.busy ?? true)
129+
const [sid, sSid] = createSignal<string | undefined>(initial?.activeSid ?? SID)
130+
const [dir, sDir] = createSignal(initial?.directory ?? DIR)
131+
const [snap, sSnap] = createSignal<QuestionRecoverySnapshot>({ kind: "none" })
132+
133+
setSync = sSync
134+
setMsgs = sMsgs
135+
setPartsSig = sParts
136+
setBusySig = sBusy
137+
setSid = sSid
138+
setDir = sDir
139+
140+
recompute = () => {
141+
const next = resolveQuestionRecoverySnapshot({
142+
sessionID: sid(),
143+
sessionTreeQuestionRequest: undefined,
144+
activeSessionSyncQuestions: sync(),
145+
activeSessionMessages: msgs(),
146+
partsByMessageID: parts(),
147+
})
148+
sSnap(next)
149+
clock.tick()
150+
}
151+
152+
const reverifyDeps: ReverifyDeps<QuestionRequest> = {
153+
snapshot: snap,
154+
activeSessionID: sid,
155+
activeDirectory: dir,
156+
isSessionBusy: () => busy(),
157+
listQuestions: () => listImpl(),
158+
messagesFor: () => msgs(),
159+
partsByMessageID: () => parts(),
160+
applyHydration: (s, qs) => hydrationCalls.push({ sid: s, questions: qs }),
161+
warn: (m, p) => warnCalls.push({ message: m, payload: p }),
162+
}
163+
164+
const clock = createQuestionRecoveryClock({
165+
snapshot: snap,
166+
activeSessionID: sid,
167+
activeDirectory: dir,
168+
halt: async (s) => {
169+
haltCalls.push(s)
170+
return haltImpl()
171+
},
172+
reverify: (s, ctx) => questionRecoveryReverify(reverifyDeps, s, ctx),
173+
now: fk.now,
174+
setTimer: fk.setTimer,
175+
clearTimer: fk.clearTimer,
176+
warn: (m, p) => warnCalls.push({ message: m, payload: p }),
177+
})
178+
179+
return d
180+
})
181+
182+
// Initialise snapshot once before tests interact.
183+
recompute()
184+
185+
return {
186+
setSyncQuestions: (q) => {
187+
setSync(q)
188+
recompute()
189+
},
190+
setMessages: (m) => {
191+
setMsgs(m)
192+
recompute()
193+
},
194+
setParts: (p) => {
195+
setPartsSig(p)
196+
recompute()
197+
},
198+
setBusy: setBusySig,
199+
setActiveSid: (s) => {
200+
setSid(s)
201+
recompute()
202+
},
203+
setDirectory: (d) => {
204+
setDir(d)
205+
recompute()
206+
},
207+
setListImpl: (impl) => {
208+
listImpl = impl
209+
},
210+
setHaltImpl: (impl) => {
211+
haltImpl = impl
212+
},
213+
haltCalls,
214+
hydrationCalls,
215+
warnCalls,
216+
fk,
217+
recompute,
218+
dispose,
219+
}
220+
}
221+
222+
describe("question recovery chain", () => {
223+
test("missingRunning edge → reverify (still uncovered) → halt fires", async () => {
224+
const h = setupChain()
225+
226+
// Drop into missingRunning: assistant message has a running question
227+
// part with no covering sync entry.
228+
h.setMessages([message("m1")])
229+
h.setParts({ m1: [runningQuestionPart("c1", "m1")] })
230+
expect(h.fk.pending()).toBe(1)
231+
232+
h.fk.advance(HEAL_DELAY_MS)
233+
await flush()
234+
expect(h.haltCalls).toEqual([SID])
235+
expect(h.hydrationCalls).toEqual([])
236+
h.dispose()
237+
})
238+
239+
test("server hydrates the missing question before fire → reverify writes it back, halt skipped", async () => {
240+
const covering = {
241+
id: "q1",
242+
sessionID: SID,
243+
tool: { messageID: "m1", callID: "c1" },
244+
} as unknown as QuestionRequest
245+
const h = setupChain()
246+
h.setMessages([message("m1")])
247+
h.setParts({ m1: [runningQuestionPart("c1", "m1")] })
248+
expect(h.fk.pending()).toBe(1)
249+
250+
// Server now reports the covering question — reverify will hydrate sync
251+
// and refuse to halt because the running part is no longer uncovered.
252+
h.setListImpl(async () => [covering])
253+
254+
h.fk.advance(HEAL_DELAY_MS)
255+
await flush()
256+
expect(h.haltCalls).toEqual([])
257+
expect(h.hydrationCalls).toHaveLength(1)
258+
expect(h.hydrationCalls[0]).toEqual({ sid: SID, questions: [covering] })
259+
h.dispose()
260+
})
261+
262+
test("transient list() failure → bounded retry → recovery on follow-up halts", async () => {
263+
let attempts = 0
264+
const h = setupChain()
265+
h.setListImpl(async () => {
266+
attempts++
267+
if (attempts === 1) throw new Error("server blip")
268+
return []
269+
})
270+
h.setMessages([message("m1")])
271+
h.setParts({ m1: [runningQuestionPart("c1", "m1")] })
272+
273+
h.fk.advance(HEAL_DELAY_MS)
274+
await flush()
275+
expect(attempts).toBe(1)
276+
expect(h.haltCalls).toEqual([])
277+
expect(h.fk.pending()).toBe(1)
278+
279+
h.fk.advance(HEAL_DELAY_MS)
280+
await flush()
281+
expect(attempts).toBe(2)
282+
expect(h.haltCalls).toEqual([SID])
283+
h.dispose()
284+
})
285+
286+
test("session leaves missingRunning before timer fires → halt skipped, no hydration", async () => {
287+
const covering = {
288+
id: "q1",
289+
sessionID: SID,
290+
tool: { messageID: "m1", callID: "c1" },
291+
} as unknown as QuestionRequest
292+
const h = setupChain()
293+
h.setMessages([message("m1")])
294+
h.setParts({ m1: [runningQuestionPart("c1", "m1")] })
295+
expect(h.fk.pending()).toBe(1)
296+
297+
// Sync receives the covering entry before the timer expires — snapshot
298+
// flips to "none" and the clock cancels its timer.
299+
h.setSyncQuestions([covering])
300+
expect(h.fk.pending()).toBe(0)
301+
302+
h.fk.advance(HEAL_DELAY_MS * 2)
303+
await flush()
304+
expect(h.haltCalls).toEqual([])
305+
expect(h.hydrationCalls).toEqual([])
306+
h.dispose()
307+
})
308+
})

0 commit comments

Comments
 (0)