From e17a00a906475baed4acbd25c7117c3bbb230c80 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 27 Feb 2026 03:05:14 +0900 Subject: [PATCH 1/3] fix(ralph-loop): scope completion detection to messages since loop start --- .../completion-promise-detector.test.ts | 105 ++++++++++++++++++ .../ralph-loop/completion-promise-detector.ts | 8 +- src/hooks/ralph-loop/loop-state-controller.ts | 16 +++ .../ralph-loop/ralph-loop-event-handler.ts | 1 + src/hooks/ralph-loop/ralph-loop-hook.ts | 1 + src/hooks/ralph-loop/storage.ts | 12 +- src/hooks/ralph-loop/types.ts | 1 + 7 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/hooks/ralph-loop/completion-promise-detector.test.ts diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts new file mode 100644 index 000000000..7f6307829 --- /dev/null +++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts @@ -0,0 +1,105 @@ +/// +import { describe, expect, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { detectCompletionInSessionMessages } from "./completion-promise-detector" + +type SessionMessage = { + info?: { role?: string } + parts?: Array<{ type: string; text?: string }> +} + +function createPluginInput(messages: SessionMessage[]): PluginInput { + return { + client: { + session: { + messages: async () => ({ data: messages }), + }, + }, + } as PluginInput +} + +describe("detectCompletionInSessionMessages", () => { + describe("#given session with prior DONE and new messages", () => { + test("#when sinceMessageIndex excludes prior DONE #then should NOT detect completion", async () => { + // #given + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Old completion DONE" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Working on the new task" }], + }, + ] + const ctx = createPluginInput(messages) + + // #when + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "DONE", + apiTimeoutMs: 1000, + directory: "/tmp", + sinceMessageIndex: 1, + }) + + // #then + expect(detected).toBe(false) + }) + + test("#when sinceMessageIndex includes current DONE #then should detect completion", async () => { + // #given + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Old completion DONE" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Current completion DONE" }], + }, + ] + const ctx = createPluginInput(messages) + + // #when + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "DONE", + apiTimeoutMs: 1000, + directory: "/tmp", + sinceMessageIndex: 1, + }) + + // #then + expect(detected).toBe(true) + }) + }) + + describe("#given no sinceMessageIndex (backward compat)", () => { + test("#then should scan all messages", async () => { + // #given + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Old completion DONE" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "No completion in latest message" }], + }, + ] + const ctx = createPluginInput(messages) + + // #when + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "DONE", + apiTimeoutMs: 1000, + directory: "/tmp", + }) + + // #then + expect(detected).toBe(true) + }) + }) +}) diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index 95a43c289..914091536 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages( promise: string apiTimeoutMs: number directory: string + sinceMessageIndex?: number }, ): Promise { try { @@ -75,7 +76,12 @@ export async function detectCompletionInSessionMessages( ? responseData : [] - const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") + const scopedMessages = + typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length + ? messageArray.slice(options.sinceMessageIndex) + : messageArray + + const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") if (assistantMessages.length === 0) return false const pattern = buildPromisePattern(options.promise) diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index ab0ad39a5..6e6602df9 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -23,6 +23,7 @@ export function createLoopStateController(options: { loopOptions?: { maxIterations?: number completionPromise?: string + messageCountAtStart?: number ultrawork?: boolean strategy?: "reset" | "continue" }, @@ -34,6 +35,7 @@ export function createLoopStateController(options: { loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, + message_count_at_start: loopOptions?.messageCountAtStart, completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, @@ -93,5 +95,19 @@ export function createLoopStateController(options: { return state }, + + setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null { + const state = readState(directory, stateDir) + if (!state || state.session_id !== sessionID) { + return null + } + + state.message_count_at_start = messageCountAtStart + if (!writeState(directory, state, stateDir)) { + return null + } + + return state + }, } } diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 7d86d79eb..bca4cb34e 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -84,6 +84,7 @@ export function createRalphLoopEventHandler( promise: state.completion_promise, apiTimeoutMs: options.apiTimeoutMs, directory: options.directory, + sinceMessageIndex: state.message_count_at_start, }) if (completionViaTranscript || completionViaApi) { diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 6cb9d28d2..9c069ee87 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -13,6 +13,7 @@ export interface RalphLoopHook { options?: { maxIterations?: number completionPromise?: string + messageCountAtStart?: number ultrawork?: boolean strategy?: "reset" | "continue" } diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts index fe1e44fa6..c9ef6b6ca 100644 --- a/src/hooks/ralph-loop/storage.ts +++ b/src/hooks/ralph-loop/storage.ts @@ -44,6 +44,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat active: isActive, iteration: iterationNum, max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS, + message_count_at_start: + typeof data.message_count_at_start === "number" + ? data.message_count_at_start + : typeof data.message_count_at_start === "string" && data.message_count_at_start.trim() !== "" + ? Number(data.message_count_at_start) + : undefined, completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE, started_at: stripQuotes(data.started_at) || new Date().toISOString(), prompt: body.trim(), @@ -72,13 +78,17 @@ export function writeState( const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : "" const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : "" const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : "" + const messageCountAtStartLine = + typeof state.message_count_at_start === "number" + ? `message_count_at_start: ${state.message_count_at_start}\n` + : "" const content = `--- active: ${state.active} iteration: ${state.iteration} max_iterations: ${state.max_iterations} completion_promise: "${state.completion_promise}" started_at: "${state.started_at}" -${sessionIdLine}${ultraworkLine}${strategyLine}--- +${sessionIdLine}${ultraworkLine}${strategyLine}${messageCountAtStartLine}--- ${state.prompt} ` diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts index bdc704f34..9e32c48a2 100644 --- a/src/hooks/ralph-loop/types.ts +++ b/src/hooks/ralph-loop/types.ts @@ -4,6 +4,7 @@ export interface RalphLoopState { active: boolean iteration: number max_iterations: number + message_count_at_start?: number completion_promise: string started_at: string prompt: string From 190c6991ac80fe8100335cc15e44c7ae356a0d9c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 27 Feb 2026 03:08:30 +0900 Subject: [PATCH 2/3] fix(ralph-loop): persist session message count at loop start --- src/hooks/ralph-loop/index.test.ts | 6 ++--- src/hooks/ralph-loop/ralph-loop-hook.ts | 33 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 8492ec6ae..a344a5a14 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -603,7 +603,7 @@ describe("ralph-loop", () => { expect(hook.getState()).toBeNull() // then - messages API was called with correct session ID - expect(messagesCalls.length).toBe(1) + expect(messagesCalls.length).toBe(2) expect(messagesCalls[0].sessionID).toBe("session-123") }) @@ -633,7 +633,7 @@ describe("ralph-loop", () => { expect(hook.getState()).toBeNull() // then - messages API was called with correct session ID - expect(messagesCalls.length).toBe(1) + expect(messagesCalls.length).toBe(2) expect(messagesCalls[0].sessionID).toBe("session-123") }) @@ -1075,7 +1075,7 @@ Original task: Build something` expect(promptCalls.length).toBe(0) expect(hook.getState()).toBeNull() // API should NOT be called since transcript found completion - expect(messagesCalls.length).toBe(0) + expect(messagesCalls.length).toBe(1) }) test("should show ultrawork completion toast", async () => { diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 9c069ee87..9e0ee3d04 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -24,6 +24,19 @@ export interface RalphLoopHook { const DEFAULT_API_TIMEOUT = 5000 as const +function getMessageCountFromResponse(messagesResponse: unknown): number { + if (Array.isArray(messagesResponse)) { + return messagesResponse.length + } + + if (typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse) { + const data = (messagesResponse as { data?: unknown }).data + return Array.isArray(data) ? data.length : 0 + } + + return 0 +} + export function createRalphLoopHook( ctx: PluginInput, options?: RalphLoopOptions @@ -52,7 +65,25 @@ export function createRalphLoopHook( return { event, - startLoop: loopState.startLoop, + startLoop: (sessionID, prompt, loopOptions): boolean => { + const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions) + if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") { + return startSuccess + } + + ctx.client.session + .messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + .then((messagesResponse: unknown) => { + const messageCountAtStart = getMessageCountFromResponse(messagesResponse) + loopState.setMessageCountAtStart(sessionID, messageCountAtStart) + }) + .catch(() => {}) + + return startSuccess + }, cancelLoop: loopState.cancelLoop, getState: loopState.getState as () => RalphLoopState | null, } From f2a1412bf165a47d939bd25a87c52dfbbe4c879d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 27 Feb 2026 03:12:22 +0900 Subject: [PATCH 3/3] test(ralph-loop): harden completion detector PluginInput mock --- .../completion-promise-detector.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts index 7f6307829..6e2dae816 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.test.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts @@ -9,13 +9,19 @@ type SessionMessage = { } function createPluginInput(messages: SessionMessage[]): PluginInput { - return { - client: { - session: { - messages: async () => ({ data: messages }), - }, - }, + const pluginInput = { + client: { session: {} } as PluginInput["client"], + project: {} as PluginInput["project"], + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost"), + $: {} as PluginInput["$"], } as PluginInput + + pluginInput.client.session.messages = + (async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"] + + return pluginInput } describe("detectCompletionInSessionMessages", () => {