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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user