From b2b178aa364d744c80a4728fab924bbbf76e5214 Mon Sep 17 00:00:00 2001 From: kittimzhe <67472034+kittimzhe@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:56:10 +0800 Subject: [PATCH] fix(browser): keep duplicated scenario step ids from being classified as absent Comparing retained scenario captures flattened each side's unique step map, so a step_id duplicated on one side vanished from that side's map; the same id arriving uniquely on the other side then landed in before_only/after_only and produced a missing_before_step or missing_after_step failure for a step the duplicate side still retains. The comparison reported both the duplicate and a definite absence for the same id, a stronger claim than the ambiguity supports. Alignment now keeps a duplicated id out of the absence lists on both sides: the id stays unaligned with its duplicate failure, but no capture is described as missing a step it retains. Genuinely missing ids keep their missing_* classification and absence lists. Fixes #7415 Co-Authored-By: gensokyo-dev Co-Authored-By: Claude Opus 5.5 --- src/domain/browserScenarioDiff.test.ts | 55 ++++++++++++++++++++++++++ src/domain/browserScenarioDiff.ts | 7 +++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index 51041fd40b..bdac324b4f 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -1077,6 +1077,61 @@ describe("browser scenario comparison validation", () => { }); }); + const duplicateLastStepCapture = () => { + const base = scenarioCapture({}); + return { + ...base, + scenario: { ...base.scenario, action_count: 2 }, + steps: [...base.steps, { ...base.steps[1]!, step_index: 2 }], + }; + }; + + it("keeps a duplicated step id unaligned without classifying it as absent", () => { + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: duplicateLastStepCapture(), + after_scenario: scenarioCapture({}), + }), + ); + + expect(compared.overall_status).toBe("unknown"); + expect(compared.alignment).toMatchObject({ + status: "partial", + aligned_steps: 1, + before_only: [], + after_only: [], + failures: [ + expect.objectContaining({ + code: "duplicate_before_step_id", + step_id: "open-settings", + }), + ], + }); + }); + + it("mirrors the duplicate classification when the later capture repeats the id", () => { + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: scenarioCapture({}), + after_scenario: duplicateLastStepCapture(), + }), + ); + + expect(compared.overall_status).toBe("unknown"); + expect(compared.alignment).toMatchObject({ + status: "partial", + aligned_steps: 1, + before_only: [], + after_only: [], + failures: [ + expect.objectContaining({ + code: "duplicate_after_step_id", + step_id: "open-settings", + }), + ], + }); + }); + it("rejects duplicate normalization rule IDs", () => { const capture = scenarioCapture({}); expect( diff --git a/src/domain/browserScenarioDiff.ts b/src/domain/browserScenarioDiff.ts index e74255567f..c53184c150 100644 --- a/src/domain/browserScenarioDiff.ts +++ b/src/domain/browserScenarioDiff.ts @@ -151,11 +151,14 @@ const alignScenarioSteps = ( const afterDuplicates = duplicateStepIds(afterCapture.steps); const before = uniqueSteps(beforeCapture.steps, beforeDuplicates); const after = uniqueSteps(afterCapture.steps, afterDuplicates); + // A duplicated step_id keeps its ambiguous presence on that side: it stays + // ineligible for alignment, but its counterpart capture must not classify + // the id as absent when the duplicate side still retains it. const beforeOnly = [...before.keys()] - .filter((stepId) => !after.has(stepId)) + .filter((stepId) => !after.has(stepId) && !afterDuplicates.has(stepId)) .sort(); const afterOnly = [...after.keys()] - .filter((stepId) => !before.has(stepId)) + .filter((stepId) => !before.has(stepId) && !beforeDuplicates.has(stepId)) .sort(); const failures: BrowserScenarioAlignmentFailure[] = [ ...duplicateFailures("before", beforeDuplicates),