mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
a8857eec0e
commit
f870d93e2a
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user