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
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 12:27:55 +03:00
parent 0b39efb0ae
commit d323b51a0a
3 changed files with 106 additions and 15 deletions
@@ -95,13 +95,19 @@ persisted "sending" flag would strand a message forever.
the tick re-arms with backoff.
5. The head is marked in flight (broadcast), then sent:
- text starting with `/` that names a command in OpenCode's `/command`
list (skills included) goes to `POST /session/:id/command` with the
captured model, agent, variant, and file parts;
list (skills included) and carries no captured context goes to
`POST /session/:id/command` with the captured model, agent, variant, and
file parts. That route accepts file parts only, so a command queued
**with** context takes the prompt route instead, the same rule the
composer applies: the command's template is expanded with its arguments
(`$ARGUMENTS`, `$1..$N`, or appended), a skill keeps its `/name args` text
and gets an explicit "the user invoked this skill" synthetic part after
the context;
- otherwise `POST /session/:id/prompt_async` with the parts in the same
order a UI send uses: text, files, the captured context, pending project
knowledge (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic,
recorded as delivered only after the prompt is accepted), then the agent
mention. The command path sends files and captured context as `parts`.
order a UI send uses: text, files, the captured context, the skill
invocation when there is one, pending project knowledge
(`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded
as delivered only after the prompt is accepted), then the agent mention.
Success removes the item, persists, broadcasts, and marks the user
message sent for notifications. Failure keeps the item, backs off
2 s → 60 s (doubling per consecutive failure of that item), and re-arms.
@@ -390,8 +390,36 @@ export function createMessageQueueRuntime({
const name = head.slice(1);
if (!name) return null;
const commands = asList(await openCodeFetch('/command', { directory })) ?? [];
if (!commands.some((command) => asRecord(command)?.name === name)) return null;
return { name, arguments: tail.join(' ') };
const match = commands.map(asRecord).find((command) => command?.name === name);
if (!match) return null;
return {
name,
arguments: tail.join(' '),
isSkill: match.source === 'skill',
template: asNonEmptyString(match.template),
};
};
/**
* The prompt a slash command stands for, expanded the way OpenCode expands
* it: `$ARGUMENTS` takes the whole argument string, `$1..$N` take quoted or
* bare words with the last position absorbing the rest, and a template with
* no placeholder gets the arguments appended. Twin of the UI's
* `expandSlashCommandGoalObjective` in `packages/ui/src/sync/session-ui-store.ts`.
*/
const expandCommandTemplate = (template, argumentsText) => {
if (template.includes('$ARGUMENTS')) return template.replaceAll('$ARGUMENTS', argumentsText);
const positions = [...template.matchAll(/\$(\d+)/g)].map((match) => Number(match[1]));
if (positions.length > 0) {
const parsed = [...argumentsText.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)]
.map((match) => match[1] ?? match[2] ?? match[3] ?? '');
const last = Math.max(...positions);
return template.replace(/\$(\d+)/g, (_match, value) => {
const position = Number(value);
return position === last ? parsed.slice(position - 1).join(' ') : (parsed[position - 1] ?? '');
});
}
return argumentsText ? `${template}\n\n${argumentsText}` : template;
};
const toFilePart = (attachment) => ({
@@ -417,16 +445,31 @@ export function createMessageQueueRuntime({
const { providerID, modelID, agent, variant } = item.sendConfig;
const fileParts = item.attachments.map(toFilePart);
const contextParts = item.context.flatMap(toContextParts);
// OpenCode's command route takes file parts only, so a command queued
// with captured context cannot go through it. Same rule as the composer:
// without context the command route keeps its semantics; with context the
// prompt route carries the expanded template (or the skill invocation as an
// explicit instruction) together with the context.
const command = await resolveSlashCommand(item.text, directory);
if (command) {
if (command && contextParts.length === 0) {
const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` };
if (agent) body.agent = agent;
if (variant) body.variant = variant;
const extraParts = [...fileParts, ...contextParts];
if (extraParts.length > 0) body.parts = extraParts;
if (fileParts.length > 0) body.parts = fileParts;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
return;
}
let text = item.text;
const commandParts = [];
if (command?.isSkill) {
commandParts.push({
type: 'text',
text: `The user explicitly invoked the ${command.name} skill. Use the corresponding skill tool to handle this request.`,
synthetic: true,
});
} else if (command?.template) {
text = expandCommandTemplate(command.template, command.arguments);
}
// Standing project context rides the prompt exactly as a UI send would
// attach it; a failed lookup sends without it rather than not at all.
@@ -437,9 +480,10 @@ export function createMessageQueueRuntime({
// Same order as a UI send: the user's text and files, the context queued
// with them, then the standing context, then the mentioned agent.
const parts = [];
if (item.text.trim()) parts.push({ type: 'text', text: item.text });
if (text.trim()) parts.push({ type: 'text', text });
parts.push(...fileParts);
parts.push(...contextParts);
parts.push(...commandParts);
if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true });
if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention });
const body = { model: { providerID, modelID } };
@@ -392,19 +392,60 @@ describe('message queue runtime', () => {
]);
});
it('sends captured context with a slash command too', async () => {
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',
context: [{ kind: 'synthetic', text: 'focus on tests' }],
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: 'text', text: 'focus on tests', synthetic: true }]);
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 () => {