Files
openchamber/packages/web/server/lib/message-queue/runtime.test.js
T
Bohdan Triapitsyn 58dfc789a2 fix(queue): clear the queue card after the last queued message is sent
When the server delivered the last item of a session's queue it deleted
the session entry and broadcast the update with an empty directory. The
UI keys its projection by directory, could not build the key, and dropped
the event, so the delivered message stayed in the "Queued messages" card
until the next full hydration.

The server now remembers a session's directory beyond the emptying of its
queue so that broadcast names it. The UI additionally treats an empty
session without a directory as "this session's queue is done" for every
projection keyed under that session id, which covers clients talking to
an older server.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2026-09-05 11:21:39 +03:00

442 lines
20 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('names the directory in the broadcast that empties a queue', async () => {
// The UI keys its projection by directory; without it the client cannot
// tell which queue just delivered its last message and keeps showing it.
const { runtime, emit, broadcasts, openCode } = createRuntime();
runtime.start();
await runtime.enqueue(SESSION, DIRECTORY, item());
openCode.state.statuses = {};
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
await settle();
expect(openCode.state.sent).toHaveLength(1);
expect(runtime.snapshot().sessions).toEqual([]);
expect(broadcasts.at(-1).properties.session).toEqual({ sessionId: SESSION, directory: DIRECTORY, items: [], sendingId: null });
});
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' }]);
});
});