diff --git a/src/hooks/auto-slash-command/index.test.ts b/src/hooks/auto-slash-command/index.test.ts index ad073b337..75984d8d7 100644 --- a/src/hooks/auto-slash-command/index.test.ts +++ b/src/hooks/auto-slash-command/index.test.ts @@ -348,6 +348,22 @@ describe("createAutoSlashCommandHook", () => { expect(output.parts[0].text).toContain("/ralph-loop Command") }) + it("should inject template for known builtin commands like ulw-loop", async () => { + //#given + const hook = createAutoSlashCommandHook() + const input = createCommandInput("ulw-loop", '"Ship feature" --strategy=continue') + const output = createCommandOutput("original") + + //#when + await hook["command.execute.before"](input, output) + + //#then + expect(output.parts[0].text).toContain("") + expect(output.parts[0].text).toContain("/ulw-loop Command") + expect(output.parts[0].text).toContain("") + expect(output.parts[0].text).toContain('"Ship feature" --strategy=continue') + }) + it("should pass command arguments correctly", async () => { //#given const hook = createAutoSlashCommandHook() diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 7767c6639..b5837c76b 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,6 +5,7 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; +import { getAgentListDisplayName } from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -95,4 +96,29 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-project-skill"]?.description).toContain("Agents project skill"); expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); + + test("remaps Atlas command agents to the list display name used by runtime agent lookup", async () => { + // given + loadBuiltinCommandsSpy.mockReturnValue({ + "start-work": { + name: "start-work", + description: "(builtin) Start work", + template: "template", + agent: "atlas", + }, + }); + const config: Record = { command: {} }; + + // when + await applyCommandConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }); + + // then + const commandConfig = config.command as Record; + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 587950f6b..626eb9850 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,5 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentDisplayName } from "../shared/agent-display-names"; +import { getAgentListDisplayName } from "../shared/agent-display-names"; import { loadUserCommands, loadProjectCommands, @@ -97,7 +97,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentDisplayName(cmd.agent); + cmd.agent = getAgentListDisplayName(cmd.agent); } } } diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index e9dfee568..a3699668c 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -170,3 +170,90 @@ describe("createPluginInterface - command.execute.before", () => { expect(readBoulderState(testDir)?.agent).toBe("atlas") }) }) + +describe("createPluginInterface - ulw-loop native command smoke", () => { + let testDir = "" + + beforeEach(() => { + testDir = join(tmpdir(), `plugin-interface-ulw-loop-${randomUUID()}`) + mkdirSync(testDir, { recursive: true }) + _resetForTesting() + registerAgentName("sisyphus") + }) + + afterEach(() => { + _resetForTesting() + rmSync(testDir, { recursive: true, force: true }) + }) + + test("starts the ultrawork loop from the native command flow with parsed arguments intact", async () => { + // given + const startLoopCalls: Array<{ + sessionID: string + prompt: string + options: Record + }> = [] + const pluginInterface = createPluginInterface({ + ctx: { + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never, + pluginConfig: {} as never, + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: {} as never, + hooks: { + autoSlashCommand: createAutoSlashCommandHook({ skills: [] }), + ralphLoop: { + startLoop: (sessionID: string, prompt: string, options?: Record) => { + startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) + return true + }, + cancelLoop: () => true, + getState: () => null, + }, + } as never, + tools: {}, + }) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "original" }], + } + + // when + await pluginInterface["command.execute.before"]?.( + { + command: "ulw-loop", + sessionID: "ses-ulw-native", + arguments: '"Ship feature" --strategy=continue', + }, + output as never, + ) + await pluginInterface["chat.message"]?.( + { + sessionID: "ses-ulw-native", + agent: "sisyphus", + } as never, + output as never, + ) + + // then + expect(output.parts[0]?.text).toContain("/ulw-loop Command") + expect(startLoopCalls).toEqual([ + { + sessionID: "ses-ulw-native", + prompt: "Ship feature", + options: { + ultrawork: true, + maxIterations: undefined, + completionPromise: undefined, + strategy: "continue", + }, + }, + ]) + }) +}) diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index 1ef58df06..91cc869b7 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -95,6 +95,49 @@ describe("createChatMessageHandler - /start-work integration", () => { }) }) +describe("createChatMessageHandler - /ulw-loop raw slash fallback", () => { + test("starts ultrawork loop when /ulw-loop arrives through chat.message without native command expansion", async () => { + // given + const startLoopCalls: Array<{ + sessionID: string + prompt: string + options: Record + }> = [] + const args = createMockHandlerArgs() + args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] }) + args.hooks.ralphLoop = { + startLoop: (sessionID: string, prompt: string, options?: Record) => { + startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) + return true + }, + cancelLoop: () => true, + } + const handler = createChatMessageHandler(args) + const input = createMockInput("sisyphus") + const output: ChatMessageHandlerOutput = { + message: {}, + parts: [{ type: "text", text: '/ulw-loop "Ship feature" --strategy=continue' }], + } + + // when + await handler(input, output) + + // then + expect(startLoopCalls).toEqual([ + { + sessionID: "test-session", + prompt: "Ship feature", + options: { + ultrawork: true, + maxIterations: undefined, + completionPromise: undefined, + strategy: "continue", + }, + }, + ]) + }) +}) + function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) { return { sessionID: "test-session", diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index b7bfea33f..42e120bcd 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -25,6 +25,10 @@ type StartWorkHookOutput = { parts: Array<{ type: string; text?: string }> } type SessionModelOverride = { providerID: string; modelID: string } +type RawLoopCommand = + | { command: "ralph-loop" | "ulw-loop"; args: string } + | { command: "cancel-ralph"; args: "" } + function isStartWorkHookOutput(value: unknown): value is StartWorkHookOutput { if (typeof value !== "object" || value === null) return false const record = value as Record @@ -84,6 +88,33 @@ function getStoredMainSessionModel( return getSessionModel(input.sessionID) } +function parseRawLoopSlashCommand(promptText: string): RawLoopCommand | null { + const trimmed = promptText.trim() + + if (!trimmed.startsWith("/")) { + return null + } + + const cancelMatch = trimmed.match(/^\/cancel-ralph(?:\s+.*)?$/i) + if (cancelMatch) { + return { command: "cancel-ralph", args: "" } + } + + const loopMatch = trimmed.match(/^\/(ralph-loop|ulw-loop)\s*([\s\S]*)$/i) + if (!loopMatch) { + return null + } + + const command = loopMatch[1]?.toLowerCase() + const args = loopMatch[2]?.trim() ?? "" + + if (command === "ralph-loop" || command === "ulw-loop") { + return { command, args } + } + + return null +} + export function createChatMessageHandler(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig @@ -201,19 +232,24 @@ export function createChatMessageHandler(args: { const isCancelRalphTemplate = promptText.includes( "Cancel the currently active Ralph Loop", ) + const rawLoopCommand = + !isRalphLoopTemplate && !isUlwLoopTemplate && !isCancelRalphTemplate + ? parseRawLoopSlashCommand(promptText) + : null - if (isRalphLoopTemplate || isUlwLoopTemplate) { + if (isRalphLoopTemplate || isUlwLoopTemplate || rawLoopCommand?.command === "ralph-loop" || rawLoopCommand?.command === "ulw-loop") { const taskMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-task>/i) - const rawTask = taskMatch?.[1]?.trim() || "" + const rawTask = taskMatch?.[1]?.trim() || rawLoopCommand?.args || "" const parsedArguments = parseRalphLoopArguments(rawTask) + const ultrawork = isUlwLoopTemplate || rawLoopCommand?.command === "ulw-loop" hooks.ralphLoop.startLoop(input.sessionID, parsedArguments.prompt, { - ultrawork: isUlwLoopTemplate, + ultrawork, maxIterations: parsedArguments.maxIterations, completionPromise: parsedArguments.completionPromise, strategy: parsedArguments.strategy, }) - } else if (isCancelRalphTemplate) { + } else if (isCancelRalphTemplate || rawLoopCommand?.command === "cancel-ralph") { hooks.ralphLoop.cancelLoop(input.sessionID) } }