Merge pull request #2159 from code-yeongyu/fix/ralph-loop-completion-scoping

fix(ralph-loop): scope completion detection to messages since loop start
This commit is contained in:
YeonGyu-Kim
2026-02-27 03:23:46 +09:00
committed by GitHub
8 changed files with 183 additions and 6 deletions

View File

@@ -0,0 +1,111 @@
/// <reference types="bun-types" />
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 <promise>DONE</promise>" }],
},
{
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 <promise>DONE</promise>" }],
},
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Current completion <promise>DONE</promise>" }],
},
]
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 <promise>DONE</promise>" }],
},
{
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)
})
})
})

View File

@@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages(
promise: string
apiTimeoutMs: number
directory: string
sinceMessageIndex?: number
},
): Promise<boolean> {
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)

View File

@@ -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 () => {

View File

@@ -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
},
}
}

View File

@@ -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) {

View File

@@ -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,
}

View File

@@ -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}
`

View File

@@ -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