|
| 1 | +import assert from "node:assert/strict" |
| 2 | +import { describe, it } from "node:test" |
| 3 | +import { createMemo, createRoot } from "solid-js" |
| 4 | +import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeSessionInbox, invalidateOpenCodeSessionContent } from "./opencode-data.ts" |
| 5 | +import { getRootClient } from "./opencode-client.ts" |
| 6 | +import { sdkManager } from "../lib/sdk-manager.ts" |
| 7 | + |
| 8 | +async function waitFor(done: () => boolean, message: string): Promise<void> { |
| 9 | + for (let turn = 0; turn < 500 && !done(); turn++) await new Promise<void>((resolve) => setTimeout(resolve, 0)) |
| 10 | + assert.ok(done(), message) |
| 11 | +} |
| 12 | + |
| 13 | +/** Replaces the session's reducer the way a pruning event or an overflowing event queue does. */ |
| 14 | +const triggers = { |
| 15 | + prune: (instanceId: string, sessionId: string) => invalidateOpenCodeSessionContent(instanceId, sessionId), |
| 16 | + overflow: (instanceId: string, sessionId: string) => { |
| 17 | + const step = (id: string, created: number) => applyOpenCodeDataEvent(instanceId, "/work", { |
| 18 | + id, type: "session.step.started", created, |
| 19 | + data: { sessionID: sessionId, assistantMessageID: id, agent: "build", model: { providerID: "provider", id: "model" } }, |
| 20 | + } as any) |
| 21 | + for (let index = 0; index < 199; index++) step(`seed-${index}`, 10 + index) |
| 22 | + for (let index = 0; index < 4096; index++) step(`overflow-${index}`, 500 + index) |
| 23 | + }, |
| 24 | +} |
| 25 | + |
| 26 | +describe("OpenCode session inbox", () => { |
| 27 | + for (const [name, replace] of Object.entries(triggers)) { |
| 28 | + it(`drops a delivered follow-up after a ${name} replaces the session reducer, before the turn ends`, async () => { |
| 29 | + const instanceId = `opencode-inbox-rebinding-${name}` |
| 30 | + const sessionId = "session" |
| 31 | + const client = getRootClient(instanceId) as any |
| 32 | + // The native inbox: what an authoritative resync lists. |
| 33 | + const native: any[] = [] |
| 34 | + client.session.get = async () => ({ |
| 35 | + id: sessionId, title: sessionId, projectID: "project", location: { directory: "/work" }, |
| 36 | + time: { created: 1, updated: 1 }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, |
| 37 | + }) |
| 38 | + client.session.active = async () => ({ [sessionId]: {} }) |
| 39 | + client.session.inbox = { list: async () => native.map((item) => ({ ...item })) } |
| 40 | + client.permission.list = async () => [] |
| 41 | + client.session.form.list = async () => [] |
| 42 | + client.message.list = async () => ({ data: [], cursor: {} }) |
| 43 | + const queue = (id: string, created: number) => { |
| 44 | + native.push({ id, sessionID: sessionId, type: "user", payload: { text: id }, delivery: "queue", time: { created } }) |
| 45 | + applyOpenCodeDataEvent(instanceId, "/work", { |
| 46 | + id: `enqueued-${id}`, type: "session.inbox.enqueued", created, |
| 47 | + data: { sessionID: sessionId, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "queue" } }, |
| 48 | + } as any) |
| 49 | + } |
| 50 | + const deliver = (id: string, created: number) => { |
| 51 | + native.splice(native.findIndex((item) => item.id === id), 1) |
| 52 | + applyOpenCodeDataEvent(instanceId, "/work", { |
| 53 | + id: `delivered-${id}`, type: "session.inbox.delivered", created, data: { sessionID: sessionId, inboxID: id }, |
| 54 | + } as any) |
| 55 | + } |
| 56 | + // The session view's queued badges read this memo. |
| 57 | + let queued!: () => string[] |
| 58 | + const dispose = createRoot((dispose) => { |
| 59 | + queued = createMemo(() => getOpenCodeSessionInbox(instanceId, sessionId, "/work").map((item) => item.id)) |
| 60 | + return dispose |
| 61 | + }) |
| 62 | + try { |
| 63 | + queue("first", 1) |
| 64 | + queue("second", 2) |
| 65 | + assert.deepEqual(queued(), ["first", "second"]) |
| 66 | + |
| 67 | + replace(instanceId, sessionId) |
| 68 | + await waitFor(() => queued().length === 2, "the resynced reducer still lists both follow-ups") |
| 69 | + await new Promise<void>((resolve) => setTimeout(resolve, 50)) |
| 70 | + assert.deepEqual(queued(), ["first", "second"]) |
| 71 | + |
| 72 | + deliver("first", 5000) |
| 73 | + assert.deepEqual(queued(), ["second"], "only the follow-up still waiting keeps its badge") |
| 74 | + deliver("second", 5001) |
| 75 | + assert.deepEqual(queued(), []) |
| 76 | + } finally { |
| 77 | + dispose() |
| 78 | + destroyOpenCodeData(instanceId) |
| 79 | + sdkManager.destroyClientsForInstance(instanceId) |
| 80 | + } |
| 81 | + }) |
| 82 | + } |
| 83 | +}) |
0 commit comments