diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts new file mode 100644 index 000000000..0ba0a6d27 --- /dev/null +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { createKeywordDetectorHook } from "./index" +import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" + +type StartLoopCall = { + sessionID: string + prompt: string + options: Record +} + +type CancelLoopCall = { sessionID: string } + +function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as any +} + +function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) { + return { + startLoop: (sessionID: string, prompt: string, options?: Record): boolean => { + startLoopCalls.push({ sessionID, prompt, options: options ?? {} }) + return true + }, + cancelLoop: (sessionID: string): boolean => { + cancelLoopCalls.push({ sessionID }) + return true + }, + getState: () => null, + event: async () => {}, + } +} + +describe("keyword-detector ralph-loop activation", () => { + beforeEach(() => { + _resetForTesting() + }) + + afterEach(() => { + _resetForTesting() + }) + + test("#given ulw keyword in main session #when chat.message fires #then ralph-loop startLoop is invoked with the user task", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw build a multi-agent backend architecture" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(1) + expect(startLoopCalls[0].sessionID).toBe("main-session") + expect(startLoopCalls[0].prompt).toContain("build a multi-agent backend architecture") + expect(startLoopCalls[0].options.ultrawork).toBe(true) + }) + + test("#given ultrawork keyword in main session #when chat.message fires #then ralph-loop startLoop is invoked with ultrawork enabled", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork ship the dashboard" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(1) + expect(startLoopCalls[0].sessionID).toBe("main-session") + expect(startLoopCalls[0].prompt).toContain("ship the dashboard") + expect(startLoopCalls[0].options.ultrawork).toBe(true) + }) + + test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "just a normal message" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword with planner agent #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw plan this feature" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "prometheus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword with non-OMO agent #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw build feature" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "OpenCode-Builder" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword without ralphLoop dependency #when chat.message fires #then no error is thrown and prompt is still injected", async () => { + // given + setMainSession("main-session") + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw do this" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).toContain("do this") + }) + + test("#given partial 'ulw' substring in StatefulWidget #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + _resetForTesting() + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "refactor the StatefulWidget component" }], + } + + // when + await hook["chat.message"]({ sessionID: "any-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword inside system-reminder block #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +The system mentions ulw mode in passing. +`, + }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + }) + + test("#given ulw keyword #when chat.message fires #then prompt is also injected as before", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw refactor the codebase" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).toContain("refactor the codebase") + expect(startLoopCalls).toHaveLength(1) + }) +}) diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index c03e43cc3..1d35bbe3f 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -12,8 +12,20 @@ import { subagentSessions, } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" +import type { RalphLoopHook } from "../ralph-loop" +import { parseRalphLoopArguments } from "../ralph-loop/command-arguments" -export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) { +const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i + +function extractUltraworkTask(cleanText: string): string { + return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim() +} + +export function createKeywordDetectorHook( + ctx: PluginInput, + _collector?: ContextCollector, + ralphLoop?: Pick +) { function getRuntimeVariant(input: { variant?: string }, message: Record): string | undefined { if (typeof message["variant"] === "string") { return message["variant"] @@ -115,6 +127,17 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context sessionID: input.sessionID, }) ) + + if (ralphLoop) { + const userTask = extractUltraworkTask(cleanText) + const parsedArguments = parseRalphLoopArguments(userTask) + ralphLoop.startLoop(input.sessionID, parsedArguments.prompt, { + ultrawork: true, + maxIterations: parsedArguments.maxIterations, + completionPromise: parsedArguments.completionPromise, + strategy: parsedArguments.strategy, + }) + } } const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts index 4bfd2b4b3..4da2b5085 100644 --- a/src/plugin/hooks/create-core-hooks.ts +++ b/src/plugin/hooks/create-core-hooks.ts @@ -36,6 +36,7 @@ export function createCoreHooks(args: { pluginConfig, isHookEnabled: (name) => isHookEnabled(name as HookName), safeHookEnabled, + ralphLoop: session.ralphLoop, }) return { diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts index c57a959bb..7d107571b 100644 --- a/src/plugin/hooks/create-transform-hooks.ts +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -1,5 +1,6 @@ import type { OhMyOpenCodeConfig } from "../../config" import type { PluginContext } from "../types" +import type { RalphLoopHook } from "../../hooks/ralph-loop" import { createClaudeCodeHooksHook, @@ -26,8 +27,9 @@ export function createTransformHooks(args: { pluginConfig: OhMyOpenCodeConfig isHookEnabled: (hookName: string) => boolean safeHookEnabled?: boolean + ralphLoop?: RalphLoopHook | null }): TransformHooks { - const { ctx, pluginConfig, isHookEnabled } = args + const { ctx, pluginConfig, isHookEnabled, ralphLoop } = args const safeHookEnabled = args.safeHookEnabled ?? true const claudeCodeHooks = isHookEnabled("claude-code-hooks") @@ -49,7 +51,7 @@ export function createTransformHooks(args: { const keywordDetector = isHookEnabled("keyword-detector") ? safeCreateHook( "keyword-detector", - () => createKeywordDetectorHook(ctx, contextCollector), + () => createKeywordDetectorHook(ctx, contextCollector, ralphLoop ?? undefined), { enabled: safeHookEnabled }, ) : null