diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf3..a35c8e4d4c2d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -26,6 +26,7 @@ import { PromptHistoryProvider } from "./component/prompt/history" import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" +import { DialogPrompt } from "./ui/dialog-prompt" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" import { Session as SessionApi } from "@/session" @@ -36,6 +37,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { Identifier } from "@/id/id" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -317,6 +319,151 @@ function App() { dialog.clear() }, }, + { + title: "Start Ralph loop", + value: "ralph-loop", + acceptsArgs: true, + category: "Session", + onSelect: async (dialog, source) => { + if (route.data.type !== "session") { + toast.show({ variant: "error", message: "Not in a session" }) + dialog.clear() + return + } + + const sessionID = route.data.sessionID + + if (source && typeof source === "object" && "prompt" in source) { + const selectedModel = local.model.current() + if (!selectedModel) { + toast.show({ variant: "error", message: "No model selected" }) + dialog.clear() + return + } + const argsText = source.prompt.args.join(" ") + const isCancel = source.prompt.args.includes("--cancel") || source.prompt.args.includes("-c") + try { + await sdk.client.session.command({ + sessionID, + command: "ralph-loop", + arguments: argsText, + messageID: Identifier.ascending("message"), + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant: local.model.variant.current(), + }) + toast.show({ variant: "success", message: isCancel ? "Ralph loop cancelled" : "Ralph loop started" }) + } catch (err) { + toast.show({ variant: "error", message: err instanceof Error ? err.message : "Failed to start loop" }) + } + dialog.clear() + return + } + + const promptResult = await DialogPrompt.show(dialog, "Ralph Loop", { + placeholder: 'e.g., "Continue with your task..."', + }) + + if (!promptResult) { + dialog.clear() + return + } + + if (promptResult.length < 3) { + toast.show({ variant: "warning", message: "Prompt too short" }) + dialog.clear() + return + } + + const completionPromise = await DialogPrompt.show(dialog, "Completion Promise", { + placeholder: 'e.g., "I_DIDNT_FIND_ANYTHING_TO_CHANGE_OR_IMPROVE"', + value: "I_DIDNT_FIND_ANYTHING_TO_CHANGE_OR_IMPROVE", + }) + + const iterationsResult = await DialogPrompt.show(dialog, "Max Iterations", { + placeholder: "e.g., 20", + value: "20", + }) + + const maxIterations = iterationsResult ? parseInt(iterationsResult, 10) : 20 + if (iterationsResult && Number.isNaN(maxIterations)) { + toast.show({ variant: "warning", message: "Invalid max iterations; using default" }) + } + + const selectedModel = local.model.current() + if (selectedModel) { + const quoteArg = (value: string) => { + if (!value || !/\s/.test(value)) return value + if (!value.includes('"')) return `"${value}"` + if (!value.includes("'")) return `'${value}'` + return value + } + const args: string[] = [] + if (!Number.isNaN(maxIterations)) { + args.push("--max-iterations", String(maxIterations)) + } + if (completionPromise) { + args.push("--completion-promise", quoteArg(completionPromise)) + } + args.push("--", quoteArg(promptResult)) + try { + await sdk.client.session.command({ + sessionID, + command: "ralph-loop", + arguments: args.join(" "), + messageID: Identifier.ascending("message"), + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant: local.model.variant.current(), + }) + toast.show({ variant: "success", message: `Ralph loop started (${maxIterations} iterations)` }) + } catch (err) { + toast.show({ variant: "error", message: err instanceof Error ? err.message : "Failed to start loop" }) + } + } else { + toast.show({ variant: "error", message: "No model selected" }) + } + dialog.clear() + }, + }, + { + title: "Cancel Ralph loop", + value: "ralph-loop.cancel", + category: "Session", + slash: { + name: "ralph-loop:cancel", + }, + onSelect: async (dialog) => { + if (route.data.type !== "session") { + toast.show({ variant: "error", message: "Not in a session" }) + dialog.clear() + return + } + + const sessionID = route.data.sessionID + const selectedModel = local.model.current() + if (!selectedModel) { + toast.show({ variant: "error", message: "No model selected" }) + dialog.clear() + return + } + try { + await sdk.client.session.command({ + sessionID, + command: "ralph-cancel", + arguments: "", + messageID: Identifier.ascending("message"), + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant: local.model.variant.current(), + }) + toast.show({ variant: "success", message: "Ralph loop cancelled" }) + } catch (err) { + toast.show({ variant: "warning", message: err instanceof Error ? err.message : "No active loop to cancel" }) + } + dialog.clear() + }, + }, { title: "Switch model", value: "model.list", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index 38dc402758b2..b75497f562ed 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -27,8 +27,11 @@ export type CommandOption = DialogSelectOption & { slash?: Slash hidden?: boolean enabled?: boolean + acceptsArgs?: boolean } +export type CommandTrigger = undefined | "prompt" | { prompt: { args: string[] } } + function init() { const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -72,13 +75,19 @@ function init() { }) const result = { - trigger(name: string) { + trigger(name: string, source?: CommandTrigger) { for (const option of entries()) { if (option.value === name) { if (!isEnabled(option)) return option.onSelect?.(dialog) return } + if (option.acceptsArgs && name.startsWith(String(option.value) + " ")) { + const argsStr = name.slice(String(option.value).length + 1).trim() + const args = argsStr.split(" ").filter((x) => x) + option.onSelect?.(dialog, { prompt: { args } }) + return + } } }, slashes() { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 730da20c2650..74bb074058f8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -547,14 +547,44 @@ export function Prompt(props: PromptProps) { inputText.startsWith("/") && iife(() => { const command = inputText.split(" ")[0].slice(1) - console.log(command) - return sync.data.command.some((x) => x.name === command) + const isCommand = sync.data.command.some((x) => x.name === command) + const isRalphLoop = command === "ralph-loop" + const isRalphCancel = command === "ralph-cancel" || command === "ralph-loop:cancel" + return isCommand || isRalphLoop || isRalphCancel }) ) { let [command, ...args] = inputText.split(" ") + const commandName = command.slice(1) + const normalizedCommand = commandName === "ralph-loop:cancel" ? "ralph-cancel" : commandName + + // Route ralph-loop and ralph-cancel through server-side command handler + // Server registers loop state in its own context where chat.waiting hook runs + if (normalizedCommand === "ralph-cancel" || normalizedCommand === "ralph-loop") { + sdk.client.session.command({ + sessionID, + command: normalizedCommand, + arguments: args.join(" "), + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + messageID, + variant, + parts: nonTextParts + .filter((x) => x.type === "file") + .map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + }) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + return + } + sdk.client.session.command({ sessionID, - command: command.slice(1), + command: commandName, arguments: args.join(" "), agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, @@ -567,6 +597,11 @@ export function Prompt(props: PromptProps) { ...x, })), }) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + return } else { sdk.client.session .prompt({ @@ -603,13 +638,14 @@ export function Prompt(props: PromptProps) { props.onSubmit?.() // temporary hack to make sure the message is sent - if (!props.sessionID) + if (!props.sessionID) { setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) + } input.clear() } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 5c37a493dfa5..c7ccabcacd60 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,7 +38,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element - onSelect?: (ctx: DialogContext) => void + onSelect?: (ctx: DialogContext, trigger?: "prompt" | { prompt: { args: string[] } }) => void } export type DialogSelectRef = { diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e96..9224c0c0b775 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -53,6 +53,7 @@ export namespace Command { export const Default = { INIT: "init", REVIEW: "review", + RALPH_LOOP: "ralph-loop", } as const const state = Instance.state(async () => { @@ -76,6 +77,12 @@ export namespace Command { subtask: true, hints: hints(PROMPT_REVIEW), }, + [Default.RALPH_LOOP]: { + name: Default.RALPH_LOOP, + description: "Start Ralph-Loop [-m MAX_ITERATIONS|20] [-p PROMISE_TEXT|unset] PROMPT", + template: "", // Handled specially by loop logic + hints: ["$ARGUMENTS"], + }, } for (const [name, command] of Object.entries(cfg.command ?? {})) { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d7..707a908c6110 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,7 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { RalphLoop } from "@opencode-ai/plugin/ralph-loop" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -18,7 +19,14 @@ export namespace Plugin { const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [ + CodexAuthPlugin, + CopilotAuthPlugin, + async () => { + log.info("loading ralph-loop internal plugin") + return { ...RalphLoop, name: "ralph-loop" } + }, + ] const state = Instance.state(async () => { const client = createOpencodeClient({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f4793d1a7987..0d33779cc0cf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,8 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" +const RALPH_LOOP_COMMAND = "ralph-loop" + // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -296,6 +298,48 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + const assistantParts = await MessageV2.parts(lastAssistant.id) + const assistantText = assistantParts + .filter((p) => p.type === "text") + .map((p) => (p as MessageV2.TextPart).text) + .join("\n") + + const hookResult = await Plugin.trigger( + "chat.waiting", + { + sessionID, + assistant: lastAssistant, + assistantText, + iterationCount: step, + lastUserID: lastUser.id, + }, + { + injectedTexts: [], + }, + ) + + if (hookResult.injectedTexts.length > 0) { + const injectedMessage: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID, + role: "user", + agent: lastUser.agent, + model: lastUser.model, + time: { created: Date.now() }, + } + await Session.updateMessage(injectedMessage) + for (const text of hookResult.injectedTexts) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: injectedMessage.id, + sessionID, + type: "text", + text, + }) + } + continue + } + log.info("exiting loop", { sessionID }) break } @@ -1596,11 +1640,89 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) - const command = await Command.get(input.command) + const isRalphCancel = input.command === "ralph-cancel" + const commandName = isRalphCancel ? RALPH_LOOP_COMMAND : input.command + const command = await Command.get(commandName) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + if (isRalphCancel) args.unshift("--cancel") + + if (commandName === RALPH_LOOP_COMMAND) { + const { registerLoop, parseRalphLoopArgs } = await import("@opencode-ai/plugin/ralph-loop") + const parsed = parseRalphLoopArgs(args) + + if (parsed.cancel) { + const { cancelLoop } = await import("@opencode-ai/plugin/ralph-loop") + const cancelled = cancelLoop(input.sessionID) + if (cancelled) { + const msgs = [] + for await (const m of MessageV2.stream(input.sessionID)) { + msgs.push(m) + } + const lastUserMsg = msgs.reverse().find((m) => m.info.role === "user") + const lastUser = lastUserMsg?.info as MessageV2.User + const msg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: input.sessionID, + role: "user", + agent: lastUser?.agent ?? agentName, + model: lastUser?.model ?? (await lastModel(input.sessionID)), + time: { created: Date.now() }, + } + await Session.updateMessage(msg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: input.sessionID, + type: "text", + text: "Ralph loop cancelled.", + }) + const parts = await MessageV2.parts(msg.id) + return { info: msg, parts } + } + throw new Error("No active ralph-loop to cancel") + } + + if (!parsed.prompt) { + throw new Error("ralph-loop requires a prompt argument") + } + + registerLoop(input.sessionID, parsed) + + const { getLoopState, formatRalphLoopPrompt } = await import("@opencode-ai/plugin/ralph-loop") + const state = getLoopState(input.sessionID) + if (state) { + state.iterationCount = state.iterationCount + 1 + const completionPrompt = formatRalphLoopPrompt(state) + const msgs = [] + for await (const m of MessageV2.stream(input.sessionID)) { + msgs.push(m) + } + const lastUserMsg = msgs.reverse().find((m) => m.info.role === "user") + const lastUser = lastUserMsg?.info as MessageV2.User + const model = input.model + ? Provider.parseModel(input.model) + : lastUser?.model ?? (await lastModel(input.sessionID)) + const agent = lastUser?.agent ?? agentName + const result = (await prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + model, + agent, + parts: [ + { + type: "text", + text: completionPrompt, + }, + ...(input.parts ?? []), + ], + variant: input.variant, + })) as MessageV2.WithParts + return result + } + } const templateCommand = await command.template diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 273490180832..1177353159f4 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -10,7 +10,8 @@ }, "exports": { ".": "./src/index.ts", - "./tool": "./src/tool.ts" + "./tool": "./src/tool.ts", + "./ralph-loop": "./src/ralph-loop.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 36a4657d74c5..d32fd2705842 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -10,6 +10,7 @@ import type { Part, Auth, Config, + AssistantMessage, } from "@opencode-ai/sdk" import type { BunShell } from "./shell" @@ -219,4 +220,20 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Called when assistant finishes and is waiting for user input. + * Allows plugins to inject continuation text and keep loop active. + */ + "chat.waiting"?: ( + input: { + sessionID: string + assistant: AssistantMessage + assistantText: string + iterationCount: number + lastUserID: string + }, + output: { + injectedTexts: string[] + }, + ) => Promise } diff --git a/packages/plugin/src/ralph-loop.ts b/packages/plugin/src/ralph-loop.ts new file mode 100644 index 000000000000..79eb8e6c0563 --- /dev/null +++ b/packages/plugin/src/ralph-loop.ts @@ -0,0 +1,175 @@ +import type { Hooks } from "./index" + +export interface RalphLoopState { + sessionID: string + prompt: string + completionPromise?: string + maxIterations: number + iterationCount: number + cancelled: boolean + lastUserID?: string +} + +export interface RalphLoopOptions { + prompt?: string + completionPromise?: string + maxIterations?: number +} + +const DEFAULT_MAX_ITERATIONS = 20 +const ABSOLUTE_MAX = 100 + +let ralphState: Map | undefined + +function getRalphState(): Map { + if (!ralphState) { + ralphState = new Map() + } + return ralphState +} + +export function registerLoop( + sessionID: string, + promptOrOptions?: string | RalphLoopOptions, + maxIterations?: number, +): RalphLoopState { + const state = getRalphState() + + let prompt: string = "" + let completionPromise: string | undefined + let maxIter = DEFAULT_MAX_ITERATIONS + + if (typeof promptOrOptions === "string") { + prompt = promptOrOptions + if (maxIterations !== undefined) { + maxIter = maxIterations + } + } else if (promptOrOptions) { + if (promptOrOptions.prompt !== undefined) { + prompt = promptOrOptions.prompt + } + if (promptOrOptions.completionPromise !== undefined) { + completionPromise = promptOrOptions.completionPromise + } + if (promptOrOptions.maxIterations !== undefined) { + maxIter = promptOrOptions.maxIterations + } + } + + const newLoop: RalphLoopState = { + sessionID, + prompt, + completionPromise, + maxIterations: Math.min(maxIter, ABSOLUTE_MAX), + iterationCount: 0, + cancelled: false, + } + state.set(sessionID, newLoop) + return newLoop +} + +export function cancelLoop(sessionID: string): boolean { + const state = getRalphState() + const loop = state.get(sessionID) + if (!loop) return false + loop.cancelled = true + state.delete(sessionID) + return true +} + +export function isLoopActive(sessionID: string): boolean { + const state = getRalphState() + const loop = state.get(sessionID) + return loop !== undefined && !loop.cancelled +} + +export function getLoopState(sessionID: string): RalphLoopState | undefined { + const state = getRalphState() + return state.get(sessionID) +} + +export function clearRalphLoop() { + ralphState = undefined +} + +export { parseRalphLoopArgs } + +function parseRalphLoopArgs(args: string[]): RalphLoopOptions & { cancel: boolean } { + const promptTokens: string[] = [] + let completionPromise: string | undefined + let maxIterations: number | undefined + let cancel = false + const maxIterationsError = "Invalid max iterations: expected a positive integer" + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === "--") { + promptTokens.push(...args.slice(i + 1)) + break + } + if (arg === "--cancel" || arg === "-c") { + cancel = true + continue + } + if (arg === "--completion-promise" || arg === "-p") { + completionPromise = args[++i] + continue + } + if (arg === "--max-iterations" || arg === "-m") { + const val = args[++i] + if (!val || !/^\d+$/.test(val)) { + throw new Error(maxIterationsError) + } + const parsed = Number(val) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(maxIterationsError) + } + maxIterations = parsed + continue + } + promptTokens.push(arg) + } + + const prompt = promptTokens.length ? promptTokens.join(" ") : undefined + return { prompt, completionPromise, maxIterations, cancel } +} + +export function formatRalphLoopPrompt(loop: RalphLoopState): string { + const status = `Ralph Loop ${loop.iterationCount}/${loop.maxIterations}` + const completion = loop.completionPromise + ? `\n\nIf you didn't manage to change or improve anything say ${loop.completionPromise} to stop. Do not lie.` + : "" + return `${status}\n\n${loop.prompt}${completion}` +} + +function checkCompletionMatch(assistantText: string, completionPromise: string): boolean { + const expectedTag = `${completionPromise}`.toLowerCase() + return assistantText.toLowerCase().includes(expectedTag) +} + +export const RalphLoop: Hooks = { + "chat.waiting": async (input, output) => { + const state = getRalphState() + const existing = state.get(input.sessionID) + + if (!existing || existing.cancelled) { + state.delete(input.sessionID) + return + } + + const nextIteration = existing.iterationCount + 1 + if (nextIteration > existing.maxIterations || nextIteration > ABSOLUTE_MAX) { + state.delete(input.sessionID) + return + } + + if (existing.completionPromise && checkCompletionMatch(input.assistantText, existing.completionPromise)) { + state.delete(input.sessionID) + return + } + + existing.iterationCount = nextIteration + const prompt = formatRalphLoopPrompt(existing) + output.injectedTexts.push(prompt) + }, +} diff --git a/packages/plugin/test/ralph-loop-integration.test.ts b/packages/plugin/test/ralph-loop-integration.test.ts new file mode 100644 index 000000000000..addf8ac2b336 --- /dev/null +++ b/packages/plugin/test/ralph-loop-integration.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { registerLoop, RalphLoop, clearRalphLoop, getLoopState } from "../src/ralph-loop" + +async function triggerHook(input: { + sessionID: string + assistant: any + assistantText: string + iterationCount: number + lastUserID: string +}): Promise<{ injectedTexts: string[] }> { + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: input.sessionID, + assistant: input.assistant, + assistantText: input.assistantText, + iterationCount: input.iterationCount, + lastUserID: input.lastUserID, + }, + output, + ) + + return output +} + +describe("RalphLoop integration", () => { + beforeEach(() => { + clearRalphLoop() + }) + + describe("full flow: register → hook trigger → injected texts", () => { + test("first call continues loop, second call continues", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + }) + + const result1 = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-1", role: "assistant" } as any, + assistantText: "let me check the code", + iterationCount: 0, + lastUserID: "user-1", + }) + + expect(result1.injectedTexts.length).toBeGreaterThan(0) + expect(result1.injectedTexts[0]).toContain("read the todo file and execute it") + expect(result1.injectedTexts[0]).toContain("DONE") + expect(result1.injectedTexts[0]).toContain("If you didn't manage to change or improve anything say") + + const result2 = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-2", role: "assistant" } as any, + assistantText: "still checking...", + iterationCount: 1, + lastUserID: "user-1", + }) + + expect(result2.injectedTexts.length).toBeGreaterThan(0) + expect(result2.injectedTexts[0]).toContain("read the todo file and execute it") + expect(result2.injectedTexts[0]).toContain("DONE") + }) + + test("promise match with XML tags stops loop on second call", async () => { + const promise = "I_DIDNT_FIND_ANYTHING_TO_CHANGE_OR_IMPROVE" + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: promise, + }) + + await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-1", role: "assistant" } as any, + assistantText: "checking files...", + iterationCount: 0, + lastUserID: "user-1", + }) + + const result2 = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-2", role: "assistant" } as any, + assistantText: `${promise}`, + iterationCount: 1, + lastUserID: "user-1", + }) + + expect(result2.injectedTexts.length).toBe(0) + expect(getLoopState("session-1")).toBeUndefined() + }) + + test("multiple continuations increment count", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + }) + + let state = getLoopState("session-1")! + state.lastUserID = "user-1" + + await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-0", role: "assistant" } as any, + assistantText: "checking files...", + iterationCount: 0, + lastUserID: "user-1", + }) + + for (let i = 1; i < 3; i++) { + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: `msg-${i}`, role: "assistant" } as any, + assistantText: "still working...", + iterationCount: i, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBeGreaterThan(0) + expect(result.injectedTexts[0]).toContain("read the todo file and execute it") + expect(result.injectedTexts[0]).toContain("DONE") + } + + state = getLoopState("session-1")! + expect(state.iterationCount).toBe(3) + }) + + test("max iterations triggers stop", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + maxIterations: 3, + }) + + let state = getLoopState("session-1")! + state.lastUserID = "user-1" + + await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-0", role: "assistant" } as any, + assistantText: "still working...", + iterationCount: 0, + lastUserID: "user-1", + }) + + for (let i = 1; i < 2; i++) { + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: `msg-${i}`, role: "assistant" } as any, + assistantText: "still working...", + iterationCount: i, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBeGreaterThan(0) + expect(result.injectedTexts[0]).toContain("read the todo file and execute it") + } + + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-3", role: "assistant" } as any, + assistantText: "still working...", + iterationCount: 3, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBeGreaterThan(0) + + const resultStop = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-4", role: "assistant" } as any, + assistantText: "still working...", + iterationCount: 4, + lastUserID: "user-1", + }) + + expect(resultStop.injectedTexts.length).toBe(0) + expect(getLoopState("session-1")).toBeUndefined() + }) + }) + + describe("edge cases", () => { + test("unregistered session returns empty", async () => { + const result = await triggerHook({ + sessionID: "nonexistent", + assistant: { id: "msg-1", role: "assistant" } as any, + assistantText: "hello", + iterationCount: 0, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBe(0) + }) + + test("case insensitive XML tag matching", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "I_DIDNT_FIND_ANYTHING", + }) + + await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-1", role: "assistant" } as any, + assistantText: "working on it...", + iterationCount: 0, + lastUserID: "user-1", + }) + + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-2", role: "assistant" } as any, + assistantText: "I_DIDNT_FIND_ANYTHING", + iterationCount: 1, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBe(0) + expect(getLoopState("session-1")).toBeUndefined() + }) + + test("prompt without completion promise continues indefinitely until limit", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + maxIterations: 3, + }) + + let state = getLoopState("session-1")! + state.lastUserID = "user-1" + + for (let i = 0; i < 2; i++) { + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: `msg-${i}`, role: "assistant" } as any, + assistantText: "working...", + iterationCount: i, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBeGreaterThan(0) + expect(result.injectedTexts[0]).toContain("Ralph Loop") + expect(result.injectedTexts[0]).toContain("read the todo file and execute it") + } + + const result = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-3", role: "assistant" } as any, + assistantText: "working...", + iterationCount: 3, + lastUserID: "user-1", + }) + + expect(result.injectedTexts.length).toBeGreaterThan(0) + + const resultStop = await triggerHook({ + sessionID: "session-1", + assistant: { id: "msg-4", role: "assistant" } as any, + assistantText: "working...", + iterationCount: 4, + lastUserID: "user-1", + }) + + expect(resultStop.injectedTexts.length).toBe(0) + }) + }) +}) diff --git a/packages/plugin/test/ralph-loop.test.ts b/packages/plugin/test/ralph-loop.test.ts new file mode 100644 index 000000000000..116e6160d9f9 --- /dev/null +++ b/packages/plugin/test/ralph-loop.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { + registerLoop, + cancelLoop, + isLoopActive, + getLoopState, + RalphLoop, + clearRalphLoop, + parseRalphLoopArgs, +} from "../src/ralph-loop" + +describe("RalphLoop", () => { + beforeEach(() => { + clearRalphLoop() + }) + + describe("registerLoop", () => { + test("creates new loop state with default max iterations", () => { + const state = registerLoop("session-1", "read the todo file and execute it") + + expect(state.sessionID).toBe("session-1") + expect(state.prompt).toBe("read the todo file and execute it") + expect(state.maxIterations).toBe(20) + expect(state.iterationCount).toBe(0) + expect(state.cancelled).toBe(false) + }) + + test("creates new loop state with custom max iterations", () => { + const state = registerLoop("session-1", "test", 5) + + expect(state.maxIterations).toBe(5) + }) + + test("caps custom max at absolute maximum of 100", () => { + const state = registerLoop("session-1", "test", 200) + + expect(state.maxIterations).toBe(100) + }) + + test("creates loop with completion promise using options object", () => { + const state = registerLoop("session-1", { + prompt: "read the todo file", + completionPromise: "DONE", + maxIterations: 5, + }) + + expect(state.prompt).toBe("read the todo file") + expect(state.completionPromise).toBe("DONE") + expect(state.maxIterations).toBe(5) + }) + + test("overwrites existing state if session already registered", () => { + const first = registerLoop("session-1", "test") + const second = registerLoop("session-1", "different prompt") + + expect(second).not.toBe(first) + expect(second.prompt).toBe("different prompt") + expect(first.sessionID).toBe(second.sessionID) + }) + }) + + describe("cancelLoop", () => { + test("returns false if session not found", () => { + const result = cancelLoop("nonexistent") + + expect(result).toBe(false) + }) + + test("cancels and removes state", () => { + registerLoop("session-1", "test") + const result = cancelLoop("session-1") + + expect(result).toBe(true) + expect(isLoopActive("session-1")).toBe(false) + expect(getLoopState("session-1")).toBeUndefined() + }) + }) + + describe("isLoopActive", () => { + test("returns false if session not registered", () => { + expect(isLoopActive("nonexistent")).toBe(false) + }) + + test("returns true if session registered", () => { + registerLoop("session-1", "test") + + expect(isLoopActive("session-1")).toBe(true) + }) + + test("returns false after cancellation", () => { + registerLoop("session-1", "test") + cancelLoop("session-1") + + expect(isLoopActive("session-1")).toBe(false) + }) + }) + + describe("getLoopState", () => { + test("returns undefined if session not found", () => { + expect(getLoopState("nonexistent")).toBeUndefined() + }) + + test("returns state if session exists", () => { + const state = registerLoop("session-1", "test") + + expect(getLoopState("session-1")).toBe(state) + }) + }) + + describe("parseRalphLoopArgs", () => { + test("parses prompt without options", () => { + const result = parseRalphLoopArgs(["read", "the", "todo", "file", "and", "execute", "it"]) + + expect(result.prompt).toBe("read the todo file and execute it") + expect(result.completionPromise).toBeUndefined() + expect(result.maxIterations).toBeUndefined() + }) + + test("parses prompt with completion promise", () => { + const result = parseRalphLoopArgs(["--completion-promise", "DONE", "read", "the", "todo", "file"]) + + expect(result.prompt).toBe("read the todo file") + expect(result.completionPromise).toBe("DONE") + }) + + test("parses prompt with max iterations", () => { + const result = parseRalphLoopArgs(["read", "the", "todo", "file", "--max-iterations", "10"]) + + expect(result.prompt).toBe("read the todo file") + expect(result.maxIterations).toBe(10) + }) + + test("parses all options", () => { + const result = parseRalphLoopArgs([ + "--max-iterations", + "10", + "read", + "the", + "todo", + "file", + "--completion-promise", + "DONE", + ]) + + expect(result.prompt).toBe("read the todo file") + expect(result.completionPromise).toBe("DONE") + expect(result.maxIterations).toBe(10) + }) + + test("parses short option aliases", () => { + const result = parseRalphLoopArgs(["test", "-p", "DONE", "-m", "10"]) + + expect(result.prompt).toBe("test") + expect(result.completionPromise).toBe("DONE") + expect(result.maxIterations).toBe(10) + }) + + test("treats tokens after -- as prompt", () => { + const result = parseRalphLoopArgs(["read", "this", "--", "--max-iterations", "not", "a", "flag"]) + + expect(result.prompt).toBe("read this --max-iterations not a flag") + expect(result.maxIterations).toBeUndefined() + expect(result.completionPromise).toBeUndefined() + }) + + test("rejects invalid max iterations", () => { + expect(() => parseRalphLoopArgs(["--max-iterations", "nope", "read", "the", "todo"])).toThrow( + /Invalid max iterations/, + ) + expect(() => parseRalphLoopArgs(["--max-iterations"])).toThrow(/Invalid max iterations/) + expect(() => parseRalphLoopArgs(["--max-iterations", "0", "read", "the", "todo"])).toThrow( + /Invalid max iterations/, + ) + }) + }) + + describe("RalphLoop hook", () => { + test("returns early if session not registered", async () => { + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "nonexistent", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + }) + + test("injects prompt on first call", async () => { + registerLoop("session-1", "read the todo file and execute it") + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBeGreaterThan(0) + expect(output.injectedTexts[0]).toContain("Ralph Loop 1/20") + expect(output.injectedTexts[0]).toContain("read the todo file and execute it") + }) + + test("increments count on subsequent calls", async () => { + registerLoop("session-1", "read the todo file and execute it") + let state = getLoopState("session-1")! + state.iterationCount = 0 + state.lastUserID = "user-1" + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + state = getLoopState("session-1")! + expect(state.iterationCount).toBe(1) + expect(output.injectedTexts.length).toBeGreaterThan(0) + }) + + test("stops when completion promise with XML tags is found", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + }) + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "I checked and DONE", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + expect(isLoopActive("session-1")).toBe(false) + }) + + test("case insensitive XML tag matching", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + }) + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "I checked and done", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + expect(isLoopActive("session-1")).toBe(false) + }) + + test("continues when assistant text doesn't contain completion tag", async () => { + registerLoop("session-1", { + prompt: "read the todo file and execute it", + completionPromise: "DONE", + }) + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "I'm still working on it...", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBeGreaterThan(0) + expect(output.injectedTexts[0]).toContain("read the todo file and execute it") + expect(output.injectedTexts[0]).toContain("DONE") + }) + + test("stops without injection when max iterations reached", async () => { + registerLoop("session-1", "continue task") + let state = getLoopState("session-1")! + state.iterationCount = state.maxIterations + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + expect(isLoopActive("session-1")).toBe(false) + }) + + test("stops without injection when absolute limit reached", async () => { + registerLoop("session-1", "continue task") + let state = getLoopState("session-1")! + state.iterationCount = 100 + state.maxIterations = 200 // Override to test ABSOLUTE_MAX check + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + expect(isLoopActive("session-1")).toBe(false) + }) + + test("continues loop when cancelled flag is set", async () => { + registerLoop("session-1", "continue task") + let state = getLoopState("session-1")! + state.iterationCount = 0 + state.cancelled = true + + const output = { injectedTexts: [] as string[] } + + await RalphLoop["chat.waiting"]!( + { + sessionID: "session-1", + assistantText: "hello", + iterationCount: 0, + assistant: {} as any, + lastUserID: "user-1", + }, + output, + ) + + expect(output.injectedTexts.length).toBe(0) + expect(isLoopActive("session-1")).toBe(false) + }) + + test("isolates state between multiple concurrent sessions", () => { + registerLoop("session-1", "prompt-1") + registerLoop("session-2", "prompt-2") + + let state1 = getLoopState("session-1")! + let state2 = getLoopState("session-2")! + + expect(state1.prompt).toBe("prompt-1") + expect(state2.prompt).toBe("prompt-2") + expect(state1).not.toBe(state2) + + state1.iterationCount = 5 + expect(getLoopState("session-1")!.iterationCount).toBe(5) + expect(getLoopState("session-2")!.iterationCount).toBe(0) + }) + + test("handles empty prompt gracefully", () => { + const state = registerLoop("session-1", "") + + expect(state.prompt).toBe("") + }) + + test("handles whitespace-only prompt", () => { + const state = registerLoop("session-1", " \t\n ") + + expect(state.prompt).toBe(" \t\n ") + }) + + test("cancelLoop returns false when no active loop exists", () => { + const result = cancelLoop("nonexistent-session") + + expect(result).toBe(false) + expect(isLoopActive("nonexistent-session")).toBe(false) + }) + + test("cancelLoop removes session from state", () => { + registerLoop("session-1", "test") + cancelLoop("session-1") + + expect(getLoopState("session-1")).toBeUndefined() + expect(isLoopActive("session-1")).toBe(false) + }) + }) +})