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:
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user