Files
oh-my-openagent/src/hooks/ralph-loop/index.test.ts

972 lines
35 KiB
TypeScript

import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createRalphLoopHook } from "./index"
import { readState, writeState, clearState } from "./storage"
import type { RalphLoopState } from "./types"
describe("ralph-loop", () => {
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
let promptCalls: Array<{ sessionID: string; text: string }>
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 }> }>
function createMockPluginInput() {
return {
client: {
session: {
prompt: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({
sessionID: opts.path.id,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({
sessionID: opts.path.id,
text: opts.body.parts[0].text,
})
return {}
},
messages: async (opts: { path: { id: string } }) => {
messagesCalls.push({ sessionID: opts.path.id })
return { data: mockSessionMessages }
},
},
tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
variant: opts.body.variant,
})
return {}
},
},
},
directory: TEST_DIR,
} as unknown as Parameters<typeof createRalphLoopHook>[0]
}
beforeEach(() => {
promptCalls = []
toastCalls = []
messagesCalls = []
mockSessionMessages = []
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
}
clearState(TEST_DIR)
})
afterEach(() => {
clearState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
})
describe("storage", () => {
test("should write and read state correctly", () => {
// given - a state object
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build a REST API",
session_id: "test-session-123",
}
// when - write and read state
const writeSuccess = writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// then - state should match
expect(writeSuccess).toBe(true)
expect(readResult).not.toBeNull()
expect(readResult?.active).toBe(true)
expect(readResult?.iteration).toBe(1)
expect(readResult?.max_iterations).toBe(50)
expect(readResult?.completion_promise).toBe("DONE")
expect(readResult?.prompt).toBe("Build a REST API")
expect(readResult?.session_id).toBe("test-session-123")
})
test("should handle ultrawork field", () => {
// given - a state object with ultrawork enabled
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build a REST API",
session_id: "test-session-123",
ultrawork: true,
}
// when - write and read state
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// then - ultrawork field should be preserved
expect(readResult?.ultrawork).toBe(true)
})
test("should return null for non-existent state", () => {
// given - no state file exists
// when - read state
const result = readState(TEST_DIR)
// then - should return null
expect(result).toBeNull()
})
test("should clear state correctly", () => {
// given - existing state
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Test prompt",
}
writeState(TEST_DIR, state)
// when - clear state
const clearSuccess = clearState(TEST_DIR)
const readResult = readState(TEST_DIR)
// then - state should be cleared
expect(clearSuccess).toBe(true)
expect(readResult).toBeNull()
})
test("should handle multiline prompts", () => {
// given - state with multiline prompt
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 10,
completion_promise: "FINISHED",
started_at: "2025-12-30T02:00:00Z",
prompt: "Build a feature\nwith multiple lines\nand requirements",
}
// when - write and read
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// then - multiline prompt preserved
expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
})
})
describe("hook", () => {
test("should start loop and write state", () => {
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// when - start loop
const success = hook.startLoop("session-123", "Build something", {
maxIterations: 25,
completionPromise: "FINISHED",
})
// then - state should be written
expect(success).toBe(true)
const state = hook.getState()
expect(state?.active).toBe(true)
expect(state?.iteration).toBe(1)
expect(state?.max_iterations).toBe(25)
expect(state?.completion_promise).toBe("FINISHED")
expect(state?.prompt).toBe("Build something")
expect(state?.session_id).toBe("session-123")
})
test("should accept ultrawork option in startLoop", () => {
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// when - start loop with ultrawork
hook.startLoop("session-123", "Build something", { ultrawork: true })
// then - state should have ultrawork=true
const state = hook.getState()
expect(state?.ultrawork).toBe(true)
})
test("should handle missing ultrawork option in startLoop", () => {
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// when - start loop without ultrawork
hook.startLoop("session-123", "Build something")
// then - state should have ultrawork=undefined
const state = hook.getState()
expect(state?.ultrawork).toBeUndefined()
})
test("should inject continuation when loop active and no completion detected", async () => {
// given - active loop state
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - continuation should be injected
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
expect(promptCalls[0].text).toContain("RALPH LOOP")
expect(promptCalls[0].text).toContain("Build a feature")
expect(promptCalls[0].text).toContain("2/10")
// then - iteration should be incremented
const state = hook.getState()
expect(state?.iteration).toBe(2)
})
test("should stop loop when max iterations reached", async () => {
// given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
const state = hook.getState()!
state.iteration = 2
writeState(TEST_DIR, state)
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - no continuation injected
expect(promptCalls.length).toBe(0)
// then - warning toast shown
expect(toastCalls.length).toBe(1)
expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
expect(toastCalls[0].variant).toBe("warning")
// then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should cancel loop via cancelLoop", () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// when - cancel loop
const success = hook.cancelLoop("session-123")
// then - loop cancelled
expect(success).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should not cancel loop for different session", () => {
// given - active loop for session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// when - try to cancel for different session
const success = hook.cancelLoop("session-456")
// then - cancel should fail
expect(success).toBe(false)
expect(hook.getState()).not.toBeNull()
})
test("should skip injection during recovery", async () => {
// given - active loop and session in recovery
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-123", error: new Error("test") },
},
})
// when - session goes idle immediately
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should clear state on session deletion", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// when - session deleted
await hook.event({
event: {
type: "session.deleted",
properties: { info: { id: "session-123" } },
},
})
// then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should not inject for different session than loop owner", async () => {
// given - loop owned by session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// when - different session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-456" },
},
})
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should clear orphaned state when original session no longer exists", async () => {
// given - state file exists from a previous session that no longer exists
const state: RalphLoopState = {
active: true,
iteration: 3,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "orphaned-session-999", // This session no longer exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return false for the orphaned session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Orphaned session doesn't exist, current session does
return sessionID !== "orphaned-session-999"
},
})
// when - a new session goes idle (different from the orphaned session in state)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// then - orphaned state should be cleared
expect(hook.getState()).toBeNull()
// then - no continuation injected (state was cleared, not resumed)
expect(promptCalls.length).toBe(0)
})
test("should NOT clear state when original session still exists (different active session)", async () => {
// given - state file exists from a session that still exists
const state: RalphLoopState = {
active: true,
iteration: 2,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "active-session-123", // This session still exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return true for the active session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Original session still exists
return sessionID === "active-session-123" || sessionID === "new-session-456"
},
})
// when - a different session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// then - state should NOT be cleared (original session still active)
expect(hook.getState()).not.toBeNull()
expect(hook.getState()?.session_id).toBe("active-session-123")
// then - no continuation injected (it's a different session's loop)
expect(promptCalls.length).toBe(0)
})
test("should use default config values", () => {
// given - hook with config
const hook = createRalphLoopHook(createMockPluginInput(), {
config: {
enabled: true,
default_max_iterations: 200,
},
})
// when - start loop without options
hook.startLoop("session-123", "Test task")
// then - should use config defaults
const state = hook.getState()
expect(state?.max_iterations).toBe(200)
})
test("should not inject when no loop is active", async () => {
// given - no active loop
const hook = createRalphLoopHook(createMockPluginInput())
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should detect completion promise and stop loop", async () => {
// given - active loop with transcript containing completion
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "Task done <promise>COMPLETE</promise>" } }) + "\n")
// when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop completed, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should detect completion promise via session messages API", async () => {
// given - active loop with assistant message containing completion promise
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 detect 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" }] },
{
info: { role: "assistant" },
parts: [
{ type: "reasoning", text: "I am done now. <promise>REASONING_DONE</promise>" },
],
},
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", {
completionPromise: "REASONING_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()
})
test("should handle multiple iterations correctly", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
// when - multiple idle events
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - iteration incremented correctly
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
})
test("should include prompt and promise in continuation message", async () => {
// given - loop with specific prompt and promise
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Create a calculator app", {
completionPromise: "CALCULATOR_DONE",
maxIterations: 10,
})
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - continuation includes original task and promise
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
expect(hook.getState()).not.toBeNull()
// when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError", message: "User aborted" },
},
},
})
// then - loop state should be cleared immediately
expect(hook.getState()).toBeNull()
})
test("should NOT set recovery mode on user abort", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
// when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError" },
},
},
})
// Start a new loop
hook.startLoop("session-123", "New task")
// when - session goes idle immediately (should work, no recovery mode)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - continuation should be injected (not blocked by recovery)
expect(promptCalls.length).toBe(1)
})
test("should check last 3 assistant messages for completion", async () => {
// given - multiple assistant messages, promise in recent (not last) assistant message
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on it." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Nearly there... <promise>DONE</promise>" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "(extra output after promise)" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should complete (promise found within last 3 assistant messages)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
// given - promise appears in an assistant message older than last 3
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 1" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 2" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 3" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should continue (promise is older than last 3 assistant messages)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should allow starting new loop while previous loop is active (different session)", async () => {
// given - active loop in session A
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("First task")
// when - start new loop in session B (without completing A)
hook.startLoop("session-B", "Second task", { maxIterations: 20 })
// then - state should be overwritten with session B's loop
expect(hook.getState()?.session_id).toBe("session-B")
expect(hook.getState()?.prompt).toBe("Second task")
expect(hook.getState()?.max_iterations).toBe(20)
expect(hook.getState()?.iteration).toBe(1)
// when - session B goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-B" } },
})
// then - continuation should be injected for session B
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-B")
expect(promptCalls[0].text).toContain("Second task")
expect(promptCalls[0].text).toContain("2/20")
// then - iteration incremented
expect(hook.getState()?.iteration).toBe(2)
})
test("should allow starting new loop in same session (restart)", async () => {
// given - active loop in session A at iteration 5
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
// Simulate some iterations
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
// when - start NEW loop in same session (restart)
hook.startLoop("session-A", "Restarted task", { maxIterations: 50 })
// then - state should be reset to iteration 1 with new prompt
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("Restarted task")
expect(hook.getState()?.max_iterations).toBe(50)
expect(hook.getState()?.iteration).toBe(1)
// when - session goes idle
promptCalls = [] // Reset to check new continuation
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
// then - continuation should use new task
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("Restarted task")
expect(promptCalls[0].text).toContain("2/50")
})
test("should NOT detect completion from user message in transcript (issue #622)", async () => {
// given - transcript contains user message with template text that includes completion promise
// This reproduces the bug where the RALPH_LOOP_TEMPLATE instructional text
// containing `<promise>DONE</promise>` is recorded as a user message and
// falsely triggers completion detection
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const templateText = `You are starting a Ralph Loop...
Output <promise>DONE</promise> when fully complete`
const userEntry = JSON.stringify({
type: "user",
timestamp: new Date().toISOString(),
content: templateText,
})
writeFileSync(transcriptPath, userEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop should CONTINUE (user message completion promise is instructional, not actual)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should NOT detect completion from continuation prompt in transcript (issue #622)", async () => {
// given - transcript contains continuation prompt (also a user message) with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const continuationText = `RALPH LOOP 2/100
When FULLY complete, output: <promise>DONE</promise>
Original task: Build something`
const userEntry = JSON.stringify({
type: "user",
timestamp: new Date().toISOString(),
content: continuationText,
})
writeFileSync(transcriptPath, userEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop should CONTINUE (continuation prompt text is not actual completion)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion from tool_result entry in transcript", async () => {
// given - transcript contains a tool_result with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const toolResultEntry = JSON.stringify({
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: "write",
tool_input: {},
tool_output: { output: "Task complete! <promise>DONE</promise>" },
})
writeFileSync(transcriptPath, toolResultEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop should complete (tool_result contains actual completion output)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - should complete via transcript (API not called when transcript succeeds)
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
expect(messagesCalls.length).toBe(0)
})
test("should show ultrawork completion toast", async () => {
// given - hook with ultrawork mode and completion in transcript
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API", { ultrawork: true })
// when - idle event triggered
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
// then - ultrawork toast shown
const completionToast = toastCalls.find(t => t.title === "ULTRAWORK LOOP COMPLETE!")
expect(completionToast).toBeDefined()
expect(completionToast!.message).toMatch(/JUST ULW ULW!/)
})
test("should show regular completion toast when ultrawork disabled", async () => {
// given - hook without ultrawork
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API")
// when - idle event triggered
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
// then - regular toast shown
expect(toastCalls.some(t => t.title === "Ralph Loop Complete!")).toBe(true)
})
test("should prepend ultrawork to continuation prompt when ultrawork=true", async () => {
// given - hook with ultrawork mode enabled
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build API", { ultrawork: true })
// when - session goes idle (continuation triggered)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - prompt should start with "ultrawork "
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toMatch(/^ultrawork /)
})
test("should NOT prepend ultrawork to continuation prompt when ultrawork=false", async () => {
// given - hook without ultrawork mode
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build API")
// when - session goes idle (continuation triggered)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - prompt should NOT start with "ultrawork "
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).not.toMatch(/^ultrawork /)
})
})
describe("API timeout protection", () => {
test("should not hang when session.messages() throws", async () => {
// given - API that throws (simulates timeout error)
let apiCallCount = 0
const errorMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
apiCallCount++
throw new Error("API timeout")
},
},
},
}
const hook = createRalphLoopHook(errorMock as any, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100,
})
hook.startLoop("session-123", "Build something")
// when - session goes idle (API will throw)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// then - should complete quickly (not hang for 10s)
expect(elapsed).toBeLessThan(6000)
// then - loop should continue (API error = no completion detected)
expect(promptCalls.length).toBe(1)
expect(apiCallCount).toBeGreaterThan(0)
})
})
})