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 00000000..6e2dae81
--- /dev/null
+++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts
@@ -0,0 +1,111 @@
+///
+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 {
+ 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", () => {
+ 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 95a43c28..91409153 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/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index 8492ec6a..a344a5a1 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/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts
index ab0ad39a..6e6602df 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 7d86d79e..bca4cb34 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 6cb9d28d..9e0ee3d0 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"
}
@@ -23,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
@@ -51,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,
}
diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts
index fe1e44fa..c9ef6b6c 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 bdc704f3..9e32c48a 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