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.
|
the tick re-arms with backoff.
|
||||||
5. The head is marked in flight (broadcast), then sent:
|
5. The head is marked in flight (broadcast), then sent:
|
||||||
- text starting with `/` that names a command in OpenCode's `/command`
|
- text starting with `/` that names a command in OpenCode's `/command`
|
||||||
list (skills included) goes to `POST /session/:id/command` with the
|
list (skills included) and carries no captured context goes to
|
||||||
captured model, agent, variant, and file parts;
|
`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
|
- otherwise `POST /session/:id/prompt_async` with the parts in the same
|
||||||
order a UI send uses: text, files, the captured context, pending project
|
order a UI send uses: text, files, the captured context, the skill
|
||||||
knowledge (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic,
|
invocation when there is one, pending project knowledge
|
||||||
recorded as delivered only after the prompt is accepted), then the agent
|
(`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded
|
||||||
mention. The command path sends files and captured context as `parts`.
|
as delivered only after the prompt is accepted), then the agent mention.
|
||||||
Success removes the item, persists, broadcasts, and marks the user
|
Success removes the item, persists, broadcasts, and marks the user
|
||||||
message sent for notifications. Failure keeps the item, backs off
|
message sent for notifications. Failure keeps the item, backs off
|
||||||
2 s → 60 s (doubling per consecutive failure of that item), and re-arms.
|
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);
|
const name = head.slice(1);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const commands = asList(await openCodeFetch('/command', { directory })) ?? [];
|
const commands = asList(await openCodeFetch('/command', { directory })) ?? [];
|
||||||
if (!commands.some((command) => asRecord(command)?.name === name)) return null;
|
const match = commands.map(asRecord).find((command) => command?.name === name);
|
||||||
return { name, arguments: tail.join(' ') };
|
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) => ({
|
const toFilePart = (attachment) => ({
|
||||||
@@ -417,16 +445,31 @@ export function createMessageQueueRuntime({
|
|||||||
const { providerID, modelID, agent, variant } = item.sendConfig;
|
const { providerID, modelID, agent, variant } = item.sendConfig;
|
||||||
const fileParts = item.attachments.map(toFilePart);
|
const fileParts = item.attachments.map(toFilePart);
|
||||||
const contextParts = item.context.flatMap(toContextParts);
|
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);
|
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}` };
|
const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` };
|
||||||
if (agent) body.agent = agent;
|
if (agent) body.agent = agent;
|
||||||
if (variant) body.variant = variant;
|
if (variant) body.variant = variant;
|
||||||
const extraParts = [...fileParts, ...contextParts];
|
if (fileParts.length > 0) body.parts = fileParts;
|
||||||
if (extraParts.length > 0) body.parts = extraParts;
|
|
||||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
|
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
|
||||||
return;
|
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
|
// 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.
|
// 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
|
// 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.
|
// with them, then the standing context, then the mentioned agent.
|
||||||
const parts = [];
|
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(...fileParts);
|
||||||
parts.push(...contextParts);
|
parts.push(...contextParts);
|
||||||
|
parts.push(...commandParts);
|
||||||
if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true });
|
if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true });
|
||||||
if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention });
|
if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention });
|
||||||
const body = { model: { providerID, modelID } };
|
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();
|
const { runtime, openCode, emit } = createRuntime();
|
||||||
runtime.start();
|
runtime.start();
|
||||||
openCode.state.commands = [{ name: 'review' }];
|
openCode.state.commands = [{ name: 'review' }];
|
||||||
await runtime.enqueue(SESSION, DIRECTORY, item({
|
await runtime.enqueue(SESSION, DIRECTORY, item({
|
||||||
content: '/review',
|
content: '/review',
|
||||||
text: '/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' } } });
|
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||||
await settle();
|
await settle();
|
||||||
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
|
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 () => {
|
it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user