fix: tolerate recovered CE question ids
This commit is contained in:
7
.changeset/ce-question-id-drift.md
Normal file
7
.changeset/ce-question-id-drift.md
Normal file
@@ -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.
|
||||||
@@ -185,6 +185,13 @@ export interface CreateInteractiveAiSessionOptions {
|
|||||||
* stream. Must not throw — implementations should swallow callback errors.
|
* stream. Must not throw — implementations should swallow callback errors.
|
||||||
*/
|
*/
|
||||||
onProgress?: (event: InteractiveAiSessionProgressEvent) => void;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -330,6 +330,47 @@ describe("interactive-ai-session seam", () => {
|
|||||||
expect(ev.type === "error" && ev.data.message).toMatch(/transport exploded/);
|
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 () => {
|
it("ignores answer() when not awaiting input", async () => {
|
||||||
const scripted = makeScriptedAgent([complete({ ok: true })]);
|
const scripted = makeScriptedAgent([complete({ ok: true })]);
|
||||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export async function createInteractiveAiSessionWith(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentQuestion && questionId !== currentQuestion.id) {
|
if (currentQuestion && questionId !== currentQuestion.id && !options.allowAnswerQuestionIdDrift) {
|
||||||
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
|
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
|
||||||
type: "error",
|
type: "error",
|
||||||
data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` },
|
data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` },
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export class CeOrchestrator {
|
|||||||
tools: "coding",
|
tools: "coding",
|
||||||
requestedSkillNames: [stage.skillId],
|
requestedSkillNames: [stage.skillId],
|
||||||
additionalSkillPaths,
|
additionalSkillPaths,
|
||||||
|
allowAnswerQuestionIdDrift: true,
|
||||||
onProgress: (event) => this.handleProgress(sessionId, event),
|
onProgress: (event) => this.handleProgress(sessionId, event),
|
||||||
...(defaultProvider ? { defaultProvider } : {}),
|
...(defaultProvider ? { defaultProvider } : {}),
|
||||||
...(defaultModelId ? { defaultModelId } : {}),
|
...(defaultModelId ? { defaultModelId } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user