From a1a2ce46f8290c657d9f859f945e8d482c92e380 Mon Sep 17 00:00:00 2001 From: Hieu Le Date: Sun, 1 Nov 2026 23:48:06 +0100 Subject: [PATCH 1/2] perf(server): bound settled-shell cutoff allocation --- .../src/orchestration-v2/ShellStream.test.ts | 233 ++++++++++++++---- .../src/orchestration-v2/ShellStream.ts | 28 ++- 2 files changed, 213 insertions(+), 48 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 336f9b12f96bf..4af7a24d285e3 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -6,6 +6,7 @@ import type { OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import type * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -15,12 +16,14 @@ import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/sql/SqlClient"; import { activeShellRowsForSubscriber, bufferShellLiveStream, composeShellStreamWithEnrichment, dedupeShellEnrichment, + loadSubscriberShellSnapshot, omitIdleSubagentShells, omitOlderSettledShells, shellStreamItemFromEnrichmentRefresh, @@ -235,7 +238,7 @@ describe("omitOlderSettledShells", () => { omitOlderSettledShells({ threads, snapshotSequence: 7, now }); it("keeps the 25 newest settled threads and counts the rest per project", () => { - const sideChat = row("side-chat", 0, { + const sideChat = row("side-chat", -500, { lineage: { parentThreadId: ThreadId.make("new-0"), relationshipToParent: "fork", @@ -246,6 +249,15 @@ describe("omitOlderSettledShells", () => { const result = omit([ ...newest, sideChat, + row("subagent", -500, { + lineage: { + parentThreadId: ThreadId.make("new-0"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("new-0"), + }, + }), + row("null-time", 0, { settledAt: null }), + row("not-settled", 0, { settledOverride: null }), row("old", 30), row("b-old", 40), row("unsettled", null), @@ -253,6 +265,9 @@ describe("omitOlderSettledShells", () => { expect(result.threads.map((thread) => thread.id)).toEqual([ ...newest.map((thread) => thread.id), "side-chat", + "subagent", + "null-time", + "not-settled", "unsettled", ]); expect(result.olderSettled).toEqual({ @@ -266,56 +281,184 @@ describe("omitOlderSettledShells", () => { }); it("keeps threads tied with the 25th newest", () => { - const result = omit([...newest, row("tie", 2.4), row("older", 2.5)]); - expect(result.threads.map((thread) => thread.id).slice(25)).toEqual(["tie"]); + const ties = Array.from({ length: 40 }, (_, index) => row(`tie-${index}`, 2.4)); + const threads = [...ties.slice(0, 20), ...newest, row("older", 2.5), ...ties.slice(20)]; + const result = omit(threads); + expect(result.threads.map((thread) => thread.id)).toEqual( + threads.filter((thread) => thread.id !== "older").map((thread) => thread.id), + ); + expect(result.olderSettled).toEqual({ + before: "1970-04-08T14:24:00.000Z", + asOfSequence: 7, + counts: [{ projectId: "project-a", count: 1 }], + }); + const allTied = omit(ties); + expect(allTied.threads).toEqual(ties); + expect(allTied.olderSettled.counts).toEqual([]); }); - it("omits nothing with fewer than 25 settled threads, however old", () => { - const old = Array.from({ length: 5 }, (_, index) => row(`old-${index}`, 300 + index)); - const result = omit(old); - expect(result.threads).toEqual(old); - expect(result.olderSettled.counts).toEqual([]); + it("uses the oldest settled timestamp below 25 and the 25th at or above it", () => { + for (const [size, before, omitted] of [ + [0, "1970-04-11T00:00:00.000Z", 0], + [1, "1969-06-15T00:00:00.000Z", 0], + [24, "1969-05-23T00:00:00.000Z", 0], + [25, "1969-05-22T00:00:00.000Z", 0], + [26, "1969-05-22T00:00:00.000Z", 1], + ] as const) { + const old = Array.from({ length: size }, (_, index) => row(`old-${index}`, 300 + index)); + const result = omit(old); + expect(result.threads).toEqual(old.slice(0, 25)); + expect(result.olderSettled).toEqual({ + before, + asOfSequence: 7, + counts: omitted === 0 ? [] : [{ projectId: "project-a", count: 1 }], + }); + } + const epoch = omit([row("zero", 100), row("negative", 101)]); + expect(epoch.threads.map((thread) => thread.id)).toEqual(["zero", "negative"]); + expect(epoch.olderSettled.before).toBe("1969-12-31T00:00:00.000Z"); }); - it("keeps older rows that are snoozed, live, or above a kept subagent or side chat, and still counts them", () => { - const threads = [ - ...newest, - row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), - row("woke", 30, { snoozedUntil: DateTime.makeUnsafe(99 * DAY) }), - row("running", 30, { activityRunStatus: "running" }), - row("parent", 30), - shellFixture({ - id: ThreadId.make("child"), - projectId: ProjectId.make("project-a"), - lineage: { - parentThreadId: ThreadId.make("parent"), - relationshipToParent: "subagent", - rootThreadId: ThreadId.make("parent"), - }, - settledOverride: null, - settledAt: null, - }), - row("side-chat-parent", 30), - row("side-chat", null, { - lineage: { - parentThreadId: ThreadId.make("side-chat-parent"), - relationshipToParent: "fork", - rootThreadId: ThreadId.make("side-chat-parent"), - sideChat: true, - }, - }), + it("retains the known newest IDs and project counts across large unsorted inputs", () => { + const kept = [ + "a-0", + "b-1", + "a-2", + "b-3", + "a-4", + "b-5", + "a-6", + "b-7", + "a-8", + "b-9", + "a-10", + "b-11", + "a-12", + "b-13", + "a-14", + "b-15", + "a-16", + "b-17", + "a-18", + "b-19", + "a-20", + "b-21", + "a-22", + "b-23", + "a-24", ]; - const result = omit(threads); - expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ - "snoozed", - "running", - "parent", - "child", - "side-chat-parent", - "side-chat", - ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); + const keptIds = new Set(kept); + for (const [size, aCount, bCount] of [ + [351, 163, 163], + [10000, 4987, 4988], + ] as const) { + for (let seed = 1; seed <= 8; seed++) { + let state = seed; + const threads = Array.from({ length: size }, (_, index) => + Object.freeze(row(`${index % 2 === 0 ? "a" : "b"}-${index}`, index)), + ); + for (let index = threads.length - 1; index > 0; index--) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + const other = state % (index + 1); + [threads[index], threads[other]] = [threads[other]!, threads[index]!]; + } + const result = omit(Object.freeze(threads)); + const ids = result.threads.map((thread) => thread.id); + expect([...ids].sort()).toEqual([...kept].sort()); + expect(ids).toEqual( + threads.filter((thread) => keptIds.has(thread.id)).map((thread) => thread.id), + ); + expect(result.olderSettled.before).toBe("1970-03-18T00:00:00.000Z"); + expect(result.olderSettled.asOfSequence).toBe(7); + expect( + [...result.olderSettled.counts].sort((a, b) => a.projectId.localeCompare(b.projectId)), + ).toEqual([ + { projectId: "project-a", count: aCount }, + { projectId: "project-b", count: bCount }, + ]); + } + } }); + + it.effect("keeps old exceptions and identical trimmed snapshots on reconnect", () => + Effect.gen(function* () { + const threads = [ + ...newest, + row("pinned", 30, { pinnedAt: now }), + row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), + row("woke", 30, { snoozedUntil: DateTime.makeUnsafe(99 * DAY) }), + row("running", 30, { activityRunStatus: "running" }), + row("parent", 30), + shellFixture({ + id: ThreadId.make("child"), + activityRunStatus: "running", + projectId: ProjectId.make("project-a"), + lineage: { + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("parent"), + }, + settledOverride: null, + settledAt: null, + }), + row("side-chat-parent", 30), + row("side-chat", null, { + lineage: { + parentThreadId: ThreadId.make("side-chat-parent"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("side-chat-parent"), + sideChat: true, + }, + }), + ]; + const result = omit(threads); + expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ + "pinned", + "snoozed", + "running", + "parent", + "child", + "side-chat-parent", + "side-chat", + ]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 6 }]); + yield* TestClock.setTime(100 * DAY); + const sql = yield* SqlClient.SqlClient; + const load = loadSubscriberShellSnapshot({ + sql, + listShellIndex: Effect.succeed( + threads.map((thread) => ({ + ...thread, + pinnedAt: thread.pinnedAt ?? null, + snoozedUntil: thread.snoozedUntil ?? null, + mayBeLive: thread.activityRunStatus === "running" || thread.id === "child", + })), + ), + readThreads: (ids) => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 7, + threads: threads.filter((thread) => ids?.includes(thread.id) ?? true), + archivedThreads: [], + }), + ), + listProjects: Effect.succeed([]), + latestSequence: Effect.succeed(7), + options: { omitIdleSubagents: true, omitOlderSettled: true }, + }); + const connected = yield* load; + const reconnected = yield* load; + expect(connected.snapshot).toEqual({ + ...emptyShellSnapshot, + snapshotSequence: 7, + threads: result.threads, + olderSettled: result.olderSettled, + }); + expect(connected.subagentThreadIds).toEqual(new Set(["child"])); + expect(reconnected).toEqual(connected); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); }); describe("shellStreamItemsFromInitialSnapshot", () => { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 0fa5251a7ef73..ff7da608f16c2 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -94,6 +94,7 @@ const NEWEST_SETTLED_KEPT = 25; function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { const subagentParentIds = new Set(); const subagentThreadIds = new Set(); + // A bounded min-heap keeps the 25th newest timestamp at its root. const settledTimes: Array = []; for (const row of rows) { if (row.lineage.relationshipToParent === "subagent") { @@ -104,12 +105,33 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { row.settledOverride === "settled" && row.settledAt !== null ) { - settledTimes.push(DateTime.toEpochMillis(row.settledAt)); + const settledAtMs = DateTime.toEpochMillis(row.settledAt); + let position = settledTimes.length; + if (position < NEWEST_SETTLED_KEPT) { + settledTimes.push(settledAtMs); + while (position > 0) { + const parent = (position - 1) >>> 1; + if (settledTimes[parent]! <= settledAtMs) break; + settledTimes[position] = settledTimes[parent]!; + position = parent; + } + } else { + if (settledAtMs <= settledTimes[0]!) continue; + position = 0; + while (position * 2 + 1 < settledTimes.length) { + let child = position * 2 + 1; + if (child + 1 < settledTimes.length && settledTimes[child + 1]! < settledTimes[child]!) + child++; + if (settledAtMs <= settledTimes[child]!) break; + settledTimes[position] = settledTimes[child]!; + position = child; + } + } + settledTimes[position] = settledAtMs; } } - settledTimes.sort((left, right) => right - left); // With fewer than 25, the oldest one is the cut, so nothing is older. - const olderSettledBeforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1); + const olderSettledBeforeMs = settledTimes[0]; const counts = new Map(); if (olderSettledBeforeMs !== undefined) { for (const row of rows) { From 41c93ee8bbaa674db014d6a6b8a14ce5932e0a33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 03:00:38 +0000 Subject: [PATCH 2/2] test(server): cover the settled cutoff heap with one ordering test Restore the existing omitOlderSettledShells cases and replace the reconnect integration test with one test that feeds the same settled rows oldest-first, newest-first and shuffled, which is what can break in the bounded heap. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ShellStream.test.ts | 260 +++++------------- 1 file changed, 73 insertions(+), 187 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 55a318e7ce085..ad39c266b434c 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -6,7 +6,6 @@ import type { OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import type * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -16,14 +15,12 @@ import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as SqlClient from "effect/sql/SqlClient"; import { activeShellRowsForSubscriber, bufferShellLiveStream, composeShellStreamWithEnrichment, dedupeShellEnrichment, - loadSubscriberShellSnapshot, omitIdleSubagentShells, omitOlderSettledShells, shellStreamItemFromEnrichmentRefresh, @@ -250,7 +247,7 @@ describe("omitOlderSettledShells", () => { omitOlderSettledShells({ threads, snapshotSequence: 7, now }); it("keeps the 25 newest settled threads and counts the rest per project", () => { - const sideChat = row("side-chat", -500, { + const sideChat = row("side-chat", 0, { lineage: { parentThreadId: ThreadId.make("new-0"), relationshipToParent: "fork", @@ -261,15 +258,6 @@ describe("omitOlderSettledShells", () => { const result = omit([ ...newest, sideChat, - row("subagent", -500, { - lineage: { - parentThreadId: ThreadId.make("new-0"), - relationshipToParent: "subagent", - rootThreadId: ThreadId.make("new-0"), - }, - }), - row("null-time", 0, { settledAt: null }), - row("not-settled", 0, { settledOverride: null }), row("old", 30), row("b-old", 40), row("unsettled", null), @@ -277,9 +265,6 @@ describe("omitOlderSettledShells", () => { expect(result.threads.map((thread) => thread.id)).toEqual([ ...newest.map((thread) => thread.id), "side-chat", - "subagent", - "null-time", - "not-settled", "unsettled", ]); expect(result.olderSettled).toEqual({ @@ -293,184 +278,85 @@ describe("omitOlderSettledShells", () => { }); it("keeps threads tied with the 25th newest", () => { - const ties = Array.from({ length: 40 }, (_, index) => row(`tie-${index}`, 2.4)); - const threads = [...ties.slice(0, 20), ...newest, row("older", 2.5), ...ties.slice(20)]; - const result = omit(threads); - expect(result.threads.map((thread) => thread.id)).toEqual( - threads.filter((thread) => thread.id !== "older").map((thread) => thread.id), - ); - expect(result.olderSettled).toEqual({ - before: "1970-04-08T14:24:00.000Z", - asOfSequence: 7, - counts: [{ projectId: "project-a", count: 1 }], - }); - const allTied = omit(ties); - expect(allTied.threads).toEqual(ties); - expect(allTied.olderSettled.counts).toEqual([]); + const result = omit([...newest, row("tie", 2.4), row("older", 2.5)]); + expect(result.threads.map((thread) => thread.id).slice(25)).toEqual(["tie"]); }); - it("uses the oldest settled timestamp below 25 and the 25th at or above it", () => { - for (const [size, before, omitted] of [ - [0, "1970-04-11T00:00:00.000Z", 0], - [1, "1969-06-15T00:00:00.000Z", 0], - [24, "1969-05-23T00:00:00.000Z", 0], - [25, "1969-05-22T00:00:00.000Z", 0], - [26, "1969-05-22T00:00:00.000Z", 1], - ] as const) { - const old = Array.from({ length: size }, (_, index) => row(`old-${index}`, 300 + index)); - const result = omit(old); - expect(result.threads).toEqual(old.slice(0, 25)); - expect(result.olderSettled).toEqual({ - before, - asOfSequence: 7, - counts: omitted === 0 ? [] : [{ projectId: "project-a", count: 1 }], - }); + it("finds the same cut whatever order the settled threads arrive in", () => { + const ascending = Array.from({ length: 200 }, (_, index) => + row(`${index % 2 === 0 ? "a" : "b"}-${index}`, index), + ); + let seed = 7; + const shuffled = [...ascending]; + for (let index = shuffled.length - 1; index > 0; index--) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + const other = seed % (index + 1); + [shuffled[index], shuffled[other]] = [shuffled[other]!, shuffled[index]!]; + } + const newestIds = new Set(ascending.slice(0, 25).map((thread) => thread.id)); + for (const threads of [ascending, [...ascending].reverse(), shuffled]) { + const result = omit(threads); + expect(result.threads.map((thread) => thread.id)).toEqual( + threads.filter((thread) => newestIds.has(thread.id)).map((thread) => thread.id), + ); + expect(result.olderSettled.before).toBe( + DateTime.formatIso(DateTime.makeUnsafe(100 * DAY - 24 * DAY)), + ); + expect(result.olderSettled.counts).toEqual( + expect.arrayContaining([ + { projectId: "project-a", count: 87 }, + { projectId: "project-b", count: 88 }, + ]), + ); } - const epoch = omit([row("zero", 100), row("negative", 101)]); - expect(epoch.threads.map((thread) => thread.id)).toEqual(["zero", "negative"]); - expect(epoch.olderSettled.before).toBe("1969-12-31T00:00:00.000Z"); }); - it("retains the known newest IDs and project counts across large unsorted inputs", () => { - const kept = [ - "a-0", - "b-1", - "a-2", - "b-3", - "a-4", - "b-5", - "a-6", - "b-7", - "a-8", - "b-9", - "a-10", - "b-11", - "a-12", - "b-13", - "a-14", - "b-15", - "a-16", - "b-17", - "a-18", - "b-19", - "a-20", - "b-21", - "a-22", - "b-23", - "a-24", - ]; - const keptIds = new Set(kept); - for (const [size, aCount, bCount] of [ - [351, 163, 163], - [10000, 4987, 4988], - ] as const) { - for (let seed = 1; seed <= 8; seed++) { - let state = seed; - const threads = Array.from({ length: size }, (_, index) => - Object.freeze(row(`${index % 2 === 0 ? "a" : "b"}-${index}`, index)), - ); - for (let index = threads.length - 1; index > 0; index--) { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; - const other = state % (index + 1); - [threads[index], threads[other]] = [threads[other]!, threads[index]!]; - } - const result = omit(Object.freeze(threads)); - const ids = result.threads.map((thread) => thread.id); - expect([...ids].sort()).toEqual([...kept].sort()); - expect(ids).toEqual( - threads.filter((thread) => keptIds.has(thread.id)).map((thread) => thread.id), - ); - expect(result.olderSettled.before).toBe("1970-03-18T00:00:00.000Z"); - expect(result.olderSettled.asOfSequence).toBe(7); - expect( - [...result.olderSettled.counts].sort((a, b) => a.projectId.localeCompare(b.projectId)), - ).toEqual([ - { projectId: "project-a", count: aCount }, - { projectId: "project-b", count: bCount }, - ]); - } - } + it("omits nothing with fewer than 25 settled threads, however old", () => { + const old = Array.from({ length: 5 }, (_, index) => row(`old-${index}`, 300 + index)); + const result = omit(old); + expect(result.threads).toEqual(old); + expect(result.olderSettled.counts).toEqual([]); }); - it.effect("keeps old exceptions and identical trimmed snapshots on reconnect", () => - Effect.gen(function* () { - const threads = [ - ...newest, - row("pinned", 30, { pinnedAt: now }), - row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), - row("woke", 30, { snoozedUntil: DateTime.makeUnsafe(99 * DAY) }), - row("running", 30, { activityRunStatus: "running" }), - row("parent", 30), - shellFixture({ - id: ThreadId.make("child"), - activityRunStatus: "running", - projectId: ProjectId.make("project-a"), - lineage: { - parentThreadId: ThreadId.make("parent"), - relationshipToParent: "subagent", - rootThreadId: ThreadId.make("parent"), - }, - settledOverride: null, - settledAt: null, - }), - row("side-chat-parent", 30), - row("side-chat", null, { - lineage: { - parentThreadId: ThreadId.make("side-chat-parent"), - relationshipToParent: "fork", - rootThreadId: ThreadId.make("side-chat-parent"), - sideChat: true, - }, - }), - ]; - const result = omit(threads); - expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ - "pinned", - "snoozed", - "running", - "parent", - "child", - "side-chat-parent", - "side-chat", - ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 6 }]); - yield* TestClock.setTime(100 * DAY); - const sql = yield* SqlClient.SqlClient; - const load = loadSubscriberShellSnapshot({ - sql, - listShellIndex: Effect.succeed( - threads.map((thread) => ({ - ...thread, - pinnedAt: thread.pinnedAt ?? null, - snoozedUntil: thread.snoozedUntil ?? null, - mayBeLive: thread.activityRunStatus === "running" || thread.id === "child", - })), - ), - readThreads: (ids) => - Effect.succeed( - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 7, - threads: threads.filter((thread) => ids?.includes(thread.id) ?? true), - archivedThreads: [], - }), - ), - listProjects: Effect.succeed([]), - latestSequence: Effect.succeed(7), - options: { omitIdleSubagents: true, omitOlderSettled: true }, - }); - const connected = yield* load; - const reconnected = yield* load; - expect(connected.snapshot).toEqual({ - ...emptyShellSnapshot, - snapshotSequence: 7, - threads: result.threads, - olderSettled: result.olderSettled, - }); - expect(connected.subagentThreadIds).toEqual(new Set(["child"])); - expect(reconnected).toEqual(connected); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); + it("keeps older rows that are snoozed, live, or above a kept subagent or side chat, and still counts them", () => { + const threads = [ + ...newest, + row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), + row("woke", 30, { snoozedUntil: DateTime.makeUnsafe(99 * DAY) }), + row("running", 30, { activityRunStatus: "running" }), + row("parent", 30), + shellFixture({ + id: ThreadId.make("child"), + projectId: ProjectId.make("project-a"), + lineage: { + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("parent"), + }, + settledOverride: null, + settledAt: null, + }), + row("side-chat-parent", 30), + row("side-chat", null, { + lineage: { + parentThreadId: ThreadId.make("side-chat-parent"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("side-chat-parent"), + sideChat: true, + }, + }), + ]; + const result = omit(threads); + expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ + "snoozed", + "running", + "parent", + "child", + "side-chat-parent", + "side-chat", + ]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); + }); }); describe("shellStreamItemsFromInitialSnapshot", () => {