From b0028283fb7f3db08fa506e39501eca82ff8fbad Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 29 Jul 2026 00:47:29 +0300 Subject: [PATCH] feat: expand slash-command goals from command templates Resolves armed slash-command objectives from authoritative templates before dispatch Applies OpenCode argument expansion for goal metadata in UI and scheduled tasks Falls back to the raw invocation when command details are unavailable --- packages/ui/src/lib/opencode/client.ts | 5 +- packages/ui/src/sync/session-ui-store.test.js | 27 +++++++- packages/ui/src/sync/session-ui-store.ts | 66 ++++++++++++++++--- .../server/lib/openchamber-sessions/routes.js | 60 ++++++++--------- .../lib/openchamber-sessions/routes.test.js | 38 +++++++++++ .../web/server/lib/scheduled-tasks/runtime.js | 62 ++++++++++++----- .../lib/scheduled-tasks/runtime.test.js | 13 +++- .../server/lib/session-goal/DOCUMENTATION.md | 15 ++++- 8 files changed, 221 insertions(+), 65 deletions(-) diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 80fba7d6..ce89831e 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1580,9 +1580,10 @@ class OpencodeService { })); } - async listCommandsWithDetails(): Promise> { + async listCommandsWithDetails(directory?: string | null): Promise> { + const requestDirectory = this.normalizeCandidatePath(directory ?? null) ?? this.currentDirectory; const response = await this.client.command.list( - this.currentDirectory ? { directory: this.currentDirectory } : undefined + requestDirectory ? { directory: requestDirectory } : undefined ); const commands = unwrapSdkData(response, 'command.list'); // Return full command details including template diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index d813800e..468ff442 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -3,7 +3,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionWorktreeStore } from './session-worktree-store'; -import { routeMessage, useSessionUIStore } from './session-ui-store'; +import { expandSlashCommandGoalObjective, routeMessage, useSessionUIStore } from './session-ui-store'; import { setActionRefs, setOptimisticRefs } from './session-actions'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; @@ -228,6 +228,31 @@ describe('routeMessage directory scoping', () => { }); }); +describe('slash-command goal objectives', () => { + test('expands every $ARGUMENTS reference from the authoritative command template', () => { + expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{ + name: 'issue--to-pr', + template: 'Run the issue pipeline for $ARGUMENTS. Verify $ARGUMENTS is represented by the PR.', + }])).toBe('Run the issue pipeline for LIN-123 --draft. Verify LIN-123 --draft is represented by the PR.'); + }); + + test('keeps the invocation when the command template is unavailable', () => { + expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123', [{ name: 'issue--to-pr' }])) + .toBe('/issue--to-pr LIN-123'); + }); + + test('matches OpenCode positional and implicit argument expansion', () => { + expect(expandSlashCommandGoalObjective('/move "src old" dist extra', [{ + name: 'move', + template: 'Move $1 to $2', + }])).toBe('Move src old to dist extra'); + expect(expandSlashCommandGoalObjective('/review auth module', [{ + name: 'review', + template: 'Review the requested scope.', + }])).toBe('Review the requested scope.\n\nauth module'); + }); +}); + describe('runtime worktree topology', () => { test('restores independent in-memory maps across A -> B -> A', () => { const topologyA = new Map([['/repo', [{ path: '/repo/a', branch: 'a' }]]]); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 1e3d47b2..bd8b803d 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -73,6 +73,34 @@ import { rememberRuntimeLiveStatus } from "./runtime-live-memory" export type { AttachedFile } +type GoalCommand = { name: string; template?: string } + +export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string { + if (!content.startsWith("/")) return content + const [head, ...tail] = content.split(" ") + const command = commands.find((candidate) => candidate.name === head.slice(1)) + if (!command?.template?.trim()) return content + const argumentsText = tail.join(" ") + if (command.template.includes("$ARGUMENTS")) { + return command.template.replaceAll("$ARGUMENTS", argumentsText) + } + + const positions = [...command.template.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])) + if (positions.length > 0) { + const parsedArguments = [...argumentsText.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)] + .map((match) => match[1] ?? match[2] ?? match[3] ?? "") + const lastPosition = Math.max(...positions) + return command.template.replace(/\$(\d+)/g, (_match, value: string) => { + const position = Number(value) + return position === lastPosition + ? parsedArguments.slice(position - 1).join(" ") + : (parsedArguments[position - 1] ?? "") + }) + } + + return argumentsText ? `${command.template}\n\n${argumentsText}` : command.template +} + // --------------------------------------------------------------------------- // Send routing — shell mode, slash commands, or normal prompt // --------------------------------------------------------------------------- @@ -1052,15 +1080,33 @@ export const useSessionUIStore = create()((set, get) => ({ ) additionalParts = [...(additionalParts ?? []), { text: goalIntro, synthetic: true }] } - const applyArmedGoal = (goalSessionId: string, goalDirectory: string | null | undefined) => { + const applyArmedGoal = async (goalSessionId: string, goalDirectory: string | null | undefined) => { if (!goalArmed) return const uiState = useUIStore.getState() const tokenBudget = uiState.sessionGoalDefaultBudgetEnabled ? uiState.sessionGoalDefaultBudget : null - const objective = goalArm.objectiveOverride?.trim() || content - void setSessionGoal(goalSessionId, goalDirectory ?? undefined, { objective, tokenBudget }, null) - .catch((error) => { - console.warn("[session-ui-store] failed to set goal from armed send", error) - }) + let objective = goalArm.objectiveOverride?.trim() || content + if (!goalArm.objectiveOverride && content.startsWith("/")) { + const directoryCommands = getDirectoryState(goalDirectory ?? undefined)?.command ?? [] + const storedCommands = useCommandsStore.getState().commands + const knownCommands = [...directoryCommands, ...storedCommands] + objective = expandSlashCommandGoalObjective(content, knownCommands) + if (objective === content) { + try { + objective = expandSlashCommandGoalObjective( + content, + await opencodeClient.listCommandsWithDetails(goalDirectory), + ) + } catch { + // Command dispatch remains authoritative; raw invocation is a safe objective fallback. + } + } + } + try { + await setSessionGoal(goalSessionId, goalDirectory ?? undefined, { objective, tokenBudget }, null) + } catch (error) { + useSessionGoalArmStore.getState().setArmed(true, goalArm.objectiveOverride) + throw error + } } // ---- New session from draft ---- @@ -1088,6 +1134,7 @@ export const useSessionUIStore = create()((set, get) => ({ filename: a.filename, })) + await applyArmedGoal(createdDraftSession.sessionId, createdDraftSession.directory) await routeMessage({ sessionId: createdDraftSession.sessionId, directory: createdDraftSession.directory, @@ -1111,7 +1158,6 @@ export const useSessionUIStore = create()((set, get) => ({ })), })), }) - applyArmedGoal(createdDraftSession.sessionId, createdDraftSession.directory) return } @@ -1168,6 +1214,9 @@ export const useSessionUIStore = create()((set, get) => ({ filename: a.filename, })) + if (targetSessionId) { + await applyArmedGoal(targetSessionId, currentSessionDirectory) + } await routeMessage({ sessionId: targetSessionId || "", directory: currentSessionDirectory, @@ -1191,9 +1240,6 @@ export const useSessionUIStore = create()((set, get) => ({ })), })), }) - if (targetSessionId) { - applyArmedGoal(targetSessionId, currentSessionDirectory) - } }, // --------------------------------------------------------------------------- diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index e58dead4..79918817 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -2,7 +2,7 @@ import express from 'express'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import { createWorktree } from '../git/index.js'; import { expandSnippets } from '../opencode/snippets.js'; -import { parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js'; +import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js'; import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js'; import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js'; @@ -369,13 +369,27 @@ export const createOpenChamberSessionService = (dependencies) => { } const expandedPrompt = expandSnippets(prompt, directory); + const parsedCommand = parseScheduledCommandPrompt(prompt); + let resolvedCommand = null; + if (parsedCommand) { + try { + const response = await client.command.list({ directory }); + const commands = Array.isArray(response?.data) ? response.data : []; + const command = commands.find((candidate) => candidate?.name === parsedCommand.command); + if (command) resolvedCommand = { ...parsedCommand, template: command.template }; + } catch { + } + } if (goalInput.enabled) { + const commandObjective = resolvedCommand + ? expandCommandGoalObjective(resolvedCommand.template, resolvedCommand.arguments) + : null; await (createSessionGoalOverride || createSessionGoal)({ baseUrl, authHeaders, sessionID, directory, - objective: expandedPrompt, + objective: commandObjective ?? expandedPrompt, tokenBudget: goalInput.tokenBudget, providerID: model.providerID, modelID: model.modelID, @@ -388,35 +402,21 @@ export const createOpenChamberSessionService = (dependencies) => { return error; }; - let dispatchedAsCommand = false; - const parsedCommand = parseScheduledCommandPrompt(prompt); - if (parsedCommand) { - let commandExists = false; + if (resolvedCommand) { try { - const response = await client.command.list({ directory }); - const commands = Array.isArray(response?.data) ? response.data : []; - commandExists = commands.some((command) => command?.name === parsedCommand.command); - } catch { + await client.session.command({ + sessionID, + directory, + command: resolvedCommand.command, + arguments: resolvedCommand.arguments, + ...(agent ? { agent } : {}), + model: `${model.providerID}/${model.modelID}`, + ...(variant ? { variant } : {}), + }); + } catch (error) { + throw markGoalPartial(error); } - if (commandExists) { - try { - await client.session.command({ - sessionID, - directory, - command: parsedCommand.command, - arguments: parsedCommand.arguments, - ...(agent ? { agent } : {}), - model: `${model.providerID}/${model.modelID}`, - ...(variant ? { variant } : {}), - }); - } catch (error) { - throw markGoalPartial(error); - } - dispatchedAsCommand = true; - } - } - - if (!dispatchedAsCommand) { + } else { try { await runPromptAsync({ baseUrl, @@ -440,7 +440,7 @@ export const createOpenChamberSessionService = (dependencies) => { } } - return { model, agent, variant, promptDispatched: true, dispatchedAsCommand }; + return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) }; }; const create = async (payload = {}) => { diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index 8b19bf83..e781fc62 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -361,6 +361,44 @@ describe('openchamber session routes', () => { } }); + it('uses the expanded slash-command template as the goal objective before command dispatch', async () => { + const originalFetch = globalThis.fetch; + const createSessionGoal = vi.fn(async () => undefined); + commandListMock.mockResolvedValue({ + data: [{ + name: 'issue--to-pr', + template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.', + }], + }); + globalThis.fetch = vi.fn(); + try { + const { app } = createApp({ createSessionGoal }); + const response = await request(app) + .post('/api/openchamber/sessions/ses_source/send') + .send({ + directory: '/repo/app', + prompt: '/issue--to-pr LIN-123', + model: 'openai/gpt-5.5', + agent: 'build', + goal: true, + }) + .expect(200); + + expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({ + objective: 'Take LIN-123 from issue through a verified pull request. Confirm the PR covers LIN-123.', + })); + expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({ + command: 'issue--to-pr', + arguments: 'LIN-123', + })); + expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]); + expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('reuses the previous session selection when send omits model, agent, and variant', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' })); diff --git a/packages/web/server/lib/scheduled-tasks/runtime.js b/packages/web/server/lib/scheduled-tasks/runtime.js index 74c20de8..5b1cffc7 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.js @@ -94,6 +94,32 @@ export const parseScheduledCommandPrompt = (prompt) => { }; }; +export const expandCommandGoalObjective = (template, argumentsText) => { + if (typeof template !== 'string' || !template.trim()) { + return null; + } + + const rawArguments = String(argumentsText ?? ''); + if (template.includes('$ARGUMENTS')) { + return template.replaceAll('$ARGUMENTS', rawArguments); + } + + const positions = [...template.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])); + if (positions.length > 0) { + const parsedArguments = [...rawArguments.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)] + .map((match) => match[1] ?? match[2] ?? match[3] ?? ''); + const lastPosition = Math.max(...positions); + return template.replace(/\$(\d+)/g, (_match, value) => { + const position = Number(value); + return position === lastPosition + ? parsedArguments.slice(position - 1).join(' ') + : (parsedArguments[position - 1] ?? ''); + }); + } + + return rawArguments ? `${template}\n\n${rawArguments}` : template; +}; + export const computeNextRunAt = (task, nowMs = Date.now()) => { if (!task?.enabled) { return null; @@ -445,10 +471,10 @@ export const createScheduledTasksRuntime = (deps) => { } }; - const runScheduledCommandIfApplicable = async ({ client, projectPath, sessionID, task }) => { + const resolveScheduledCommand = async ({ client, projectPath, task }) => { const parsed = parseScheduledCommandPrompt(task?.execution?.prompt); if (!parsed) { - return false; + return null; } let commands = []; @@ -456,25 +482,24 @@ export const createScheduledTasksRuntime = (deps) => { const response = await client.command.list({ directory: projectPath }); commands = Array.isArray(response?.data) ? response.data : []; } catch { - return false; + return null; } - const hasMatchingCommand = commands.some((command) => command?.name === parsed.command); - if (!hasMatchingCommand) { - return false; - } + const command = commands.find((candidate) => candidate?.name === parsed.command); + return command ? { ...parsed, template: command.template } : null; + }; + const runScheduledCommand = async ({ client, projectPath, sessionID, task, command }) => { await client.session.command({ sessionID, directory: projectPath, - command: parsed.command, - arguments: parsed.arguments, + command: command.command, + arguments: command.arguments, ...(task.execution.agent ? { agent: task.execution.agent } : {}), model: `${task.execution.providerID}/${task.execution.modelID}`, ...(task.execution.variant ? { variant: task.execution.variant } : {}), }); - return true; }; const runTaskWithWatchdog = async (projectID, task, reason) => { @@ -527,13 +552,18 @@ export const createScheduledTasksRuntime = (deps) => { } } + const scheduledCommand = await resolveScheduledCommand({ client, projectPath, task }); + if (task.execution.goalEnabled) { + const commandObjective = scheduledCommand + ? expandCommandGoalObjective(scheduledCommand.template, scheduledCommand.arguments) + : null; await createSessionGoal({ baseUrl, authHeaders, sessionID, directory: projectPath, - objective: expandSnippets(task.execution.prompt, projectPath), + objective: commandObjective ?? expandSnippets(task.execution.prompt, projectPath), tokenBudget: task.execution.goalTokenBudget, providerID: task.execution.providerID, modelID: task.execution.modelID, @@ -541,13 +571,9 @@ export const createScheduledTasksRuntime = (deps) => { }); } - const executedAsCommand = await runScheduledCommandIfApplicable({ - client, - projectPath, - sessionID, - task, - }); - if (!executedAsCommand) { + if (scheduledCommand) { + await runScheduledCommand({ client, projectPath, sessionID, task, command: scheduledCommand }); + } else { await runPromptAsync({ baseUrl, authHeaders, diff --git a/packages/web/server/lib/scheduled-tasks/runtime.test.js b/packages/web/server/lib/scheduled-tasks/runtime.test.js index 5b718c4b..7dafaca9 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.test.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; +import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; describe('scheduled-tasks runtime helpers', () => { it('computes next daily run in timezone', () => { @@ -97,4 +97,15 @@ describe('scheduled-tasks runtime helpers', () => { expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull(); expect(parseScheduledCommandPrompt('/')).toBeNull(); }); + + it('expands command arguments into the goal objective', () => { + expect(expandCommandGoalObjective( + 'Run the issue pipeline for $ARGUMENTS. Verify $ARGUMENTS is represented by the PR.', + 'LIN-123 --draft', + )).toBe('Run the issue pipeline for LIN-123 --draft. Verify LIN-123 --draft is represented by the PR.'); + expect(expandCommandGoalObjective(undefined, 'LIN-123')).toBeNull(); + expect(expandCommandGoalObjective('Move $1 to $2', '"src old" dist extra')).toBe('Move src old to dist extra'); + expect(expandCommandGoalObjective('Review the requested scope.', 'auth module')) + .toBe('Review the requested scope.\n\nauth module'); + }); }); diff --git a/packages/web/server/lib/session-goal/DOCUMENTATION.md b/packages/web/server/lib/session-goal/DOCUMENTATION.md index f12329bc..23427c84 100644 --- a/packages/web/server/lib/session-goal/DOCUMENTATION.md +++ b/packages/web/server/lib/session-goal/DOCUMENTATION.md @@ -160,6 +160,12 @@ sees only that final turn, so the report is its evidence. colors/labels shared across surfaces. - `stores/useSessionGoalArmStore.ts` — the "next prompt starts a goal" flag, consumed by `sendMessage` in `sync/session-ui-store.ts` (works for drafts). + Armed slash commands resolve their authoritative command template and apply + OpenCode argument expansion (`$ARGUMENTS`, positional placeholders, or the + implicit argument suffix) for the audit objective before goal metadata is + written and before `session.command` dispatch. If command details cannot be + loaded, the raw invocation remains the objective rather than blocking command + execution. - `hooks/useSessionGoal.ts` — live goal state. - `components/chat/SessionGoalButton.tsx` — composer target button (arm / status color / cancel confirm); `SessionGoalRow.tsx` — goal strip @@ -172,7 +178,8 @@ sees only that final turn, so the report is its evidence. Scheduled tasks can run as goals: `execution.goalEnabled` (+ optional `execution.goalTokenBudget`) on a task makes the scheduled-tasks runtime stamp `metadata.openchamber.goal` onto the fresh session (objective = the -expanded task prompt) and attach the goal-mode intro part to the prompt. +expanded task prompt, or the argument-expanded command template for a slash +command) and attach the goal-mode intro part to normal prompts. The loop here picks it up from session events like any other goal. ## CLI-created goals @@ -183,8 +190,10 @@ session, fits and stores the expanded prompt as its objective, patches active goal metadata, appends the synthetic goal reminder, and only then dispatches the prompt. `--goal-token-budget` applies the same optional budget contract as scheduled goals. Slash commands retain command dispatch semantics and cannot -carry the synthetic prompt part; the goal metadata is still installed before -the command runs. +carry the synthetic prompt part. Their command template with OpenCode argument +expansion becomes the audit objective; goal metadata +is still installed before the command runs. A missing command template falls +back to the raw invocation. `openchamber session send --goal` and `openchamber session fork --goal` use the same server-owned prompt orchestration. Send installs a fresh goal on the