diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index d041d94466..671dd7c8d7 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -188,8 +188,8 @@ export interface CreateInteractiveAiSessionOptions { /** * 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. + * Default remains strict for fresh planning/CE sessions; recovery paths may + * enable this when the persisted session row is the authoritative anchor. */ allowAnswerQuestionIdDrift?: boolean; } diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts index 55f002ebfb..d2e46d6917 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts @@ -155,6 +155,7 @@ describe("interrupt + resume (no silent loss)", () => { expect(done.event?.type).toBe("complete"); expect(done.session.status).toBe("completed"); expect(factory).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledWith(expect.objectContaining({ allowAnswerQuestionIdDrift: true })); expect(rehydrated.prompt).toHaveBeenCalledTimes(1); expect(rehydrated.answer).toHaveBeenCalledTimes(1); const hasAnswerTurn = done.session.conversationHistory.some( @@ -183,6 +184,7 @@ describe("interrupt + resume (no silent loss)", () => { const done = await orch.answer(started.session.id, "q1", "a"); expect(done.session.status).toBe("completed"); expect(factory).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledWith(expect.not.objectContaining({ allowAnswerQuestionIdDrift: true })); expect(live.prompt).toHaveBeenCalledTimes(1); expect(live.answer).toHaveBeenCalledTimes(1); }); @@ -276,6 +278,7 @@ describe("interrupt + resume (no silent loss)", () => { await new Promise((resolve) => setImmediate(resolve)); const after = store.get(created.id)!; expect(after.status).toBe("completed"); + expect(rehydrated.answer).toHaveBeenCalledTimes(1); const hasAnswerTurn = after.conversationHistory.some( (t) => t.text === JSON.stringify({ answer: "a", questionId: "q1" }), ); diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index b0489a53b6..c1f376b5e0 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -291,6 +291,7 @@ export class CeOrchestrator { private buildSessionOptions( stage: CeStageDefinition, sessionId: string, + opts: { allowAnswerQuestionIdDrift?: boolean } = {}, ): Parameters[0] { const defaultProvider = getDefaultProvider(this.ctx.settings); const defaultModelId = getDefaultModelId(this.ctx.settings); @@ -302,7 +303,11 @@ export class CeOrchestrator { tools: "coding", requestedSkillNames: [stage.skillId], additionalSkillPaths, - allowAnswerQuestionIdDrift: true, + /* + * FNXC:CompoundEngineering 2026-07-01-17:31: + * Question-id drift tolerance is recovery-only. Fresh CE sessions keep the strict interactive seam guard so live/DB question divergence is surfaced immediately; rehydration enables the tolerance because the persisted session row is the recovery anchor after a restart. + */ + ...(opts.allowAnswerQuestionIdDrift ? { allowAnswerQuestionIdDrift: true } : {}), onProgress: (event) => this.handleProgress(sessionId, event), ...(defaultProvider ? { defaultProvider } : {}), ...(defaultModelId ? { defaultModelId } : {}), @@ -643,7 +648,9 @@ export class CeOrchestrator { } private async rehydrateReplay(session: CeSession, stage: CeStageDefinition): Promise { - const interactive = await this.factory!(this.buildSessionOptions(stage, session.id)); + const interactive = await this.factory!( + this.buildSessionOptions(stage, session.id, { allowAnswerQuestionIdDrift: true }), + ); const live = interactive.session; // Walk the recorded user turns in order. The FIRST user turn is the opening