From d323b51a0a88b189891f41530cbc6b42f643b38f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 12:27:55 +0300 Subject: [PATCH] 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 --- .../server/lib/message-queue/DOCUMENTATION.md | 18 ++++-- .../web/server/lib/message-queue/runtime.js | 56 +++++++++++++++++-- .../server/lib/message-queue/runtime.test.js | 47 +++++++++++++++- 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/packages/web/server/lib/message-queue/DOCUMENTATION.md b/packages/web/server/lib/message-queue/DOCUMENTATION.md index 470bef7c..88f0116d 100644 --- a/packages/web/server/lib/message-queue/DOCUMENTATION.md +++ b/packages/web/server/lib/message-queue/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/message-queue/runtime.js b/packages/web/server/lib/message-queue/runtime.js index e040b079..cd8b0d1e 100644 --- a/packages/web/server/lib/message-queue/runtime.js +++ b/packages/web/server/lib/message-queue/runtime.js @@ -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 } }; diff --git a/packages/web/server/lib/message-queue/runtime.test.js b/packages/web/server/lib/message-queue/runtime.test.js index 509fb07e..6022e38c 100644 --- a/packages/web/server/lib/message-queue/runtime.test.js +++ b/packages/web/server/lib/message-queue/runtime.test.js @@ -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 () => {