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' }]); }); });