|
| 1 | +/** |
| 2 | + * Session-list ordering. |
| 3 | + * |
| 4 | + * The behaviour these pin, in order of how badly the failure reads to a user: |
| 5 | + * |
| 6 | + * 1. A session blocked on YOU is never buried under idle ones — including |
| 7 | + * when the blocked party is a fleet's child and the orchestrator is idle. |
| 8 | + * 2. The list orders by relevance, not creation time (the original bug). |
| 9 | + * 3. It does not reorder under the pointer. |
| 10 | + * 4. It degrades sanely against a daemon that never sends lastActivityAt. |
| 11 | + */ |
| 12 | + |
| 13 | +import { describe, it, expect } from "vitest"; |
| 14 | + |
| 15 | +import { |
| 16 | + BAND, |
| 17 | + activityKey, |
| 18 | + bandOf, |
| 19 | + bandOfGroup, |
| 20 | + bandSections, |
| 21 | + compareByRecency, |
| 22 | + holdOrder, |
| 23 | + snapshotOrder, |
| 24 | +} from "./session-order"; |
| 25 | +import type { FleetGroup } from "./fleet"; |
| 26 | +import type { SessionInfo, SessionStatus } from "../protocol/types"; |
| 27 | + |
| 28 | +function mk( |
| 29 | + id: string, |
| 30 | + over: Partial<SessionInfo> & { status?: SessionStatus } = {}, |
| 31 | +): SessionInfo { |
| 32 | + return { |
| 33 | + id, |
| 34 | + name: id, |
| 35 | + workdir: `/repo/${id}`, |
| 36 | + status: "idle", |
| 37 | + createdBy: "u", |
| 38 | + createdAt: "2026-01-01T00:00:00.000Z", |
| 39 | + attachedClients: 0, |
| 40 | + ...over, |
| 41 | + } as SessionInfo; |
| 42 | +} |
| 43 | + |
| 44 | +const group = (lead: SessionInfo, children: SessionInfo[] = []): FleetGroup => ({ |
| 45 | + lead, |
| 46 | + children, |
| 47 | + isFleet: children.length > 0, |
| 48 | +}); |
| 49 | + |
| 50 | +describe("bands", () => { |
| 51 | + it("puts anything blocked on a human in NEEDS_YOU", () => { |
| 52 | + expect(bandOf("waiting_approval")).toBe(BAND.NEEDS_YOU); |
| 53 | + // A failed session is the other thing that wants a human; filing it under |
| 54 | + // idle is how a failure goes unnoticed for an hour. |
| 55 | + expect(bandOf("error")).toBe(BAND.NEEDS_YOU); |
| 56 | + }); |
| 57 | + |
| 58 | + it("treats thinking and tool_running as one band so they cannot thrash", () => { |
| 59 | + // These alternate several times a second. If they banded differently the |
| 60 | + // row would jitter continuously. |
| 61 | + expect(bandOf("thinking")).toBe(BAND.WORKING); |
| 62 | + expect(bandOf("tool_running")).toBe(BAND.WORKING); |
| 63 | + }); |
| 64 | + |
| 65 | + it("idle is idle", () => { |
| 66 | + expect(bandOf("idle")).toBe(BAND.IDLE); |
| 67 | + }); |
| 68 | + |
| 69 | + it("a fleet takes the most urgent band of anything in it", () => { |
| 70 | + // The orchestrator sits idle while its children work — banding on the lead |
| 71 | + // alone would hide a child that is blocked on an approval. |
| 72 | + const fleet = group(mk("goal", { status: "idle" }), [ |
| 73 | + mk("kid-1", { status: "idle" }), |
| 74 | + mk("kid-2", { status: "waiting_approval" }), |
| 75 | + ]); |
| 76 | + expect(bandOfGroup(fleet)).toBe(BAND.NEEDS_YOU); |
| 77 | + }); |
| 78 | +}); |
| 79 | + |
| 80 | +describe("recency", () => { |
| 81 | + it("orders by lastActivityAt, not createdAt — the original bug", () => { |
| 82 | + // Old session, driven all morning; new session, untouched since creation. |
| 83 | + const old = mk("old", { |
| 84 | + createdAt: "2026-01-01T00:00:00.000Z", |
| 85 | + lastActivityAt: "2026-06-01T12:00:00.000Z", |
| 86 | + }); |
| 87 | + const fresh = mk("fresh", { |
| 88 | + createdAt: "2026-05-01T00:00:00.000Z", |
| 89 | + lastActivityAt: "2026-05-01T00:00:00.000Z", |
| 90 | + }); |
| 91 | + expect([fresh, old].sort(compareByRecency).map((s) => s.id)).toEqual(["old", "fresh"]); |
| 92 | + }); |
| 93 | + |
| 94 | + it("falls back to createdAt when the daemon never sends lastActivityAt", () => { |
| 95 | + // An older daemon must not sink every session to epoch 0 — that would be |
| 96 | + // worse than the behaviour being replaced. |
| 97 | + const s = mk("legacy", { createdAt: "2026-03-03T00:00:00.000Z" }); |
| 98 | + expect(activityKey(s)).toBe(Date.parse("2026-03-03T00:00:00.000Z")); |
| 99 | + }); |
| 100 | + |
| 101 | + it("survives an unparseable timestamp instead of producing NaN order", () => { |
| 102 | + expect(activityKey(mk("bad", { lastActivityAt: "not-a-date" }))).toBe(0); |
| 103 | + }); |
| 104 | + |
| 105 | + it("a fleet is as recent as its busiest child", () => { |
| 106 | + const fleet = group( |
| 107 | + mk("goal", { lastActivityAt: "2026-01-01T00:00:00.000Z" }), |
| 108 | + [mk("kid", { lastActivityAt: "2026-09-09T00:00:00.000Z" })], |
| 109 | + ); |
| 110 | + const solo = group(mk("solo", { lastActivityAt: "2026-05-05T00:00:00.000Z" })); |
| 111 | + const [section] = bandSections([solo, fleet]); |
| 112 | + expect(section!.groups.map((g) => g.lead.id)).toEqual(["goal", "solo"]); |
| 113 | + }); |
| 114 | +}); |
| 115 | + |
| 116 | +describe("bandSections", () => { |
| 117 | + it("orders bands NEEDS YOU → WORKING → IDLE and drops empty ones", () => { |
| 118 | + const sections = bandSections([ |
| 119 | + group(mk("i", { status: "idle" })), |
| 120 | + group(mk("w", { status: "thinking" })), |
| 121 | + group(mk("n", { status: "waiting_approval" })), |
| 122 | + ]); |
| 123 | + expect(sections.map((s) => s.label)).toEqual(["Needs you", "Working", "Idle"]); |
| 124 | + expect(sections.map((s) => s.groups[0]!.lead.id)).toEqual(["n", "w", "i"]); |
| 125 | + |
| 126 | + // No empty headers when a band has nothing in it. |
| 127 | + expect(bandSections([group(mk("only", { status: "idle" }))]).map((s) => s.label)).toEqual([ |
| 128 | + "Idle", |
| 129 | + ]); |
| 130 | + }); |
| 131 | + |
| 132 | + it("is deterministic for same-millisecond sessions", () => { |
| 133 | + // A fan-out creates children within the same millisecond; without a |
| 134 | + // tie-break they would swap places on every re-render. |
| 135 | + const at = "2026-04-04T00:00:00.000Z"; |
| 136 | + const a = group(mk("bbb", { lastActivityAt: at })); |
| 137 | + const b = group(mk("aaa", { lastActivityAt: at })); |
| 138 | + const once = bandSections([a, b]).flatMap((s) => s.groups.map((g) => g.lead.id)); |
| 139 | + const twice = bandSections([b, a]).flatMap((s) => s.groups.map((g) => g.lead.id)); |
| 140 | + expect(once).toEqual(twice); |
| 141 | + }); |
| 142 | +}); |
| 143 | + |
| 144 | +describe("holdOrder — does not reorder under the pointer", () => { |
| 145 | + it("keeps a row in place when its band changes mid-hover", () => { |
| 146 | + const idle = group(mk("a", { status: "idle", lastActivityAt: "2026-01-02T00:00:00.000Z" })); |
| 147 | + const other = group(mk("b", { status: "idle", lastActivityAt: "2026-01-01T00:00:00.000Z" })); |
| 148 | + const before = bandSections([idle, other]); |
| 149 | + const snap = snapshotOrder(before); |
| 150 | + expect(before.map((s) => s.label)).toEqual(["Idle"]); |
| 151 | + |
| 152 | + // 'b' starts asking for approval — normally it would jump to a new top band. |
| 153 | + const after = bandSections([ |
| 154 | + idle, |
| 155 | + group(mk("b", { status: "waiting_approval", lastActivityAt: "2026-01-01T00:00:00.000Z" })), |
| 156 | + ]); |
| 157 | + expect(after.map((s) => s.label)).toEqual(["Needs you", "Idle"]); |
| 158 | + |
| 159 | + // Held: the layout the user is pointing at is unchanged. |
| 160 | + const held = holdOrder(after, snap); |
| 161 | + expect(held.map((s) => s.label)).toEqual(["Idle"]); |
| 162 | + expect(held[0]!.groups.map((g) => g.lead.id)).toEqual(["a", "b"]); |
| 163 | + }); |
| 164 | + |
| 165 | + it("still shows new sessions and drops destroyed ones while holding", () => { |
| 166 | + // Order is frozen; MEMBERSHIP is not. Freezing membership would leave a |
| 167 | + // destroyed session clickable — a worse bug than the one being prevented. |
| 168 | + const a = group(mk("a", { status: "idle" })); |
| 169 | + const b = group(mk("b", { status: "idle" })); |
| 170 | + const snap = snapshotOrder(bandSections([a, b])); |
| 171 | + |
| 172 | + const c = group(mk("c", { status: "idle" })); |
| 173 | + const held = holdOrder(bandSections([a, c]), snap); // b destroyed, c appeared |
| 174 | + const ids = held.flatMap((s) => s.groups.map((g) => g.lead.id)); |
| 175 | + expect(ids).toContain("a"); |
| 176 | + expect(ids).toContain("c"); |
| 177 | + expect(ids).not.toContain("b"); |
| 178 | + // The newcomer appends rather than displacing the row being aimed at. |
| 179 | + expect(ids.indexOf("c")).toBeGreaterThan(ids.indexOf("a")); |
| 180 | + }); |
| 181 | + |
| 182 | + it("is a no-op without a snapshot", () => { |
| 183 | + const live = bandSections([group(mk("a", { status: "waiting_approval" }))]); |
| 184 | + expect(holdOrder(live, null)).toEqual(live); |
| 185 | + }); |
| 186 | +}); |
0 commit comments