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
347 lines
15 KiB
JavaScript
347 lines
15 KiB
JavaScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { createMessageQueueRuntime, parseQueuedItemInput } from './runtime.js';
|
|
|
|
const SESSION = 'ses_queue_test_1';
|
|
const DIRECTORY = '/repo';
|
|
|
|
const item = (overrides = {}) => ({
|
|
content: 'follow up',
|
|
text: 'follow up',
|
|
attachments: [],
|
|
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
|
...overrides,
|
|
});
|
|
|
|
const tempDirs = [];
|
|
const makeDataDir = () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-message-queue-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
/**
|
|
* A fake OpenCode: status map, message tail, command list, and a log of every
|
|
* prompt/command it received.
|
|
*/
|
|
const createOpenCode = () => {
|
|
const state = {
|
|
statuses: {},
|
|
tail: [],
|
|
commands: [],
|
|
sent: [],
|
|
failNext: null,
|
|
};
|
|
const fetchImpl = vi.fn(async (url, init = {}) => {
|
|
const { pathname } = new URL(url);
|
|
const method = init.method ?? 'GET';
|
|
if (state.failNext && state.failNext.test(pathname)) {
|
|
state.failNext = null;
|
|
return new Response('boom', { status: 500 });
|
|
}
|
|
if (pathname === '/session/status') return Response.json(state.statuses);
|
|
if (pathname.endsWith('/message')) return Response.json(state.tail);
|
|
if (pathname === '/command') return Response.json(state.commands);
|
|
if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) {
|
|
state.sent.push({ path: pathname, body: JSON.parse(init.body) });
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
return { state, fetchImpl };
|
|
};
|
|
|
|
const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs } = {}) => {
|
|
let eventHandler = () => {};
|
|
let statusHandler = () => {};
|
|
const broadcasts = [];
|
|
const promptSent = [];
|
|
const options = {
|
|
globalEventHub: {
|
|
subscribeEvent(handler) { eventHandler = handler; return () => {}; },
|
|
subscribeStatus(handler) { statusHandler = handler; return () => {}; },
|
|
},
|
|
buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
sessionKnowledgeRuntime: knowledge,
|
|
broadcastGlobalUiEvent: (event) => broadcasts.push(event),
|
|
onPromptSent: (sessionId) => promptSent.push(sessionId),
|
|
dataDir,
|
|
fetchImpl: openCode.fetchImpl,
|
|
dispatchQuietMs: 0,
|
|
abortHoldMs: 50,
|
|
};
|
|
if (retryDelayMs) options.retryDelayMs = retryDelayMs;
|
|
const runtime = createMessageQueueRuntime(options);
|
|
return {
|
|
runtime,
|
|
openCode,
|
|
dataDir,
|
|
broadcasts,
|
|
promptSent,
|
|
emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
|
|
connect: () => statusHandler({ type: 'connect' }),
|
|
};
|
|
};
|
|
|
|
const settle = async (ms = 30) => {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
};
|
|
|
|
describe('parseQueuedItemInput', () => {
|
|
it('rejects an item the server could not deliver later', () => {
|
|
expect(() => parseQueuedItemInput({ content: 'x' })).toThrow(TypeError);
|
|
expect(() => parseQueuedItemInput(item({ content: '', text: '' }))).toThrow(TypeError);
|
|
expect(() => parseQueuedItemInput(item({ attachments: [{ filename: 'a.png' }] }))).toThrow(TypeError);
|
|
});
|
|
|
|
it('keeps delivery fields and trims blank edges of the content', () => {
|
|
const parsed = parseQueuedItemInput(item({ content: '\n\nhello\n', text: 'hello', agentMention: 'reviewer' }));
|
|
expect(parsed).toEqual({
|
|
content: 'hello',
|
|
text: 'hello',
|
|
agentMention: 'reviewer',
|
|
attachments: [],
|
|
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('message queue runtime', () => {
|
|
it('delivers the head of the queue when the session goes idle, in order', async () => {
|
|
const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime();
|
|
runtime.start();
|
|
openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
|
|
|
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' }));
|
|
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' }));
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
|
|
openCode.state.statuses = {};
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
|
|
expect(openCode.state.sent[0].body).toEqual({
|
|
model: { providerID: 'anthropic', modelID: 'claude' },
|
|
agent: 'build',
|
|
parts: [{ type: 'text', text: 'first' }],
|
|
});
|
|
expect(promptSent).toEqual([SESSION]);
|
|
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']);
|
|
// Clients learned about the in-flight item and then the removal.
|
|
expect(broadcasts.at(-1)).toMatchObject({
|
|
type: 'openchamber:message-queue.updated',
|
|
properties: { session: { sessionId: SESSION, sendingId: null } },
|
|
});
|
|
|
|
// The next turn: busy, then idle again — the second message goes out.
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } });
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(2);
|
|
expect(runtime.sessionSnapshot(SESSION).items).toEqual([]);
|
|
});
|
|
|
|
it('does not send into a running turn even when the status event says idle', async () => {
|
|
const { runtime, openCode, emit } = createRuntime();
|
|
runtime.start();
|
|
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }];
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
|
|
// The reply completes: that alone drains the queue (a missed idle event
|
|
// must not strand it).
|
|
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1, completed: 2 } } }];
|
|
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 1, completed: 2 } } } });
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
});
|
|
|
|
it('treats an unreachable OpenCode as unknown, not idle', async () => {
|
|
const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 });
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
openCode.state.failNext = /\/session\/status$/;
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle(5);
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
// Retried after the status fetch recovers.
|
|
await settle(40);
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps a failed item and retries with backoff', async () => {
|
|
const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 });
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
openCode.state.failNext = /prompt_async$/;
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle(10);
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
|
expect(runtime.sessionSnapshot(SESSION).sendingId).toBeNull();
|
|
expect(broadcasts.at(-1).properties.session.sendingId).toBeNull();
|
|
await settle(40);
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
|
});
|
|
|
|
it('holds delivery briefly after a user abort', async () => {
|
|
const { runtime, openCode, emit } = createRuntime();
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } });
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle(10);
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
await settle(80);
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
});
|
|
|
|
it('honors a hold until it is released', async () => {
|
|
const { runtime, openCode, emit } = createRuntime();
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
runtime.setHold(SESSION, true, 60_000);
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(0);
|
|
|
|
runtime.setHold(SESSION, false);
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
});
|
|
|
|
it('survives a restart and delivers once OpenCode reconnects', async () => {
|
|
const dataDir = makeDataDir();
|
|
const first = createRuntime({ dataDir });
|
|
first.runtime.start();
|
|
first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
|
await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' }));
|
|
await first.runtime.flush();
|
|
first.runtime.stop();
|
|
|
|
const second = createRuntime({ dataDir });
|
|
second.runtime.start();
|
|
await second.runtime.load();
|
|
expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']);
|
|
second.connect();
|
|
await settle();
|
|
expect(second.openCode.state.sent).toHaveLength(1);
|
|
expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]);
|
|
});
|
|
|
|
it('moves an unreadable queue file aside instead of treating it as empty', async () => {
|
|
const dataDir = makeDataDir();
|
|
fs.writeFileSync(path.join(dataDir, 'message-queue.json'), '{ not json');
|
|
const { runtime } = createRuntime({ dataDir });
|
|
await runtime.load();
|
|
expect(runtime.snapshot().sessions).toEqual([]);
|
|
expect(fs.readdirSync(dataDir).some((name) => name.startsWith('message-queue.json.corrupt-'))).toBe(true);
|
|
});
|
|
|
|
it('refuses to remove or take the item currently being sent', async () => {
|
|
const { runtime, openCode, emit } = createRuntime();
|
|
runtime.start();
|
|
let release;
|
|
// status map, message tail, then the prompt itself (held open until released)
|
|
openCode.fetchImpl.mockImplementationOnce(async () => Response.json({}))
|
|
.mockImplementationOnce(async () => Response.json([]))
|
|
.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); }));
|
|
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(runtime.sessionSnapshot(SESSION).sendingId).toBe(itemId);
|
|
|
|
await expect(runtime.remove(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
|
await expect(runtime.take(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
|
const taken = await runtime.takeAll(SESSION);
|
|
expect(taken.items).toEqual([]);
|
|
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
|
|
|
release();
|
|
await settle();
|
|
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
|
});
|
|
|
|
it('take hands back the full payload and leaves the rest queued', async () => {
|
|
const { runtime } = createRuntime();
|
|
runtime.start();
|
|
const attachment = { id: 'a1', filename: 'shot.png', mimeType: 'image/png', size: 3, source: 'local', dataUrl: 'data:image/png;base64,AAA=' };
|
|
const first = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'with image', attachments: [attachment] }));
|
|
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'plain' }));
|
|
|
|
expect(runtime.sessionSnapshot(SESSION).items[0].attachments[0]).not.toHaveProperty('dataUrl');
|
|
const taken = await runtime.take(SESSION, first.itemId);
|
|
expect(taken.item.attachments[0].dataUrl).toBe(attachment.dataUrl);
|
|
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['plain']);
|
|
|
|
const all = await runtime.takeAll(SESSION);
|
|
expect(all.items.map((entry) => entry.content)).toEqual(['plain']);
|
|
expect(runtime.snapshot().sessions).toEqual([]);
|
|
});
|
|
|
|
it('reorders only with a complete permutation', async () => {
|
|
const { runtime } = createRuntime();
|
|
runtime.start();
|
|
const a = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'a' }));
|
|
const b = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'b' }));
|
|
await expect(runtime.reorder(SESSION, [b.itemId])).rejects.toThrow(TypeError);
|
|
await runtime.reorder(SESSION, [b.itemId, a.itemId]);
|
|
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['b', 'a']);
|
|
});
|
|
|
|
it('drops the queue of a deleted session', async () => {
|
|
const { runtime, emit, broadcasts } = createRuntime();
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
emit({ type: 'session.deleted', properties: { info: { id: SESSION } } });
|
|
expect(runtime.snapshot().sessions).toEqual([]);
|
|
expect(broadcasts.at(-1).properties.session).toMatchObject({ sessionId: SESSION, items: [] });
|
|
});
|
|
|
|
it('dispatches a queued slash command through the command endpoint', async () => {
|
|
const { runtime, openCode, emit } = createRuntime();
|
|
runtime.start();
|
|
openCode.state.commands = [{ name: 'review' }];
|
|
await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review src', text: '/review src', sendConfig: { providerID: 'p', modelID: 'm', agent: 'build', variant: 'max' } }));
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(openCode.state.sent).toHaveLength(1);
|
|
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
|
|
expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
|
|
});
|
|
|
|
it('attaches pending project knowledge and records its delivery', async () => {
|
|
const recorded = [];
|
|
const knowledge = {
|
|
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
|
|
recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); },
|
|
};
|
|
const { runtime, openCode, emit } = createRuntime({ knowledge });
|
|
runtime.start();
|
|
await runtime.enqueue(SESSION, DIRECTORY, item({ agentMention: 'reviewer', attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }] }));
|
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
await settle();
|
|
expect(openCode.state.sent[0].body.parts).toEqual([
|
|
{ type: 'text', text: 'follow up' },
|
|
{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
|
|
{ type: 'text', text: 'pinned notes', synthetic: true },
|
|
{ type: 'agent', name: 'reviewer' },
|
|
]);
|
|
expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
|
|
});
|
|
});
|