fix(ralph-loop): guard compaction continuation ownership
This commit is contained in:
@@ -23,6 +23,23 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { TaskHistory } from "../../features/background-agent/task-history"
|
||||
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
noReply?: boolean
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
parts: Array<{
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: true
|
||||
metadata?: { compaction_continue?: true }
|
||||
}>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
||||
promptAsyncMock = mock(async () => ({})),
|
||||
@@ -148,7 +165,7 @@ describe("createCompactionContextInjector", () => {
|
||||
describe("agent checkpoint recovery", () => {
|
||||
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
@@ -201,27 +218,22 @@ describe("createCompactionContextInjector", () => {
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
||||
path: { id: "ses_checkpoint" },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: "/tmp/test" },
|
||||
})
|
||||
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||
expect(recoveryCall?.path).toEqual({ id: "ses_checkpoint" })
|
||||
expect(recoveryCall?.body.noReply).toBe(true)
|
||||
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||
expect(recoveryCall?.body.tools).toEqual({ bash: true })
|
||||
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
|
||||
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
|
||||
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
|
||||
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
|
||||
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
|
||||
})
|
||||
|
||||
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
@@ -273,22 +285,17 @@ describe("createCompactionContextInjector", () => {
|
||||
|
||||
//#then
|
||||
expect(restored).toBe(true)
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
||||
path: { id: "ses_autocontinue_checkpoint" },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: "/tmp/test" },
|
||||
})
|
||||
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||
expect(recoveryCall?.path).toEqual({ id: "ses_autocontinue_checkpoint" })
|
||||
expect(recoveryCall?.body.noReply).toBe(true)
|
||||
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||
expect(recoveryCall?.body.tools).toEqual({ bash: true })
|
||||
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
|
||||
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
|
||||
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
|
||||
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
|
||||
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
|
||||
})
|
||||
|
||||
it("clears stale checkpoint when the next compaction capture has no prompt config", async () => {
|
||||
@@ -314,7 +321,7 @@ describe("createCompactionContextInjector", () => {
|
||||
|
||||
it("recovers after five consecutive assistant messages with no text", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
@@ -375,15 +382,10 @@ describe("createCompactionContextInjector", () => {
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: "ses_no_text_tail" },
|
||||
body: expect.objectContaining({
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||
expect(recoveryCall?.path).toEqual({ id: "ses_no_text_tail" })
|
||||
expect(recoveryCall?.body.noReply).toBe(true)
|
||||
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,12 @@ type PromptAsyncInput = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
parts: Array<{
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: true
|
||||
metadata?: { compaction_continue?: true }
|
||||
}>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}
|
||||
@@ -96,46 +101,31 @@ describe("createCompactionContextInjector recovery", () => {
|
||||
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const checkpointedPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const incompletePromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
checkpointedPromptConfig,
|
||||
incompletePromptConfig,
|
||||
incompletePromptConfig,
|
||||
checkpointedPromptConfig,
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
@@ -157,6 +147,55 @@ describe("createCompactionContextInjector recovery", () => {
|
||||
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
|
||||
})
|
||||
|
||||
it("marks the recovery prompt as synthetic compaction continuation", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const incompletePromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
]
|
||||
const recoveredPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
recoveredPromptConfig,
|
||||
incompletePromptConfig,
|
||||
incompletePromptConfig,
|
||||
recoveredPromptConfig,
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_synthetic_recovery")
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID: "ses_synthetic_recovery" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
||||
const recoveryPart = promptAsyncRecorder.calls[0]?.body.parts[0]
|
||||
expect(recoveryPart?.synthetic).toBe(true)
|
||||
expect(recoveryPart?.metadata).toEqual({ compaction_continue: true })
|
||||
})
|
||||
|
||||
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import {
|
||||
getCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { log } from "../../shared/logger"
|
||||
import { setSessionModel } from "../../shared/session-model-state"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
@@ -88,7 +88,7 @@ export function createRecoveryLogic(
|
||||
agent: launchAgent ?? expectedPromptConfig.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user