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 {} }, 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[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 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 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({ content: "Task done COMPLETE" })) // #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. API_DONE" }] }, ] 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 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("CALCULATOR_DONE") }) 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 only check LAST assistant message for completion", async () => { // #given - multiple assistant messages, only first has completion promise mockSessionMessages = [ { info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] }, { info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. DONE" }] }, { info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] }, { info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] }, ] 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 (last message has no completion promise) expect(promptCalls.length).toBe(1) expect(hook.getState()?.iteration).toBe(2) }) test("should detect completion only in LAST assistant message", async () => { // #given - last assistant message has completion promise mockSessionMessages = [ { info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] }, { info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] }, { info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] }, { info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! DONE" }] }, ] 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 (last message has completion promise) 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({ content: "DONE" })) 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) }) }) describe("API timeout protection", () => { test("should not hang when session.messages() times out", async () => { // #given - slow API that takes longer than timeout const slowMock = { ...createMockPluginInput(), client: { ...createMockPluginInput().client, session: { ...createMockPluginInput().client.session, messages: async () => { // Simulate slow API (would hang without timeout) await new Promise((resolve) => setTimeout(resolve, 10000)) return { data: [] } }, }, }, } const hook = createRalphLoopHook(slowMock as any, { getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"), apiTimeout: 100, // 100ms timeout for test }) hook.startLoop("session-123", "Build something") // #when - session goes idle (API will timeout) const startTime = Date.now() await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } }, }) const elapsed = Date.now() - startTime // #then - should complete within timeout + buffer (not hang for 10s) expect(elapsed).toBeLessThan(500) // #then - loop should continue (API timeout = no completion detected) expect(promptCalls.length).toBe(1) }) }) })