From ad8db59374cdf1bc3b007351f9f1ab2d6020a92e Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Wed, 1 Jul 2026 09:41:20 -0700 Subject: [PATCH] fix: tolerate recovered CE question ids --- .changeset/ce-question-id-drift.md | 7 ++++ packages/core/src/plugin-types.ts | 7 ++++ .../__tests__/interactive-ai-session.test.ts | 41 +++++++++++++++++++ packages/engine/src/interactive-ai-session.ts | 2 +- .../src/session/orchestrator.ts | 1 + 5 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/ce-question-id-drift.md diff --git a/.changeset/ce-question-id-drift.md b/.changeset/ce-question-id-drift.md new file mode 100644 index 0000000000..ac2b2e26fa --- /dev/null +++ b/.changeset/ce-question-id-drift.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow Compound Engineering recovered sessions to answer persisted questions after dashboard restarts. +category: fix +dev: Keeps strict question-id validation by default while letting CE trust its persisted session row as the recovery anchor. diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 6d438ad385..d041d94466 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -185,6 +185,13 @@ export interface CreateInteractiveAiSessionOptions { * stream. Must not throw — implementations should swallow callback errors. */ onProgress?: (event: InteractiveAiSessionProgressEvent) => void; + /** + * Trust the caller's persisted/current question id when answering, even if a + * rehydrated live handle generated a different question id while replaying. + * Default remains strict for planning surfaces; CE enables this because its + * persisted session row is the recovery anchor across dashboard restarts. + */ + allowAnswerQuestionIdDrift?: boolean; } /** diff --git a/packages/engine/src/__tests__/interactive-ai-session.test.ts b/packages/engine/src/__tests__/interactive-ai-session.test.ts index bc94a206bb..87ebd6930c 100644 --- a/packages/engine/src/__tests__/interactive-ai-session.test.ts +++ b/packages/engine/src/__tests__/interactive-ai-session.test.ts @@ -330,6 +330,47 @@ describe("interactive-ai-session seam", () => { expect(ev.type === "error" && ev.data.message).toMatch(/transport exploded/); }); + it("rejects mismatched question ids by default", async () => { + const question: PlanningQuestion = { id: "current", type: "text", question: "Current?" }; + const scripted = makeScriptedAgent([q(question), complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + expect((await session.nextEvent()).type).toBe("question"); + + await session.answer("persisted", "answer"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toContain('questionId "persisted" does not match current question "current"'); + expect(scripted.promptCalls()).toHaveLength(1); + }); + + it("can trust the caller's persisted question id after non-deterministic rehydration", async () => { + const question: PlanningQuestion = { id: "rehydrated-different", type: "text", question: "Rehydrated?" }; + const scripted = makeScriptedAgent([q(question), complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + allowAnswerQuestionIdDrift: true, + }); + + await session.prompt("start"); + expect((await session.nextEvent()).type).toBe("question"); + + await session.answer("persisted-original", "answer"); + const done = await session.nextEvent(); + expect(done.type).toBe("complete"); + const lastPrompt = scripted.promptCalls().at(-1)!; + expect(JSON.parse(lastPrompt)).toMatchObject({ + type: "answer", + questionId: "persisted-original", + response: "answer", + }); + }); + it("ignores answer() when not awaiting input", async () => { const scripted = makeScriptedAgent([complete({ ok: true })]); const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { diff --git a/packages/engine/src/interactive-ai-session.ts b/packages/engine/src/interactive-ai-session.ts index cc34dcf808..dda12de0f4 100644 --- a/packages/engine/src/interactive-ai-session.ts +++ b/packages/engine/src/interactive-ai-session.ts @@ -444,7 +444,7 @@ export async function createInteractiveAiSessionWith( }); return; } - if (currentQuestion && questionId !== currentQuestion.id) { + if (currentQuestion && questionId !== currentQuestion.id && !options.allowAnswerQuestionIdDrift) { pendingEvent = Promise.resolve({ type: "error", data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` }, diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index 40b6b7527a..b0489a53b6 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -302,6 +302,7 @@ export class CeOrchestrator { tools: "coding", requestedSkillNames: [stage.skillId], additionalSkillPaths, + allowAnswerQuestionIdDrift: true, onProgress: (event) => this.handleProgress(sessionId, event), ...(defaultProvider ? { defaultProvider } : {}), ...(defaultModelId ? { defaultModelId } : {}),