Files
openchamber/packages/ui/src/stores/messageQueueStore.server.test.ts
T
Bohdan Triapitsyn 34bf631157 feat(queue): queue a message with everything the composer had attached
Queueing captured only the text and files. Context chips (inline comments,
terminal selections, browser annotations, PR comments and checks, quotes,
linked issue/PR/Linear references, pending synthetic parts) stayed in the
composer and only left with the next manual send, so a queued message the
server delivered went out without them and the chips rode an unrelated
message later.

A queued message now carries what the composer would have sent: the text
with its agent mention stripped and file mentions resolved into
attachments, the attached context as structured parts, and the skill
instruction derived from the text. The server delivers those parts in the
composer's order, the VS Code auto-send does the same, and editing a queued
message puts the chips and linked references back. A failed queue restores
the composer completely. Snapshots and broadcasts omit the captured
context like attachment payloads; a take returns it.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
2026-09-04 22:56:27 +03:00

260 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { AttachedFile } from "./types/sessionTypes"
import type { MessageQueueUpdatedEvent } from "./messageQueueStore"
type FetchCall = { path: string; method: string; body: ReturnType<typeof JSON.parse> }
let calls: FetchCall[] = []
let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 })
mock.module("@/lib/runtime-fetch", () => ({
runtimeFetch: async (path: string, init?: RequestInit) => {
const call = {
path,
method: init?.method ?? "GET",
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
}
calls.push(call)
return respond(call)
},
}))
const desktop = await import("@/lib/desktop")
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false }))
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" }))
mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined }))
const {
applyMessageQueueUpdatedEvent,
createMessageQueueTarget,
getMessageQueueKey,
useMessageQueueStore,
} = await import("./messageQueueStore")
type ServerItem = MessageQueueUpdatedEvent["properties"]["session"]["items"][number]
type ServerSession = MessageQueueUpdatedEvent["properties"]["session"]
type ServerReply = {
revision: number
session?: ServerSession
sessions?: ServerSession[]
item?: ServerItem
items?: ServerItem[]
}
const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status })
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const key = getMessageQueueKey(target)
const serverItem = (id: string, content: string, extra: Partial<ServerItem> = {}): ServerItem => ({
id,
createdAt: 1,
content,
text: content,
attachments: [],
sendConfig: { providerID: "p", modelID: "m" },
...extra,
})
const issueMetadata = { openchamberContext: { kind: "github-issue" as const, number: 3, title: "Bug", url: "https://x/issues/3" } }
const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({
sessionId: "session-1",
directory: "/repo",
items,
sendingId,
})
const updated = (revision: number, updatedSession: ServerSession): MessageQueueUpdatedEvent => ({
type: "openchamber:message-queue.updated",
properties: { revision, session: updatedSession },
})
const attachment: AttachedFile = {
id: "att-1",
file: new File(["hi"], "note.txt", { type: "text/plain" }),
dataUrl: "data:text/plain;base64,aGk=",
mimeType: "text/plain",
filename: "note.txt",
size: 2,
source: "local",
}
beforeEach(() => {
calls = []
respond = () => json({ revision: 1, session: session([]) })
// Forgetting also drops the revision guard, so each test starts unordered.
useMessageQueueStore.getState().forgetQueue(target)
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
})
describe("server-owned message queue", () => {
// First: the one-time upload of a legacy local queue happens before this
// runtime is known to be server-owned, which the later hydrations establish.
test("hydrate uploads messages queued by an older build before reading the server", async () => {
useMessageQueueStore.setState({
queuedMessages: {
[key]: [{ id: "local-1", content: "from before", text: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }],
},
})
respond = (call) => (call.method === "POST"
? json({ revision: 2, session: session([serverItem("q1", "from before")]) })
: json({ revision: 2, sessions: [session([serverItem("q1", "from before")])] }))
await useMessageQueueStore.getState().hydrate()
expect(calls[0]).toEqual({
method: "POST",
path: "/api/message-queue/sessions/session-1/items",
body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], context: [], sendConfig: { providerID: "p", modelID: "m" } } },
})
expect(calls[1]?.path).toBe("/api/message-queue")
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
})
test("hydrate replaces the runtime's projection with the server queue", async () => {
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "hello")], "q1")] })
await useMessageQueueStore.getState().hydrate()
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue"])
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["hello"])
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
})
test("addToQueue shows the message at once and settles on the server's copy", async () => {
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
const pending = useMessageQueueStore.getState().addToQueue(target, {
content: "hi @reviewer",
text: "hi",
agentMention: "reviewer",
attachments: [attachment],
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
})
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
await pending
expect(calls[0]).toEqual({
method: "POST",
path: "/api/message-queue/sessions/session-1/items",
body: {
directory: "/repo",
item: {
content: "hi @reviewer",
text: "hi",
agentMention: "reviewer",
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }],
context: [],
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
},
},
})
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
})
test("addToQueue hands the captured context to the server, and a take brings it back", async () => {
const context = [
{ kind: "context" as const, text: "issue body", metadata: issueMetadata },
{ kind: "synthetic" as const, text: "conflict payload" },
]
respond = () => json({ revision: 6, session: session([serverItem("srv-1", "with context")]) })
await useMessageQueueStore.getState().addToQueue(target, {
content: "with context",
context,
sendConfig: { providerID: "p", modelID: "m" },
})
expect(calls[0]?.body.item.context).toEqual(context)
// The projection carries no context; the server strips payloads from snapshots.
expect(useMessageQueueStore.getState().queuedMessages[key]?.[0]?.context).toBe(undefined)
respond = () => json({ revision: 7, session: session([]), item: serverItem("srv-1", "with context", { context }) })
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "srv-1")
expect(taken?.context).toEqual(context)
expect(taken?.text).toBe("with context")
})
test("a server item with malformed context is rejected at the boundary", async () => {
respond = () => new Response(JSON.stringify({
revision: 8,
session: session([]),
item: { ...serverItem("srv-1", "x"), context: [{ kind: "context", text: "x", metadata: { openchamberContext: { kind: "nope" } } }] },
}), { status: 200 })
await expect(useMessageQueueStore.getState().takeForSend(target, "srv-1")).rejects.toThrow()
})
test("addToQueue rolls the optimistic entry back when the server refuses", async () => {
respond = () => new Response("nope", { status: 500 })
await expect(useMessageQueueStore.getState().addToQueue(target, {
content: "x",
sendConfig: { providerID: "p", modelID: "m" },
})).rejects.toThrow()
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
})
test("addToQueue refuses a message with no captured model", async () => {
await expect(useMessageQueueStore.getState().addToQueue(target, { content: "x" })).rejects.toThrow()
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
expect(calls).toHaveLength(0)
})
test("takeForSend brings the full message back, attachments included", async () => {
respond = () => json({
revision: 7,
session: session([]),
item: serverItem("q1", "with file", {
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: "data:text/plain;base64,aGk=" }],
}),
})
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "q1")
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/items/q1/take")
expect(calls[0]?.method).toBe("POST")
expect(taken?.content).toBe("with file")
expect(taken?.attachments?.[0]?.dataUrl).toBe("data:text/plain;base64,aGk=")
expect(taken?.attachments?.[0]?.file.size).toBe(2)
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
})
test("takeForSend without an id takes everything the server is not already sending", async () => {
respond = () => json({ revision: 8, session: session([serverItem("q1", "in flight")], "q1"), items: [serverItem("q2", "second")] })
const taken = await useMessageQueueStore.getState().takeForSend(target)
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/take")
expect(calls[0]?.method).toBe("POST")
expect(taken.map((m) => m.content)).toEqual(["second"])
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
})
test("broadcasts update the projection but never move it backwards", () => {
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
applyMessageQueueUpdatedEvent(updated(2, session([serverItem("q0", "older")])), "runtime-a")
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
applyMessageQueueUpdatedEvent(updated(9, session([serverItem("q1", "newer")])), "runtime-b")
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
})
test("removeFromQueue and clearQueue update locally and tell the server", async () => {
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } })
respond = () => json({ revision: 10, session: session([serverItem("q2", "b")]) })
useMessageQueueStore.getState().removeFromQueue(target, "q1")
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q2"])
await Promise.resolve()
await Promise.resolve()
expect(calls[0]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1/items/q1", body: undefined })
respond = () => json({ revision: 11, session: session([]) })
useMessageQueueStore.getState().clearQueue(target)
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
await Promise.resolve()
expect(calls[1]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1", body: undefined })
})
test("reorderQueue sends the complete new order", async () => {
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } })
respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) })
useMessageQueueStore.getState().reorderQueue(target, "q2", "q1")
await Promise.resolve()
expect(calls[0]).toEqual({ method: "PUT", path: "/api/message-queue/sessions/session-1/order", body: { itemIds: ["q2", "q1"] } })
})
})