fix(ralph-loop): scope completion detection to messages since loop start

This commit is contained in:
YeonGyu-Kim
2026-02-27 03:05:14 +09:00
parent cf97494073
commit e17a00a906
7 changed files with 142 additions and 2 deletions

View File

@@ -0,0 +1,105 @@
/// <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 {
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 <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

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

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