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 <gensokyo-dev@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and Ysopwnable committed 2026-11-14 10:21:04 +01:00
1 parent 6c8be105a2
commit b2b178aa36
2 files changed
+60 -2

No files matched your search

+55
View File
@@ -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(
+5 -2
View File
@@ -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),