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
483 lines
22 KiB
JavaScript
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' }]);
|
|
});
|
|
});
|