From c133ee2afdf283fc59cba4d2c2d0cd2baac2c1c4 Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Mon, 27 Jul 2026 06:12:32 -0700 Subject: [PATCH] fix(engine): compare maxIterations and percentComplete in progressChanged progressChanged only compared phase/status/iteration/activity, so raising maxIterations mid-run changed percentComplete without triggering a customer-facing push. Compare both displayed budget axes alongside the existing fields. Closes #9323 --- packages/loopover-engine/src/loop-progress.ts | 5 ++++- test/unit/loop-progress.test.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/loopover-engine/src/loop-progress.ts b/packages/loopover-engine/src/loop-progress.ts index 73e9fada3d..d54f8856d1 100644 --- a/packages/loopover-engine/src/loop-progress.ts +++ b/packages/loopover-engine/src/loop-progress.ts @@ -78,13 +78,16 @@ function activityChanged(prev: readonly LoopProgressActivity[], next: readonly L /** True when `next` differs from `prev` in a way worth pushing to the customer — so the surface streams * ON CHANGE instead of polling on a fixed interval (#4800's acceptance). A null `prev` (the first snapshot) - * always pushes. Compares the displayed axes: phase, status, iteration, and the activity tail's contents. */ + * always pushes. Compares the displayed axes: phase, status, iteration, maxIterations, percentComplete, + * and the activity tail's contents. */ export function progressChanged(prev: ProgressSnapshot | null, next: ProgressSnapshot): boolean { if (prev === null) return true; return ( prev.phase !== next.phase || prev.status !== next.status || prev.iteration !== next.iteration || + prev.maxIterations !== next.maxIterations || + prev.percentComplete !== next.percentComplete || activityChanged(prev.recentActivity, next.recentActivity) ); } diff --git a/test/unit/loop-progress.test.ts b/test/unit/loop-progress.test.ts index b4e67244c9..f39a04363e 100644 --- a/test/unit/loop-progress.test.ts +++ b/test/unit/loop-progress.test.ts @@ -56,13 +56,22 @@ describe("progressChanged — push on change, not on a fixed interval (#4800)", expect(progressChanged(null, base)).toBe(true); }); - it("pushes when phase, status, iteration, or the activity tail changes", () => { + it("pushes when phase, status, iteration, maxIterations, percentComplete, or the activity tail changes", () => { expect(progressChanged(base, buildProgressSnapshot(running({ phase: "reviewing", recentActivity: [{ step: "a" }] })))).toBe(true); expect(progressChanged(base, buildProgressSnapshot(running({ status: "converged", recentActivity: [{ step: "a" }] })))).toBe(true); expect(progressChanged(base, buildProgressSnapshot(running({ iteration: 3, recentActivity: [{ step: "a" }] })))).toBe(true); + expect(progressChanged(base, buildProgressSnapshot(running({ maxIterations: 10, recentActivity: [{ step: "a" }] })))).toBe(true); expect(progressChanged(base, buildProgressSnapshot(running({ recentActivity: [{ step: "a" }, { step: "b" }] })))).toBe(true); }); + it("REGRESSION (#9323): pushes when maxIterations changes even if iteration/phase/status/activity are unchanged", () => { + const before = buildProgressSnapshot(running({ iteration: 2, maxIterations: 5, recentActivity: [{ step: "a" }] })); + const after = buildProgressSnapshot(running({ iteration: 2, maxIterations: 10, recentActivity: [{ step: "a" }] })); + expect(before.percentComplete).toBe(40); + expect(after.percentComplete).toBe(20); + expect(progressChanged(before, after)).toBe(true); + }); + it("does not push when nothing displayed has changed", () => { expect(progressChanged(base, buildProgressSnapshot(running({ recentActivity: [{ step: "a" }] })))).toBe(false); });