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
This commit is contained in:
@@ -1580,9 +1580,10 @@ class OpencodeService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string; template?: string }>> {
|
async listCommandsWithDetails(directory?: string | null): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string; template?: string }>> {
|
||||||
|
const requestDirectory = this.normalizeCandidatePath(directory ?? null) ?? this.currentDirectory;
|
||||||
const response = await this.client.command.list(
|
const response = await this.client.command.list(
|
||||||
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
requestDirectory ? { directory: requestDirectory } : undefined
|
||||||
);
|
);
|
||||||
const commands = unwrapSdkData(response, 'command.list');
|
const commands = unwrapSdkData(response, 'command.list');
|
||||||
// Return full command details including template
|
// Return full command details including template
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
|||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSessionWorktreeStore } from './session-worktree-store';
|
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 { setActionRefs, setOptimisticRefs } from './session-actions';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
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', () => {
|
describe('runtime worktree topology', () => {
|
||||||
test('restores independent in-memory maps across A -> B -> A', () => {
|
test('restores independent in-memory maps across A -> B -> A', () => {
|
||||||
const topologyA = new Map([['/repo', [{ path: '/repo/a', branch: 'a' }]]]);
|
const topologyA = new Map([['/repo', [{ path: '/repo/a', branch: 'a' }]]]);
|
||||||
|
|||||||
@@ -73,6 +73,34 @@ import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
|
|||||||
|
|
||||||
export type { AttachedFile }
|
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
|
// Send routing — shell mode, slash commands, or normal prompt
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1052,15 +1080,33 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
)
|
)
|
||||||
additionalParts = [...(additionalParts ?? []), { text: goalIntro, synthetic: true }]
|
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
|
if (!goalArmed) return
|
||||||
const uiState = useUIStore.getState()
|
const uiState = useUIStore.getState()
|
||||||
const tokenBudget = uiState.sessionGoalDefaultBudgetEnabled ? uiState.sessionGoalDefaultBudget : null
|
const tokenBudget = uiState.sessionGoalDefaultBudgetEnabled ? uiState.sessionGoalDefaultBudget : null
|
||||||
const objective = goalArm.objectiveOverride?.trim() || content
|
let objective = goalArm.objectiveOverride?.trim() || content
|
||||||
void setSessionGoal(goalSessionId, goalDirectory ?? undefined, { objective, tokenBudget }, null)
|
if (!goalArm.objectiveOverride && content.startsWith("/")) {
|
||||||
.catch((error) => {
|
const directoryCommands = getDirectoryState(goalDirectory ?? undefined)?.command ?? []
|
||||||
console.warn("[session-ui-store] failed to set goal from armed send", error)
|
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 ----
|
// ---- New session from draft ----
|
||||||
@@ -1088,6 +1134,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
await applyArmedGoal(createdDraftSession.sessionId, createdDraftSession.directory)
|
||||||
await routeMessage({
|
await routeMessage({
|
||||||
sessionId: createdDraftSession.sessionId,
|
sessionId: createdDraftSession.sessionId,
|
||||||
directory: createdDraftSession.directory,
|
directory: createdDraftSession.directory,
|
||||||
@@ -1111,7 +1158,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
applyArmedGoal(createdDraftSession.sessionId, createdDraftSession.directory)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,6 +1214,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
if (targetSessionId) {
|
||||||
|
await applyArmedGoal(targetSessionId, currentSessionDirectory)
|
||||||
|
}
|
||||||
await routeMessage({
|
await routeMessage({
|
||||||
sessionId: targetSessionId || "",
|
sessionId: targetSessionId || "",
|
||||||
directory: currentSessionDirectory,
|
directory: currentSessionDirectory,
|
||||||
@@ -1191,9 +1240,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
if (targetSessionId) {
|
|
||||||
applyArmedGoal(targetSessionId, currentSessionDirectory)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import express from 'express';
|
|||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||||
import { createWorktree } from '../git/index.js';
|
import { createWorktree } from '../git/index.js';
|
||||||
import { expandSnippets } from '../opencode/snippets.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 { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
|
||||||
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
|
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
|
||||||
|
|
||||||
@@ -369,13 +369,27 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expandedPrompt = expandSnippets(prompt, directory);
|
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) {
|
if (goalInput.enabled) {
|
||||||
|
const commandObjective = resolvedCommand
|
||||||
|
? expandCommandGoalObjective(resolvedCommand.template, resolvedCommand.arguments)
|
||||||
|
: null;
|
||||||
await (createSessionGoalOverride || createSessionGoal)({
|
await (createSessionGoalOverride || createSessionGoal)({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
authHeaders,
|
authHeaders,
|
||||||
sessionID,
|
sessionID,
|
||||||
directory,
|
directory,
|
||||||
objective: expandedPrompt,
|
objective: commandObjective ?? expandedPrompt,
|
||||||
tokenBudget: goalInput.tokenBudget,
|
tokenBudget: goalInput.tokenBudget,
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
modelID: model.modelID,
|
modelID: model.modelID,
|
||||||
@@ -388,35 +402,21 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
let dispatchedAsCommand = false;
|
if (resolvedCommand) {
|
||||||
const parsedCommand = parseScheduledCommandPrompt(prompt);
|
|
||||||
if (parsedCommand) {
|
|
||||||
let commandExists = false;
|
|
||||||
try {
|
try {
|
||||||
const response = await client.command.list({ directory });
|
await client.session.command({
|
||||||
const commands = Array.isArray(response?.data) ? response.data : [];
|
sessionID,
|
||||||
commandExists = commands.some((command) => command?.name === parsedCommand.command);
|
directory,
|
||||||
} catch {
|
command: resolvedCommand.command,
|
||||||
|
arguments: resolvedCommand.arguments,
|
||||||
|
...(agent ? { agent } : {}),
|
||||||
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw markGoalPartial(error);
|
||||||
}
|
}
|
||||||
if (commandExists) {
|
} else {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
await runPromptAsync({
|
await runPromptAsync({
|
||||||
baseUrl,
|
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 = {}) => {
|
const create = async (payload = {}) => {
|
||||||
|
|||||||
@@ -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 () => {
|
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
|
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
|
||||||
|
|||||||
@@ -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()) => {
|
export const computeNextRunAt = (task, nowMs = Date.now()) => {
|
||||||
if (!task?.enabled) {
|
if (!task?.enabled) {
|
||||||
return null;
|
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);
|
const parsed = parseScheduledCommandPrompt(task?.execution?.prompt);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let commands = [];
|
let commands = [];
|
||||||
@@ -456,25 +482,24 @@ export const createScheduledTasksRuntime = (deps) => {
|
|||||||
const response = await client.command.list({ directory: projectPath });
|
const response = await client.command.list({ directory: projectPath });
|
||||||
commands = Array.isArray(response?.data) ? response.data : [];
|
commands = Array.isArray(response?.data) ? response.data : [];
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMatchingCommand = commands.some((command) => command?.name === parsed.command);
|
const command = commands.find((candidate) => candidate?.name === parsed.command);
|
||||||
if (!hasMatchingCommand) {
|
return command ? { ...parsed, template: command.template } : null;
|
||||||
return false;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
const runScheduledCommand = async ({ client, projectPath, sessionID, task, command }) => {
|
||||||
await client.session.command({
|
await client.session.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
directory: projectPath,
|
directory: projectPath,
|
||||||
command: parsed.command,
|
command: command.command,
|
||||||
arguments: parsed.arguments,
|
arguments: command.arguments,
|
||||||
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
||||||
model: `${task.execution.providerID}/${task.execution.modelID}`,
|
model: `${task.execution.providerID}/${task.execution.modelID}`,
|
||||||
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const runTaskWithWatchdog = async (projectID, task, reason) => {
|
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) {
|
if (task.execution.goalEnabled) {
|
||||||
|
const commandObjective = scheduledCommand
|
||||||
|
? expandCommandGoalObjective(scheduledCommand.template, scheduledCommand.arguments)
|
||||||
|
: null;
|
||||||
await createSessionGoal({
|
await createSessionGoal({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
authHeaders,
|
authHeaders,
|
||||||
sessionID,
|
sessionID,
|
||||||
directory: projectPath,
|
directory: projectPath,
|
||||||
objective: expandSnippets(task.execution.prompt, projectPath),
|
objective: commandObjective ?? expandSnippets(task.execution.prompt, projectPath),
|
||||||
tokenBudget: task.execution.goalTokenBudget,
|
tokenBudget: task.execution.goalTokenBudget,
|
||||||
providerID: task.execution.providerID,
|
providerID: task.execution.providerID,
|
||||||
modelID: task.execution.modelID,
|
modelID: task.execution.modelID,
|
||||||
@@ -541,13 +571,9 @@ export const createScheduledTasksRuntime = (deps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const executedAsCommand = await runScheduledCommandIfApplicable({
|
if (scheduledCommand) {
|
||||||
client,
|
await runScheduledCommand({ client, projectPath, sessionID, task, command: scheduledCommand });
|
||||||
projectPath,
|
} else {
|
||||||
sessionID,
|
|
||||||
task,
|
|
||||||
});
|
|
||||||
if (!executedAsCommand) {
|
|
||||||
await runPromptAsync({
|
await runPromptAsync({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
authHeaders,
|
authHeaders,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('scheduled-tasks runtime helpers', () => {
|
||||||
it('computes next daily run in timezone', () => {
|
it('computes next daily run in timezone', () => {
|
||||||
@@ -97,4 +97,15 @@ describe('scheduled-tasks runtime helpers', () => {
|
|||||||
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
|
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
|
||||||
expect(parseScheduledCommandPrompt('/')).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,6 +160,12 @@ sees only that final turn, so the report is its evidence.
|
|||||||
colors/labels shared across surfaces.
|
colors/labels shared across surfaces.
|
||||||
- `stores/useSessionGoalArmStore.ts` — the "next prompt starts a goal" flag,
|
- `stores/useSessionGoalArmStore.ts` — the "next prompt starts a goal" flag,
|
||||||
consumed by `sendMessage` in `sync/session-ui-store.ts` (works for drafts).
|
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.
|
- `hooks/useSessionGoal.ts` — live goal state.
|
||||||
- `components/chat/SessionGoalButton.tsx` — composer target button
|
- `components/chat/SessionGoalButton.tsx` — composer target button
|
||||||
(arm / status color / cancel confirm); `SessionGoalRow.tsx` — goal strip
|
(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
|
Scheduled tasks can run as goals: `execution.goalEnabled` (+ optional
|
||||||
`execution.goalTokenBudget`) on a task makes the scheduled-tasks runtime
|
`execution.goalTokenBudget`) on a task makes the scheduled-tasks runtime
|
||||||
stamp `metadata.openchamber.goal` onto the fresh session (objective = the
|
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.
|
The loop here picks it up from session events like any other goal.
|
||||||
|
|
||||||
## CLI-created goals
|
## 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
|
goal metadata, appends the synthetic goal reminder, and only then dispatches
|
||||||
the prompt. `--goal-token-budget` applies the same optional budget contract as
|
the prompt. `--goal-token-budget` applies the same optional budget contract as
|
||||||
scheduled goals. Slash commands retain command dispatch semantics and cannot
|
scheduled goals. Slash commands retain command dispatch semantics and cannot
|
||||||
carry the synthetic prompt part; the goal metadata is still installed before
|
carry the synthetic prompt part. Their command template with OpenCode argument
|
||||||
the command runs.
|
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
|
`openchamber session send --goal` and `openchamber session fork --goal` use
|
||||||
the same server-owned prompt orchestration. Send installs a fresh goal on the
|
the same server-owned prompt orchestration. Send installs a fresh goal on the
|
||||||
|
|||||||
Reference in New Issue
Block a user