fix: tolerate recovered CE question ids (#1855)

## Summary
- add an opt-in `allowAnswerQuestionIdDrift` flag for interactive AI
sessions
- keep strict question-id validation by default
- enable the tolerance only for Compound Engineering recovered sessions
so persisted session rows can answer after dashboard
restarts/non-deterministic rehydration

## Test Plan
- `corepack pnpm --filter @fusion/engine exec vitest run
src/__tests__/interactive-ai-session.test.ts --silent=passed-only
--reporter=dot`
- `corepack pnpm --filter @fusion/engine typecheck`
- `corepack pnpm --filter @fusion-plugin-examples/compound-engineering
build`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved interactive session recovery so persisted answers can
continue after dashboard restarts, even if the question ID changes
during rehydration.
* Keeps strict question-ID validation by default; mismatches still fail
unless drift is explicitly allowed.
* **New Features**
* Added `allowAnswerQuestionIdDrift` option to permit accepting the
persisted question ID during recovered session answering.
* **Tests**
* Added/updated coverage for strict mismatch error behavior and the
successful completion path when drift is enabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-01 15:21:17 -07:00
committed by GitHub
6 changed files with 69 additions and 2 deletions

View File

@@ -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), {

View File

@@ -444,7 +444,7 @@ export async function createInteractiveAiSessionWith(
});
return;
}
if (currentQuestion && questionId !== currentQuestion.id) {
if (currentQuestion && questionId !== currentQuestion.id && !options.allowAnswerQuestionIdDrift) {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
type: "error",
data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` },