feat(ralph-loop): add strategy option for fresh context per iteration

Closes #1901

Add 'default_strategy' config option (default: 'continue') to control whether ralph-loop creates a new session per iteration ('reset') or keeps the same session ('continue'). The 'reset' strategy keeps the model in the smart zone by starting with fresh context for each iteration.

Supports --strategy flag for per-command override.
This commit is contained in:
YeonGyu-Kim
2026-02-21 02:27:57 +09:00
parent 14edde17c5
commit e7697567d5
16 changed files with 323 additions and 49 deletions
+92
View File
@@ -6,12 +6,14 @@ import { tmpdir } from "node:os"
import { createRalphLoopHook } from "./index"
import { readState, writeState, clearState } from "./storage"
import type { RalphLoopState } from "./types"
import { parseRalphLoopArguments } from "./command-arguments"
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 createSessionCalls: Array<{ parentID?: string; title?: string; directory?: string }>
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
let mockMessagesApiResponseShape: "data" | "array"
@@ -37,6 +39,17 @@ describe("ralph-loop", () => {
messagesCalls.push({ sessionID: opts.path.id })
return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data: mockSessionMessages }
},
create: async (opts: {
body: { parentID?: string; title?: string }
query?: { directory?: string }
}) => {
createSessionCalls.push({
parentID: opts.body.parentID,
title: opts.body.title,
directory: opts.query?.directory,
})
return { data: { id: `new-session-${createSessionCalls.length}` } }
},
},
tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
@@ -57,6 +70,7 @@ describe("ralph-loop", () => {
promptCalls = []
toastCalls = []
messagesCalls = []
createSessionCalls = []
mockSessionMessages = []
mockMessagesApiResponseShape = "data"
@@ -123,6 +137,26 @@ describe("ralph-loop", () => {
expect(readResult?.ultrawork).toBe(true)
})
test("should store and read strategy field", () => {
// given - a state object with strategy
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",
strategy: "reset",
}
// when - write and read state
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// then - strategy should be preserved
expect(readResult?.strategy).toBe("reset")
})
test("should return null for non-existent state", () => {
// given - no state file exists
// when - read state
@@ -173,6 +207,32 @@ describe("ralph-loop", () => {
})
})
describe("command arguments", () => {
test("should parse --strategy=reset flag", () => {
// given - ralph-loop command arguments with reset strategy
const rawArguments = '"Build feature X" --strategy=reset --max-iterations=12'
// when - parse command arguments
const parsedArguments = parseRalphLoopArguments(rawArguments)
// then - strategy should be parsed as reset
expect(parsedArguments.strategy).toBe("reset")
expect(parsedArguments.prompt).toBe("Build feature X")
expect(parsedArguments.maxIterations).toBe(12)
})
test("should parse --strategy=continue flag", () => {
// given - ralph-loop command arguments with continue strategy
const rawArguments = '"Build feature X" --strategy=continue'
// when - parse command arguments
const parsedArguments = parseRalphLoopArguments(rawArguments)
// then - strategy should be parsed as continue
expect(parsedArguments.strategy).toBe("continue")
})
})
describe("hook", () => {
test("should start loop and write state", () => {
// given - hook instance
@@ -445,6 +505,38 @@ describe("ralph-loop", () => {
expect(state?.max_iterations).toBe(200)
})
test("should default strategy to continue when not specified", () => {
// given - hook with no strategy option
const hook = createRalphLoopHook(createMockPluginInput())
// when - start loop without strategy
hook.startLoop("session-123", "Test task")
// then - strategy should default to continue
const state = hook.getState()
expect(state?.strategy).toBe("continue")
})
test("should create new session for reset strategy", async () => {
// given - hook with reset strategy
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build a feature", { strategy: "reset" })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - new session should be created and continuation injected there
expect(createSessionCalls.length).toBe(1)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("new-session-1")
expect(hook.getState()?.session_id).toBe("new-session-1")
})
test("should not inject when no loop is active", async () => {
// given - no active loop
const hook = createRalphLoopHook(createMockPluginInput())