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:
111
src/hooks/ralph-loop/completion-promise-detector.test.ts
Normal file
111
src/hooks/ralph-loop/completion-promise-detector.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user