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
@@ -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 } };