Files
openchamber/packages/web/server/lib/message-queue/runtime.test.js
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

427 lines
19 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: [],
context: [],
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
});
});
it('keeps captured context and rejects a malformed part', () => {
const context = [
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } }, instructions: '' },
{ kind: 'instruction', text: 'use the skill' },
{ kind: 'synthetic', text: 'conflict payload' },
];
expect(parseQueuedItemInput(item({ context })).context).toEqual([
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } } },
{ kind: 'instruction', text: 'use the skill' },
{ kind: 'synthetic', text: 'conflict payload' },
]);
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'context', text: 'no metadata' }] }))).toThrow(TypeError);
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'other', text: 'x' }] }))).toThrow(TypeError);
});
it('accepts an item that is only context', () => {
const parsed = parseQueuedItemInput(item({ content: '', text: '', context: [{ kind: 'synthetic', text: 'just context' }] }));
expect(parsed.text).toBe('');
expect(parsed.context).toHaveLength(1);
});
});
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('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => {
const knowledge = {
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
recordDelivered: async () => {},
};
const { runtime, openCode, emit } = createRuntime({ knowledge });
runtime.start();
const metadata = { openchamberContext: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } };
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' }],
context: [
{ kind: 'context', text: 'the diff', metadata, instructions: 'how to read it' },
{ kind: 'synthetic', text: 'conflict payload' },
{ kind: 'instruction', text: 'use the skill' },
],
}));
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: 'how to read it', synthetic: true },
{ type: 'text', text: 'the diff', synthetic: true, metadata },
{ type: 'text', text: 'conflict payload', synthetic: true },
{ type: 'text', text: 'use the skill', synthetic: true },
{ type: 'text', text: 'pinned notes', synthetic: true },
{ type: 'agent', name: 'reviewer' },
]);
});
it('sends captured context with a slash command too', async () => {
const { runtime, openCode, emit } = createRuntime();
runtime.start();
openCode.state.commands = [{ name: 'review' }];
await runtime.enqueue(SESSION, DIRECTORY, item({
content: '/review',
text: '/review',
context: [{ kind: 'synthetic', text: 'focus on tests' }],
}));
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
await settle();
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'focus on tests', synthetic: true }]);
});
it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
const { runtime, broadcasts } = createRuntime();
runtime.start();
const context = [{ kind: 'synthetic', text: 'a large diff' }];
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ context }));
expect(runtime.sessionSnapshot(SESSION).items[0]).not.toHaveProperty('context');
expect(runtime.sessionSnapshot(SESSION).items[0].text).toBe('follow up');
expect(broadcasts.at(-1).properties.session.items[0]).not.toHaveProperty('context');
const taken = await runtime.take(SESSION, itemId);
expect(taken.item.context).toEqual(context);
});
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' }]);
});
});