Files
openchamber/packages/web/server/lib/message-queue/runtime.test.js
T
Bohdan Triapitsyn d323b51a0a fix(queue): deliver a queued slash command with context as a prompt
OpenCode's command route accepts file parts only. The server queue was
attaching captured context as text parts to POST /session/:id/command, so
a slash command queued with a comment, quote, or PR diff was rejected with
400 and retried forever.

A command queued without context still takes the command route with its
files. One queued with context now takes the prompt route the way the
composer does: the command's template is expanded with its arguments, a
skill keeps its text and gets the explicit skill-invocation instruction,
and the context rides along.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2026-09-05 12:27:55 +03:00

483 lines
22 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('keeps files on the command route, which is all that route accepts', async () => {
const { runtime, openCode, emit } = createRuntime();
runtime.start();
openCode.state.commands = [{ name: 'review' }];
await runtime.enqueue(SESSION, DIRECTORY, item({
content: '/review',
text: '/review',
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].path).toBe(`/session/${SESSION}/command`);
expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }]);
});
it('sends a command queued with context as its expanded prompt, context included', async () => {
// The command route rejects text parts, so a command with captured
// context takes the prompt route with the template expanded, exactly as
// the composer does.
const { runtime, openCode, emit } = createRuntime();
runtime.start();
openCode.state.commands = [{ name: 'review', source: 'command', template: 'Review $1 with focus on $2' }];
const metadata = { openchamberContext: { kind: 'chat-quote', quote: 'q', text: 'why?' } };
await runtime.enqueue(SESSION, DIRECTORY, item({
content: '/review src "error handling"',
text: '/review src "error handling"',
context: [{ kind: 'context', text: 'quoted', metadata }],
}));
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
await settle();
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
expect(openCode.state.sent[0].body.parts).toEqual([
{ type: 'text', text: 'Review src with focus on error handling' },
{ type: 'text', text: 'quoted', synthetic: true, metadata },
]);
});
it('sends a skill queued with context as an explicit invocation, context included', async () => {
const { runtime, openCode, emit } = createRuntime();
runtime.start();
openCode.state.commands = [{ name: 'grill', source: 'skill', template: 'skill body' }];
await runtime.enqueue(SESSION, DIRECTORY, item({
content: '/grill auth',
text: '/grill auth',
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}/prompt_async`);
expect(openCode.state.sent[0].body.parts).toEqual([
{ type: 'text', text: '/grill auth' },
{ type: 'text', text: 'focus on tests', synthetic: true },
{ type: 'text', text: 'The user explicitly invoked the grill skill. Use the corresponding skill tool to handle this request.', 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' }]);
});
});