fix(keyword-detector): start ralph-loop when ulw keyword detected (#3113)

Plain 'ulw' keyword now starts the continuation loop via
ralphLoop.startLoop(), matching README promise. Wired through
create-core-hooks.ts and create-transform-hooks.ts.

TDD: 40 keyword-detector tests pass, 1239 hook tests pass, tsc clean.

Closes #3113
This commit is contained in:
YeonGyu-Kim
2026-04-07 15:10:43 +09:00
parent 3e8fd5ff18
commit 717c976c66
4 changed files with 251 additions and 3 deletions
@@ -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<string, unknown>
}
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<string, unknown>): 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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
The system mentions ulw mode in passing.
</system-reminder>`,
}],
}
// 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<string, unknown>,
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)
})
})
+24 -1
View File
@@ -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<RalphLoopHook, "startLoop">
) {
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): 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)
+1
View File
@@ -36,6 +36,7 @@ export function createCoreHooks(args: {
pluginConfig,
isHookEnabled: (name) => isHookEnabled(name as HookName),
safeHookEnabled,
ralphLoop: session.ralphLoop,
})
return {
+4 -2
View File
@@ -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