fix(ui): no-op the second reconcile of the same sessions.changed event (#124326)

Every sessions.changed is reconciled twice — once by the capability's own
subscribeEvents handler and once by the chat page's reconcileSessionEvent —
and reconcileSessionHistory always built a fresh sessions array, so the
second, content-identical pass defeated every downstream
result === state.result publish gate and re-rendered the sidebar per event.

The merge now returns the original result identity when the merged row is
content-equal to the existing row (shallow compare with structural fallback;
rows are wire scalars/plain objects). The isOlderSessionSnapshot strict-<
guard could not catch this: the duplicate pass carries the same updatedAt.

Production +31 (the equality gate); regression test proves second-pass
identity and fails pre-fix. 9082 UI tests green.
This commit is contained in:
Peter Steinberger
2026-08-15 18:11:53 -07:00
committed by GitHub
parent a8857eec0e
commit f870d93e2a
2 changed files with 58 additions and 0 deletions
+27
View File
@@ -89,6 +89,33 @@ test("sessions.changed removes a label when the event carries null", () => {
expect(reconciled.result?.sessions[0]?.displayName).toBeUndefined();
});
test("reconciling the same sessions.changed twice keeps result identity on the second pass", () => {
const result: SessionsListResult = {
ts: 1,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }],
};
const payload = {
sessionKey: "agent:main:main",
reason: "patch",
updatedAt: 2,
label: "Renamed",
};
const first = reconcileSessionChanged(result, payload);
expect(first.applied).toBe(true);
expect(first.result).not.toBe(result);
expect(first.result?.sessions[0]?.label).toBe("Renamed");
// The capability handler and the chat page both drive the same event; the
// second reconcile must return the identical result object so downstream
// result === state.result publish gates skip the duplicate re-render.
const second = reconcileSessionChanged(first.result ?? null, payload);
expect(second.result).toBe(first.result);
});
test("sessions.changed deletes every null-tombstoned field, not a hand-kept list", () => {
// The gateway tombstones more fields than the old per-field cascade knew
// about; these five leaked literal null into rows typed optional-not-null.
+31
View File
@@ -195,6 +195,27 @@ function stripThinkingMetadata<T extends ThinkingMetadataCarrier>(value: T): T {
return next;
}
/** Same-content merge detection; row values are wire scalars/plain objects, so one level suffices. */
function isShallowEqualSessionRow(
incoming: GatewaySessionRow,
existing: GatewaySessionRow,
): boolean {
const incomingKeys = Object.keys(incoming);
if (incomingKeys.length !== Object.keys(existing).length) {
return false;
}
return incomingKeys.every((key) => {
const a = (incoming as Record<string, unknown>)[key];
const b = (existing as Record<string, unknown>)[key];
return (
a === b ||
(a !== null && b !== null && typeof a === "object" && typeof b === "object"
? JSON.stringify(a) === JSON.stringify(b)
: false)
);
});
}
function isOlderSessionSnapshot(
incoming: GatewaySessionRow,
existing: GatewaySessionRow | undefined,
@@ -551,6 +572,16 @@ export function reconcileSessionHistory(
// result === state.result publish gate can skip a spurious re-render.
return defaults ? { ...result, defaults: nextDefaults } : result;
}
if (
existing &&
isShallowEqualSessionRow(visibleSession, existing) &&
sessionMatchesArchivedFilter(visibleSession, archivedFilter)
) {
// The same event reconciled twice (capability handler + chat page) must
// no-op the second pass; a fresh array here defeats every downstream
// result === state.result publish gate and re-renders per event.
return defaults ? { ...result, defaults: nextDefaults } : result;
}
const sessions = sessionMatchesArchivedFilter(visibleSession, archivedFilter)
? [
...result.sessions.filter((candidate) => candidate.key !== visibleKey),