fix(ralph-loop): only scan text parts for completion tags and handle both API shapes

Reasoning parts could contain completion-like text triggering false
positives. Also handles session.messages returning either an array
or {data: [...]} shape.
This commit is contained in:
YeonGyu-Kim
2026-02-11 00:44:21 +09:00
parent 11d1e70067
commit 88e1e3d0fa
2 changed files with 60 additions and 13 deletions

View File

@@ -63,10 +63,19 @@ export async function detectCompletionInSessionMessages(
options.apiTimeoutMs,
)
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const messagesResponse: unknown = response
const responseData =
typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse
? (messagesResponse as { data?: unknown }).data
: undefined
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
const messageArray: unknown[] = Array.isArray(messagesResponse)
? messagesResponse
: Array.isArray(responseData)
? responseData
: []
const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
@@ -74,10 +83,11 @@ export async function detectCompletionInSessionMessages(
for (const assistant of recentAssistants) {
if (!assistant.parts) continue
const responseText = assistant.parts
.filter((p) => p.type === "text" || p.type === "reasoning")
.map((p) => p.text ?? "")
.join("\n")
let responseText = ""
for (const part of assistant.parts) {
if (part.type !== "text") continue
responseText += `${responseText ? "\n" : ""}${part.text ?? ""}`
}
if (pattern.test(responseText)) {
return true

View File

@@ -1,3 +1,4 @@
/// <reference types="bun-types" />
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
@@ -12,6 +13,7 @@ describe("ralph-loop", () => {
let toastCalls: Array<{ title: string; message: string; variant: string }>
let messagesCalls: Array<{ sessionID: string }>
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
let mockMessagesApiResponseShape: "data" | "array"
function createMockPluginInput() {
return {
@@ -33,7 +35,7 @@ describe("ralph-loop", () => {
},
messages: async (opts: { path: { id: string } }) => {
messagesCalls.push({ sessionID: opts.path.id })
return { data: mockSessionMessages }
return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data: mockSessionMessages }
},
},
tui: {
@@ -56,6 +58,7 @@ describe("ralph-loop", () => {
toastCalls = []
messagesCalls = []
mockSessionMessages = []
mockMessagesApiResponseShape = "data"
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
@@ -511,7 +514,37 @@ describe("ralph-loop", () => {
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("should detect completion promise in reasoning part via session messages API", async () => {
test("should detect completion promise via session messages API when API returns array", async () => {
// given - active loop with assistant message containing completion promise
mockMessagesApiResponseShape = "array"
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. <promise>API_DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop completed via API detection, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
// then - messages API was called with correct session ID
expect(messagesCalls.length).toBe(1)
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("should ignore completion promise in reasoning part via session messages API", async () => {
//#given - active loop with assistant reasoning containing completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
@@ -527,6 +560,7 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", {
completionPromise: "REASONING_DONE",
maxIterations: 10,
})
//#when - session goes idle
@@ -537,10 +571,13 @@ describe("ralph-loop", () => {
},
})
//#then - loop completed via API detection, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
//#then - completion promise in reasoning is ignored, continuation injected
expect(promptCalls.length).toBe(1)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(false)
const state = hook.getState()
expect(state).not.toBeNull()
expect(state?.iteration).toBe(2)
})
test("should handle multiple iterations correctly", async () => {