feat(queue): deliver queued messages from the server
Messages queued while a session is busy used to live in the browser tab and were sent by that tab once the session went idle, so closing the tab (or losing the connection) stranded them. The web server now owns the queue: it persists to <data-dir>/message-queue.json, watches session.status on the global event hub, re-verifies idleness against OpenCode before sending, and delivers the head of the queue via prompt_async (or /command for slash commands) with the model, agent, variant, attachments, and agent mention captured at queue time. Failed sends stay queued and retry with backoff; a user abort holds delivery briefly; every change is broadcast so all clients see one queue. The shared UI store becomes a projection of the server queue outside VS Code (hydrate on connect, apply broadcasts, optimistic mutations settled on the server's copy, one-time upload of locally queued messages from older builds). Edit / send-now take the full message back from the server. A UI-driven auto-review run asks the server to hold that session's queue. VS Code keeps its local queue and foreground auto-send. Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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,
|
||||
attachments: [],
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
...extra,
|
||||
})
|
||||
|
||||
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", 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: [], 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 }],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
||||
})
|
||||
|
||||
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", createdAt: 1 }, { id: "q2", content: "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", createdAt: 1 }, { id: "q2", content: "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"] } })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user