From 2607da19d9944cc725f9d73ba6a9044a29e1e5d2 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 20:43:17 -0500 Subject: [PATCH 01/19] fix(mobile): load deferred work when expanding thread history --- .../src/features/threads/ThreadFeed.tsx | 110 ++++-- apps/mobile/src/lib/threadActivity.test.ts | 29 ++ apps/mobile/src/lib/threadActivity.ts | 16 +- .../src/orchestration-v2/Orchestrator.ts | 6 +- .../orchestration-v2/ProjectionStore.test.ts | 84 +++++ .../src/orchestration-v2/ProjectionStore.ts | 312 ++++++++++++++++-- .../ThreadManagementService.ts | 10 +- .../src/orchestration-v2/ThreadStream.test.ts | 43 +++ .../src/orchestration-v2/ThreadStream.ts | 7 +- apps/server/src/orchestration-v2/http.ts | 17 +- .../threadHistoryPaging.test.ts | 88 +++++ .../orchestration-v2/threadHistoryPaging.ts | 149 ++++++++- apps/server/src/ws.ts | 4 + .../chat/MessagesTimeline.logic.test.ts | 41 +++ .../components/chat/MessagesTimeline.logic.ts | 9 + .../src/components/chat/MessagesTimeline.tsx | 54 ++- .../pullRequest/PullRequestDetailPanel.tsx | 2 +- docs/internals/performance-regressions.md | 9 +- .../src/state/boundedThreadSnapshotHttp.ts | 2 +- .../state/orchestrationV2Projection.test.ts | 8 +- .../src/state/orchestrationV2Projection.ts | 5 +- .../src/state/threadCommands.test.ts | 1 + .../src/state/threadCommands.ts | 23 ++ .../src/state/threadHistoryController.ts | 17 + .../src/state/threadHistoryHttp.ts | 3 +- .../src/state/threadHistoryMerge.test.ts | 97 +++++- .../src/state/threadHistoryMerge.ts | 64 ++++ .../src/state/threads-sync.test.ts | 108 ++++++ packages/client-runtime/src/state/threads.ts | 103 ++++++ packages/contracts/src/environmentHttp.ts | 4 +- packages/contracts/src/orchestrationV2.ts | 9 + packages/shared/package.json | 4 + .../shared/src/orchestrationV2WorkHistory.ts | 34 ++ 33 files changed, 1378 insertions(+), 94 deletions(-) create mode 100644 packages/shared/src/orchestrationV2WorkHistory.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 36fe66a026493..346ee5f873d65 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1232,6 +1232,84 @@ function compactionLabel(label: string, title: string | undefined) { : label; } +function RunFoldButton({ + entry, + environmentId, + threadId, + onToggle, + rowSizing, + iconColor, +}: { + entry: Extract; + environmentId: EnvironmentId; + threadId: ThreadId; + onToggle: (runId: RunId) => void; + rowSizing: ReturnType; + iconColor: ColorValue; +}) { + const loadWork = useAtomCommand(threadEnvironment.loadWorkHistory, { reportFailure: false }); + const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? ""; + const [failure, setFailure] = useState<{ attempt: number; message: string } | null>(null); + const [retry, setRetry] = useState(0); + const error = anchorsKey && failure?.attempt === retry ? failure.message : null; + const loading = entry.expanded && anchorsKey !== "" && error === null; + const requestWork = useEffectEvent( + (anchorIds: string[], environmentId: EnvironmentId, threadId: ThreadId) => + Promise.all( + anchorIds.map((anchorId) => loadWork({ environmentId, input: { threadId, anchorId } })), + ), + ); + // oxlint-disable-next-line t3code/react-effect-budget -- Expanded folds restore their deferred server history. + useEffect(() => { + if (!entry.expanded || !anchorsKey) return; + let active = true; + void requestWork(anchorsKey.split("\n"), environmentId, threadId).then((results) => { + if (!active) return; + const failed = results.some( + (result) => result._tag === "Failure" || result.value._tag === "error", + ); + setFailure(failed ? { attempt: retry, message: "Couldn't load work. Tap to retry." } : null); + }); + return () => { + active = false; + }; + }, [entry.expanded, anchorsKey, retry, environmentId, threadId]); + const toggle = () => { + if (error !== null && entry.expanded) { + setFailure(null); + setRetry((value) => value + 1); + } else { + setFailure(null); + onToggle(entry.runId); + } + }; + return ( + + + {entry.label} + {loading ? " · Loading work..." : error ? ` · ${error}` : ""} + + + + ); +} + function renderFeedEntry( info: { item: ThreadFeedRow; index: number }, props: Pick< @@ -1303,30 +1381,14 @@ function renderFeedEntry( if (entry.type === "run-fold") { return ( - props.onToggleTurnFold(entry.runId)} - hitSlop={4} - className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2" - style={{ - minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), - }} - > - - {entry.label} - - - + {entry.checkpointSummary ? ( { + it("keeps deferred work attached to its collapsed run and drops the cursor after loading", () => { + const work = { + ...command(), + deferredWork: { cursor: "work", itemCount: 100, lastOrdinal: 100 }, + }; + const rows = [ + projected(userMessage(), 0), + projected(work, 1), + projected(assistantMessage(), 2), + ]; + const cache = createThreadFeedCache(); + const presented = deriveThreadFeedPresentation( + buildThreadFeed(rows, { cache }), + null, + new Set(), + ); + expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); + expect(presented[1]).toMatchObject({ deferredWorkItemIds: [work.id], expanded: false }); + const loaded = deriveThreadFeedPresentation( + buildThreadFeed([rows[0]!, projected(command(), 1), rows[2]!], { cache }), + null, + new Set([runId]), + ); + expect(loaded.find((entry) => entry.type === "run-fold")).toMatchObject({ expanded: true }); + expect(loaded.find((entry) => entry.type === "run-fold")).not.toHaveProperty( + "deferredWorkItemIds", + ); + }); + it("keeps async answers in question history instead of user bubbles", () => { const requestId = RuntimeRequestId.make("question"); const question: OrchestrationV2TurnItem = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 0038bc9bf4c18..37fe962ec862f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -231,6 +231,7 @@ type ThreadFeedEntryContent = } | { readonly type: "run-fold"; + readonly deferredWorkItemIds?: ReadonlyArray; readonly id: string; readonly createdAt: string; readonly runId: RunId; @@ -1083,6 +1084,7 @@ export function threadFeedActivityIsVisible( } interface ThreadFeedRunFold { + readonly deferredWorkItemIds?: ReadonlyArray; readonly runId: RunId; readonly createdAt: string; readonly hiddenEntryIds: ReadonlySet; @@ -1274,7 +1276,17 @@ function deriveThreadFeedRunFolds( const duration = elapsedMs === null ? null : formatDuration(elapsedMs); const interrupted = latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); + const deferredWorkItemIds = group.entries.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork + ? [String(activity.projectedItem.sourceItemId)] + : [], + ) + : [], + ); foldsByAnchorId.set(firstHiddenEntry.id, { + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), runId, createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, @@ -1502,11 +1514,13 @@ export function deriveThreadFeedPresentation( row.runId !== fold.runId || row.createdAt !== fold.createdAt || row.label !== fold.label || - row.expanded !== expanded + row.expanded !== expanded || + row.deferredWorkItemIds?.join("|") !== fold.deferredWorkItemIds?.join("|") ) { row = { type: "run-fold", id: `run-fold:${fold.runId}`, + ...(fold.deferredWorkItemIds ? { deferredWorkItemIds: fold.deferredWorkItemIds } : {}), createdAt: fold.createdAt, runId: fold.runId, label: fold.label, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e27f32ef84420..50f606a8bf73a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -347,6 +347,7 @@ export interface OrchestratorV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -384,6 +385,7 @@ export interface OrchestratorV2Shape { readonly schemaVersion: number; readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; + readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }, OrchestratorV2Error >; @@ -12688,9 +12690,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }), getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 7f70a2ec25eee..f4dcc84caf0b0 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2ThreadShellSnapshot, NodeId, PlanId, @@ -1916,6 +1917,89 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), ); + yield* setNewestTurnType("command_execution"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId} + `; + const longTurnSnapshot = yield* humanWindow(); + assert.lengthOf(longTurnSnapshot.projection.visibleTurnItems, sqlPageLimit); + const longTurnPage = buildBoundedThreadProjection({ + ...longTurnSnapshot, + }); + const promptId = TurnItemId.make("turn-item:bounded-sql-history:1"); + assert.strictEqual(longTurnPage.projection.visibleTurnItems[0]?.sourceItemId, promptId); + const longTurnIds = new Set( + longTurnPage.projection.visibleTurnItems.map((row) => row.sourceItemId), + ); + let longTurnCursor = longTurnPage.historyCursor; + for (let pages = 0; longTurnCursor !== null; pages += 1) { + assert.isBelow(pages, 20); + assert.notStrictEqual(decodeThreadHistoryCursor(longTurnCursor).si, promptId); + const page = yield* projectionStore.getThreadHistoryPage(threadId, longTurnCursor); + assert.strictEqual(page.items[0]?.sourceItemId, promptId); + for (const row of page.items) longTurnIds.add(row.sourceItemId); + longTurnCursor = page.nextCursor; + } + assert.strictEqual(longTurnIds.size, 1_000); + const conversation = yield* projectionStore.getThreadHistoryPage( + threadId, + longTurnPage.historyCursor!, + undefined, + true, + ); + assert.strictEqual(conversation.items[0]?.sourceItemId, promptId); + const deferred = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(deferred.projection.visibleTurnItems, 3); + assert.strictEqual(deferred.projection.visibleTurnItems[0]?.sourceItemId, promptId); + const anchor = deferred.projection.visibleTurnItems[1]!; + assert.strictEqual(anchor.item.deferredWork?.itemCount, 999); + const workIds = new Set(); + let workCursor: string | null = anchor.item.deferredWork!.cursor; + for (let pages = 0; workCursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = yield* projectionStore.getThreadHistoryPage( + threadId, + workCursor, + anchor.sourceItemId, + false, + "work", + ); + assert.isAtMost(page.items.length, 75); + for (const row of page.items) workIds.add(row.sourceItemId); + workCursor = page.nextCursor; + } + assert.strictEqual(workIds.size, 999); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + const backgroundWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(backgroundWindow.projection.visibleTurnItems, sqlPageLimit); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:' || ordinal, + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:' || ordinal) + WHERE thread_id = ${threadId} + `; + yield* setNewestTurnType("user_message"); + const tiedIds = [ TurnItemId.make("turn-item:bounded-sql-history:tie:b"), TurnItemId.make("turn-item:bounded-sql-history:tie:a"), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ed064325bfe16..411da1c4c9ac2 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -94,6 +94,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; +import { DEFERRED_WORK_ITEM_LIMIT } from "@t3tools/shared/orchestrationV2WorkHistory"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; import { isThreadHistoryUserTurn, @@ -101,6 +102,12 @@ import { threadHistoryTurnStarts, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, + missingUserTurnStarts, + deferWorkHistoryRows, + selectDeferredWorkPage, + encodeThreadHistoryCursor, + decodeThreadHistoryCursor, + projectedRowEncodedBytes, olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, @@ -447,6 +454,7 @@ export interface ProjectionStoreV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -633,6 +641,7 @@ export interface ProjectionStoreV2Shape { options: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -642,6 +651,7 @@ export interface ProjectionStoreV2Shape { readonly schemaVersion: number; readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; + readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }, ProjectionStoreV2Error >; @@ -2895,13 +2905,14 @@ export const layer: Layer.Layer = window: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; }, ) => sql` WITH eligible AS NOT MATERIALIZED ( SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, - item.run_id, item.node_id, item.type + item.run_id, item.node_id, item.type, item.status, run.status AS run_status FROM orchestration_v2_projection_turn_items AS item LEFT JOIN orchestration_v2_projection_runs AS run ON run.run_id = item.run_id @@ -3027,6 +3038,39 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors + ), work_segments AS ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type, + MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) + OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, + CASE WHEN status = 'completed' AND run_status = 'completed' + AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') + AND (type <> 'dynamic_tool' OR ( + payload_json NOT LIKE '%htmlRender%' AND payload_json NOT LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + AND payload_json NOT LIKE '%"viewedImagePath"%')) + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items blocked + WHERE blocked.run_id = eligible.run_id + AND (blocked.type IN ('run_interrupt_request', 'run_interrupt_result') + OR (blocked.type = 'error' AND blocked.status = 'failed' AND blocked.parent_item_id IS NULL)) + ) THEN 1 ELSE 0 END AS can_defer + FROM eligible + WHERE ${window.deferWork ? 1 : 0} = 1 + AND EXISTS (SELECT 1 FROM user_anchors) + AND ordinal >= (SELECT ordinal FROM boundary) + ), deferred_work AS ( + SELECT run_id, segment, MIN(ordinal) AS first_ordinal, + MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count + FROM work_segments WHERE can_defer = 1 + GROUP BY run_id, segment HAVING COUNT(*) > ${DEFERRED_WORK_ITEM_LIMIT} + ), window_items AS NOT MATERIALIZED ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type + FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 + OR NOT EXISTS (SELECT 1 FROM user_anchors) + UNION ALL + SELECT item.item_rowid, item.ordinal, item.turn_item_id, item.run_id, item.type + FROM work_segments item LEFT JOIN deferred_work deferred + ON deferred.run_id = item.run_id AND deferred.segment = item.segment + WHERE item.can_defer = 0 OR deferred.run_id IS NULL OR item.ordinal = deferred.first_ordinal ), latest_user_turn AS ( -- Mirrors selectOlderTimelinePage: whole turns while the -- newest user turn fits the turn row budget, otherwise the @@ -3034,7 +3078,7 @@ export const layer: Layer.Layer = SELECT COUNT(*) AS row_count FROM ( SELECT 1 - FROM eligible + FROM window_items WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} ) @@ -3043,7 +3087,7 @@ export const layer: Layer.Layer = -- order. A turn window can hold megabytes of tool output, and -- carrying it through the union and sort cost about a second. SELECT item_rowid, ordinal, turn_item_id, run_id, type - FROM eligible + FROM window_items WHERE ordinal >= (SELECT ordinal FROM boundary) ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE @@ -3084,6 +3128,7 @@ export const layer: Layer.Layer = window?: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3102,7 +3147,12 @@ export const layer: Layer.Layer = return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } - const boundedTurnItemRows = + type WindowPayloadRow = PayloadRow & { + readonly work_last_id?: string | null; + readonly work_last_ordinal?: number | null; + readonly work_item_count?: number | null; + }; + const boundedTurnItemRows: ReadonlyArray = (fields !== undefined && !fields.includes("turnItems")) || (window === undefined && matchesNothing(filter?.turnItemTypes, filter?.turnItemStatuses, filter?.turnItemRunIds)) @@ -3123,14 +3173,43 @@ export const layer: Layer.Layer = -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite -- skips sorting again once the payloads are attached. Carry the -- rowid through selection to avoid a second string-key index lookup. - SELECT item.payload_json + SELECT item.payload_json, tail.turn_item_id AS work_last_id, + deferred.last_ordinal AS work_last_ordinal, deferred.item_count AS work_item_count FROM retained CROSS JOIN orchestration_v2_projection_turn_items AS item ON item.rowid = retained.item_rowid + LEFT JOIN deferred_work deferred ON deferred.run_id = item.run_id + AND deferred.first_ordinal = item.ordinal + LEFT JOIN orchestration_v2_projection_turn_items tail + ON tail.thread_id = item.thread_id AND tail.ordinal = deferred.last_ordinal ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. - const turnItems = yield* decodeRows(decodeTurnItemPayload, threadId)(boundedTurnItemRows); + const decodedTurnItems = yield* decodeRows( + decodeTurnItemPayload, + threadId, + )(boundedTurnItemRows); + const turnItems = decodedTurnItems.map((item, index) => { + const row = boundedTurnItemRows[index]!; + return row.work_last_id == null || + row.work_item_count == null || + row.work_last_ordinal == null + ? item + : { + ...item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make(row.work_last_id), + position: row.work_last_ordinal, + inclusive: true, + }), + itemCount: row.work_item_count, + lastOrdinal: row.work_last_ordinal, + }, + }; + }); const cohortJson = (field: string) => JSON.stringify( turnItems.flatMap((payload) => { @@ -3653,6 +3732,7 @@ export const layer: Layer.Layer = window?: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3669,6 +3749,7 @@ export const layer: Layer.Layer = window?: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3781,6 +3862,7 @@ export const layer: Layer.Layer = return { rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, + deferWork: window.deferWork, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -5399,7 +5481,7 @@ export const layer: Layer.Layer = type TimelineIndexItem = Pick< OrchestrationV2TurnItem, - "id" | "threadId" | "runId" | "nodeId" | "type" + "id" | "threadId" | "runId" | "nodeId" | "parentItemId" | "type" | "status" | "ordinal" > & { readonly inputIntent?: Extract< OrchestrationV2TurnItem, @@ -5407,6 +5489,9 @@ export const layer: Layer.Layer = >["inputIntent"]; readonly createdBy?: "user" | "agent" | "system"; readonly messageId?: MessageId; + readonly runStatus?: string | undefined; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; type TimelineIndexRow = { readonly sourceThreadId: ThreadId; @@ -5432,22 +5517,35 @@ export const layer: Layer.Layer = turn_item_id: string; run_id: string | null; node_id: string | null; + parent_item_id: string | null; type: OrchestrationV2TurnItem["type"]; input_intent: NonNullable | null; created_by: "user" | "agent" | "system" | null; message_id: string | null; - }>`SELECT turn_item_id, run_id, node_id, type, + status: OrchestrationV2TurnItem["status"]; + ordinal: number; + persistent_output: number; + }>`SELECT turn_item_id, run_id, node_id, parent_item_id, type, status, ordinal, + CASE WHEN type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' + OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END AS persistent_output, CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} ORDER BY ordinal ASC, turn_item_id ASC`; + const runStatuses = new Map(records.runs.map((run) => [String(run.id), run.status])); const items: Array = rows.map((row) => ({ id: TurnItemId.make(row.turn_item_id), threadId, runId: row.run_id === null ? null : RunId.make(row.run_id), nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + parentItemId: row.parent_item_id === null ? null : TurnItemId.make(row.parent_item_id), type: row.type, + status: row.status, + ordinal: row.ordinal, + runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id), + persistentOutput: row.persistent_output === 1, ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), ...(row.created_by === null ? {} : { createdBy: row.created_by }), ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }), @@ -5532,6 +5630,7 @@ export const layer: Layer.Layer = cursor, throughEntryId, conversationOnly, + workMode, ) => sql .withTransaction( @@ -5550,21 +5649,59 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); + let historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; + if (workMode === "deferred") { + const decoded = yield* Effect.try({ + try: () => decodeThreadHistoryCursor(cursor), + catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), + }); + const anchor = indexed.find( + (row) => row.sourceThreadId === decoded.st && row.sourceItemId === decoded.si, + ); + if ( + anchor !== undefined && + !historyItems.some( + (row) => + row.sourceThreadId === anchor.sourceThreadId && + row.sourceItemId === anchor.sourceItemId, + ) + ) { + historyItems = [...historyItems, anchor].sort( + (left, right) => left.canonicalPosition - right.canonicalPosition, + ); + } + } const selection = yield* Effect.try({ try: () => { const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); - const page = selectHistoryPageFromCursor({ - items: indexed, - cursor, - snapshotSequence, - rowEncodedBytes: () => 0, - policy, - }); + const page = + workMode === "work" + ? selectDeferredWorkPage({ + items: indexed, + cursor, + throughEntryId, + snapshotSequence, + rowEncodedBytes: () => 0, + }) + : selectHistoryPageFromCursor({ + items: historyItems, + cursor, + snapshotSequence, + rowEncodedBytes: () => 0, + policy, + }); return { policy, page }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); - const { policy, page } = selection; + const { page } = selection; + const policy = + workMode === "work" + ? { + maxItems: DEFERRED_WORK_ITEM_LIMIT, + maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + } + : selection.policy; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) @@ -5603,7 +5740,11 @@ export const layer: Layer.Layer = visibility: row.visibility, sourceThreadId: row.sourceThreadId, sourceItemId: row.sourceItemId, - item: projectTurnItemForWire(item), + item: projectTurnItemForWire( + row.item.deferredWork === undefined + ? item + : { ...item, deferredWork: row.item.deferredWork }, + ), }; }), ); @@ -5613,9 +5754,37 @@ export const layer: Layer.Layer = ? page.items.filter((row) => isConversationHistoryItem(row.item)) : page.items, ); - const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); - const requests = yield* materialize(missingInterruptRequests(indexed, bounded.items)); - const pageItems = [...requests, ...bounded.items].map((row, position) => ({ + const prompts = + workMode === "work" + ? [] + : [ + ...items.filter((row) => isThreadHistoryUserTurn(row.item)), + ...(yield* materialize(missingUserTurnStarts(indexed, page.items))), + ]; + const promptBytes = prompts.reduce( + (sum, row) => sum + projectedRowEncodedBytes(row), + 0, + ); + const bounded = selectRecentTimelineWindow({ + items, + snapshotSequence, + policy: { + ...policy, + maxEncodedBytes: Math.max(1, policy.maxEncodedBytes - promptBytes), + }, + }); + const requests = + workMode === "work" + ? [] + : yield* materialize(missingInterruptRequests(indexed, bounded.items)); + const context = [ + ...requests, + ...missingUserTurnStarts( + prompts, + bounded.items.length > 0 ? bounded.items : page.items, + ), + ].sort((left, right) => left.position - right.position); + const pageItems = [...context, ...bounded.items].map((row, position) => ({ ...row, position, })); @@ -5720,11 +5889,50 @@ export const layer: Layer.Layer = const projection = yield* readProjection(threadId, new Set(), { rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, + deferWork: options.deferWork, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), ...(historyAnchor === undefined ? {} : { historyAnchor }), }); + const visible = projection.visibleTurnItems; + const runsWithoutPrompts = new Set( + visible.flatMap((row) => (row.item.runId === null ? [] : [row.item.runId])), + ); + for (const row of visible) { + if (isThreadHistoryUserTurn(row.item) && row.item.runId !== null) + runsWithoutPrompts.delete(row.item.runId); + } + const promptRows = + runsWithoutPrompts.size === 0 + ? [] + : yield* sql` + SELECT prompt.payload_json + FROM json_each(${encodeIdList([...runsWithoutPrompts])}) wanted + CROSS JOIN orchestration_v2_projection_turn_items AS prompt ON prompt.rowid = ( + SELECT input.rowid + FROM orchestration_v2_projection_turn_items AS input + WHERE input.run_id = wanted.value + AND input.type IN ('user_message', 'notification') + ORDER BY input.ordinal + LIMIT 1 + ) + JOIN orchestration_v2_projection_runs AS run ON run.run_id = prompt.run_id + WHERE prompt.type = 'user_message' + AND json_extract(prompt.payload_json, '$.createdBy') = 'user' + AND json_extract(prompt.payload_json, '$.inputIntent') IN ('turn_start', 'queued_turn') + AND run.status <> 'rolled_back' + AND NOT (run.status = 'cancelled' + AND json_extract(prompt.payload_json, '$.inputIntent') = 'queued_turn') + `; + const prompts = yield* decodeRows(decodeTurnItemPayload, threadId)(promptRows); + const historyContextItems = prompts.map((item, position) => ({ + position, + visibility: item.threadId === threadId ? ("local" as const) : ("inherited" as const), + sourceThreadId: item.threadId, + sourceItemId: item.id, + item, + })); const rows = yield* sql<{ readonly snapshot_sequence: number | null }>` SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events @@ -5736,6 +5944,7 @@ export const layer: Layer.Layer = schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, snapshotSequence: rows[0]?.snapshot_sequence ?? 0, projection, + historyContextItems, }; }), ) @@ -7625,7 +7834,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ), ), ), - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => + getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly, workMode) => service.getThreadSnapshot(threadId).pipe( Effect.flatMap(({ projection, snapshotSequence }) => Effect.gen(function* () { @@ -7637,16 +7846,40 @@ export const layerMemory: Layer.Layer = Layer.effect( ...row, item: projectTurnItemForWire(row.item), })); - const page = selectHistoryPageFromCursor({ - items: visible, - cursor, - throughEntryId, - snapshotSequence, - }); + const page = + workMode === "work" + ? selectDeferredWorkPage({ + items: visible, + cursor, + throughEntryId, + snapshotSequence, + }) + : selectHistoryPageFromCursor({ + items: + workMode === "deferred" + ? deferWorkHistoryRows( + visible.map((row) => ({ + ...row, + item: { + ...row.item, + runStatus: projection.runs.find( + (run) => run.id === row.item.runId, + )?.status, + }, + })), + ) + : visible, + cursor, + throughEntryId, + snapshotSequence, + }); const items = [ - ...missingInterruptRequests(visible, page.items), + ...(workMode === "work" ? [] : missingInterruptRequests(visible, page.items)), + ...(workMode === "work" ? [] : missingUserTurnStarts(visible, page.items)), ...page.items, - ].map((row, position) => ({ ...row, position })); + ] + .sort((left, right) => left.position - right.position) + .map((row, position) => ({ ...row, position })); return { ...page, snapshotSequence, @@ -7686,7 +7919,19 @@ export const layerMemory: Layer.Layer = Layer.effect( : snapshot.projection.visibleTurnItems.findIndex( (row) => row.sourceItemId === options.anchorItemId, ) + 1; - const candidates = snapshot.projection.visibleTurnItems.slice(0, anchorIndex); + const allCandidates = snapshot.projection.visibleTurnItems.slice(0, anchorIndex); + const candidates = options.deferWork + ? deferWorkHistoryRows( + allCandidates.map((row) => ({ + ...row, + item: { + ...row.item, + runStatus: snapshot.projection.runs.find((run) => run.id === row.item.runId) + ?.status, + }, + })), + ) + : allCandidates; const turnStarts = threadHistoryTurnStarts(candidates.map((row) => row.item)); const turnAnchors = options.userTurnLimit === undefined @@ -7710,12 +7955,11 @@ export const layerMemory: Layer.Layer = Layer.effect( latestTurnRows <= THREAD_HISTORY_PAGE_POLICY.maxTurnItems ? THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT : options.rowLimit; - const visibleTurnItems = candidates.slice( - Math.max(start, anchorIndex - Math.max(0, rowLimit)), - ); + const selected = candidates.slice(Math.max(start, anchorIndex - Math.max(0, rowLimit))); return { ...snapshot, - projection: { ...snapshot.projection, visibleTurnItems }, + projection: { ...snapshot.projection, visibleTurnItems: selected }, + historyContextItems: missingUserTurnStarts(candidates, selected), }; }), ), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 9a80f8d284172..807a26a9c3957 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1211,10 +1211,16 @@ const make = Effect.gen(function* () { }), dispatch, getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return ensureProjectionTranscript(threadId).pipe( Effect.andThen( - orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), + orchestrator.getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + workMode, + ), ), ); }, diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index a5059eada443b..9b0cfbeb6a5a3 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,6 +1,8 @@ import { EventId, + MessageId, OrchestrationV2ThreadStreamItem, + RunId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -163,6 +165,47 @@ describe("decideThreadResume", () => { expect(snapshot.latestLocalTurnOrdinal).toBe(80); }); + it("includes an out-of-window user prompt in ordinary and compact socket snapshots", () => { + const runId = RunId.make("long-run"); + const full = timelineProjection(80); + const rows = full.visibleTurnItems.map((row) => ({ + ...row, + item: { ...row.item, runId }, + })); + const first = rows[0]!; + if (first.item.type !== "command_execution") throw new Error("Expected command fixture"); + const prompt: OrchestrationV2ProjectedTurnItem = { + ...first, + sourceItemId: TurnItemId.make("prompt-item"), + item: { + ...first.item, + id: TurnItemId.make("prompt-item"), + ordinal: 0, + type: "user_message", + messageId: MessageId.make("prompt"), + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Investigate WebGL", + attachments: [], + }, + }; + for (const compactTurnItems of [false, true]) { + const snapshot = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 44, + projection: { ...full, visibleTurnItems: rows, turnItems: rows.map((row) => row.item) }, + historyContextItems: [prompt], + compactTurnItems, + }); + const restored = boundedSnapshotProjection(snapshot); + expect(restored.visibleTurnItems[0]?.item).toEqual(prompt.item); + expect(restored.turnItems[0]).toEqual(prompt.item); + expect(restored.visibleTurnItems).toHaveLength(76); + expect(snapshot.hasMoreHistory).toBe(true); + if (compactTurnItems) expect(snapshot.turnItemsOmitLocalVisible).toBe(true); + } + }); + it("rejects a 10 MiB raw replay even when its projected form fits the wire budget", () => { const item = { id: TurnItemId.make("large-dynamic-tool"), diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef07..414844028ec51 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,7 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { projectThreadProjectionForWire, projectTurnItemForWire } from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -65,10 +65,15 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly compactTurnItems?: boolean; readonly dedupeBodies?: boolean; readonly compactIds?: boolean; + readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, projection: projectThreadProjectionForWire(input.projection), + historyContextItems: input.historyContextItems?.map((row) => ({ + ...row, + item: projectTurnItemForWire(row.item), + })), }); return { kind: "snapshot", diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 9d3e583b832e0..578b1e01f5b89 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -133,10 +133,12 @@ export const layer = HttpApiBuilder.group( anchorThreadId?: Parameters< typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], + deferWork = false, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork, userTurnLimit: anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns @@ -223,12 +225,16 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( - traceLocalHandlerWork, - ); + const snapshot = yield* loadThreadSnapshotWindow( + args.params.threadId, + undefined, + undefined, + args.query.deferWork === "1", + ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, + historyContextItems: snapshot.historyContextItems, }); return { snapshotSequence: snapshot.snapshotSequence, @@ -261,6 +267,11 @@ export const layer = HttpApiBuilder.group( args.query.cursor, args.query.throughEntryId, args.query.view === "conversation", + args.query.view === "work" + ? "work" + : args.query.deferWork === "1" + ? "deferred" + : undefined, ) .pipe( Effect.map((page) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 38ad3b5c7de13..12e1d0109ae47 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import { buildBoundedThreadProjection, + deferWorkHistoryRows, boundedTimelineEncodedBytes, computeLatestLocalTurnOrdinal, encodeThreadHistoryCursor, @@ -155,6 +156,93 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { + it("defers only completed work beyond 75 items and keeps conversation and persistent rows", () => { + const work = Array.from({ length: 76 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; + }); + expect(deferWorkHistoryRows(work.slice(0, 75))).toHaveLength(75); + const compaction = { + ...work[0]!, + sourceItemId: TurnItemId.make("compaction"), + item: { + ...work[0]!.item, + type: "compaction" as const, + }, + }; + const persistent = { + ...work[0]!, + sourceItemId: TurnItemId.make("render"), + item: { + ...work[0]!.item, + type: "dynamic_tool" as const, + persistentOutput: true, + }, + }; + const deferred = deferWorkHistoryRows([ + ...work.slice(0, 30), + compaction, + persistent, + ...work.slice(30), + ]); + expect(deferred.map((row) => row.sourceItemId)).toEqual(["item-0", "compaction", "render"]); + expect(deferred[0]?.item.deferredWork?.itemCount).toBe(76); + for (const runStatus of ["running", "failed", "interrupted", "cancelled"]) { + expect( + deferWorkHistoryRows(work.map((row) => ({ ...row, item: { ...row.item, runStatus } }))), + ).toHaveLength(76); + } + const interrupted = { + ...compaction, + item: { ...compaction.item, type: "run_interrupt_result" as const }, + }; + expect(deferWorkHistoryRows([...work, interrupted])).toHaveLength(77); + const failed = { + ...compaction, + item: { + ...compaction.item, + type: "error" as const, + status: "failed" as const, + nodeId: "root-node", + parentItemId: null, + }, + }; + expect(deferWorkHistoryRows([...work, failed])).toHaveLength(77); + }); + + it("retains a long turn's user prompt when the byte budget cuts through its work", () => { + const items: OrchestrationV2ProjectedTurnItem[] = Array.from({ length: 315 }, (_, index) => { + const row = makeRow(index, { outputBytes: 2_000 }); + return { ...row, item: { ...row.item, runId: RUN } }; + }); + items[0] = { + ...items[0]!, + item: { + ...items[0]!.item, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make("webgl-prompt"), + text: "Investigate WebGL in a separate VM", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + const bounded = buildBoundedThreadProjection({ + projection: makeProjection(items), + snapshotSequence: 1, + }); + + expect(bounded.projection.visibleTurnItems[0]?.item).toEqual(items[0]!.item); + expect(bounded.projection.visibleTurnItems.at(-1)?.item).toEqual(items.at(-1)!.item); + expect(bounded.projection.turnItems).toContain(items[0]!.item); + expect(bounded.projection.visibleTurnItems.length).toBeLessThan(items.length); + expect(bounded.hasMoreHistory).toBe(true); + expect(boundedTimelineEncodedBytes(bounded.projection)).toBeLessThanOrEqual( + THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + ); + }); + it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { const humanTurn = (length: number) => { const items = Array.from({ length }, (_, index) => makeRow(index)); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 579639ca83ab5..fd85232f3c271 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -5,6 +5,10 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { + DEFERRED_WORK_ITEM_LIMIT, + isDeferrableWorkItem, +} from "@t3tools/shared/orchestrationV2WorkHistory"; /** * Every window stops at the byte budget. A window that holds a user turn also @@ -46,6 +50,7 @@ export type ThreadHistoryCursorPayload = { readonly st: string; readonly si: string; readonly p: number; + readonly inclusive?: boolean; }; type HistoryRow = Pick< @@ -58,6 +63,13 @@ type HistoryRow = Pick< readonly inputIntent?: string; readonly createdBy?: string; readonly runId?: string | null; + readonly status?: OrchestrationV2TurnItem["status"]; + readonly nodeId?: string | null; + readonly parentItemId?: string | null; + readonly ordinal?: number; + readonly runStatus?: string | undefined; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; }; export type SelectTimelinePageResult = { @@ -114,6 +126,7 @@ export function encodeThreadHistoryCursor(input: { readonly sourceThreadId: ThreadId | string; readonly sourceItemId: TurnItemId | string; readonly position: number; + readonly inclusive?: boolean; }): string { return encodeCursorPayload({ v: 1, @@ -121,6 +134,7 @@ export function encodeThreadHistoryCursor(input: { st: String(input.sourceThreadId), si: String(input.sourceItemId), p: input.position, + ...(input.inclusive ? { inclusive: true } : {}), }); } @@ -153,7 +167,8 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa !("p" in parsed) || typeof (parsed as { p: unknown }).p !== "number" || !Number.isInteger((parsed as { p: number }).p) || - (parsed as { p: number }).p < 0 + (parsed as { p: number }).p < 0 || + ("inclusive" in parsed && typeof parsed.inclusive !== "boolean") ) { throw new InvalidThreadHistoryCursorError(); } @@ -167,6 +182,94 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } +export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { + const blocked = new Set( + items.flatMap(({ item }) => + item.runId != null && + (item.type === "run_interrupt_request" || + item.type === "run_interrupt_result" || + (item.type === "error" && item.status === "failed" && item.parentItemId == null)) + ? [item.runId] + : [], + ), + ); + const segments = new Map(); + const groups = new Map(); + for (const row of items) { + const item = row.item; + if (item.runId == null) continue; + if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); + if ( + item.runStatus !== "completed" || + blocked.has(item.runId) || + !isDeferrableWorkItem({ ...item, status: item.status ?? "idle" }) + ) + continue; + const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + const omitted = new Set(); + const anchors = new Map(); + for (const group of groups.values()) { + if (group.length <= DEFERRED_WORK_ITEM_LIMIT) continue; + const first = group[0]!; + const last = group.at(-1)!; + for (const row of group.slice(1)) omitted.add(row); + anchors.set(first, { + ...first, + item: { + ...first.item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: last.sourceThreadId, + sourceItemId: last.sourceItemId, + position: last.position, + inclusive: true, + }), + itemCount: group.length, + lastOrdinal: last.item.ordinal ?? last.position, + }, + }, + }); + } + return items.flatMap((row) => (omitted.has(row) ? [] : [anchors.get(row) ?? row])); +} + +export function selectDeferredWorkPage(input: { + readonly items: ReadonlyArray; + readonly cursor: string; + readonly throughEntryId?: string | undefined; + readonly snapshotSequence: number; + readonly rowEncodedBytes?: ((row: Row) => number) | undefined; +}): SelectTimelinePageResult { + const cursor = decodeThreadHistoryCursor(input.cursor); + const end = findCursorIndex(input.items, cursor) + (cursor.inclusive ? 1 : 0); + const start = input.items.findIndex((row) => row.sourceItemId === input.throughEntryId); + if (start < 0 || start >= end) throw new InvalidThreadHistoryCursorError(); + const anchor = input.items[start]!; + const items = input.items + .slice(start, end) + .filter( + (row) => + row.sourceThreadId === anchor.sourceThreadId && + row.item.runId === anchor.item.runId && + isDeferrableWorkItem({ ...row.item, status: row.item.status ?? "idle" }), + ); + return selectOlderTimelinePage({ + items, + exclusiveEndIndex: items.length, + snapshotSequence: input.snapshotSequence, + rowEncodedBytes: input.rowEncodedBytes, + policy: { + maxItems: DEFERRED_WORK_ITEM_LIMIT, + maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + }, + }); +} + /** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ export function threadHistoryTurnStarts( items: ReadonlyArray, @@ -373,6 +476,22 @@ export function missingInterruptRequests( ); } +/** A window can start inside a long run, but its work still needs the user's prompt. */ +export function missingUserTurnStarts( + items: ReadonlyArray, + page: ReadonlyArray>, +): Row[] { + const runs = new Set(page.flatMap((row) => (row.item.runId == null ? [] : [row.item.runId]))); + for (const row of page) { + if (isThreadHistoryUserTurn(row.item) && row.item.runId != null) runs.delete(row.item.runId); + } + if (runs.size === 0) return []; + return items.filter( + (row) => + isThreadHistoryUserTurn(row.item) && row.item.runId != null && runs.has(row.item.runId), + ); +} + function isLocalProjectedRow( projection: OrchestrationV2ThreadProjection, row: OrchestrationV2ProjectedTurnItem, @@ -409,16 +528,7 @@ function localTurnItemsForVisibleWindow( projection: OrchestrationV2ThreadProjection, visible: ReadonlyArray, ): OrchestrationV2TurnItem[] { - const localIds = new Set(); - for (const row of visible) { - if (isLocalProjectedRow(projection, row)) { - localIds.add(String(row.sourceItemId)); - } - } - if (localIds.size === 0) { - return []; - } - return projection.turnItems.filter((item) => localIds.has(String(item.id))); + return visible.filter((row) => isLocalProjectedRow(projection, row)).map((row) => row.item); } function messagesForBoundedProjection( @@ -492,6 +602,7 @@ export function buildBoundedThreadProjection(input: { readonly projection: OrchestrationV2ThreadProjection; readonly snapshotSequence: number; readonly policy?: ThreadHistoryPagePolicy | undefined; + readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"] | undefined; }): BoundedProjectionResult { const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY; const threadId = input.projection.thread.id; @@ -528,6 +639,14 @@ export function buildBoundedThreadProjection(input: { reserve += bytesOfJson(item); } } + for (const row of [ + ...(input.historyContextItems ?? []), + ...controlProjection.visibleTurnItems, + ]) { + if (isThreadHistoryUserTurn(row.item)) { + reserve += projectedRowBoundedSnapshotEncodedBytes(row, threadId); + } + } return reserve; })(); const controlBytes = bytesOfJson({ @@ -550,7 +669,13 @@ export function buildBoundedThreadProjection(input: { policy: windowPolicy, rowEncodedBytes: (row) => projectedRowBoundedSnapshotEncodedBytes(row, threadId), }); - const visibleTurnItems = renumberPositions(window.items); + const visibleTurnItems = renumberPositions([ + ...missingUserTurnStarts( + [...(input.historyContextItems ?? []), ...controlProjection.visibleTurnItems], + window.items, + ), + ...window.items, + ]); const windowTurnItems = localTurnItemsForVisibleWindow(controlProjection, visibleTurnItems); const dependencyTurnItems = retainedInterruptRequestTurnItems( controlProjection, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e5696e6f5cd17..3afd4eb795748 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -639,6 +639,7 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; } @@ -789,6 +790,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; readonly acceptCompactIds?: boolean; @@ -876,6 +878,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs useBoundedSnapshot ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork: input.acceptDeferredWork === true, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) @@ -2051,6 +2054,7 @@ export const layerWsRpc = ( buildBoundedThreadProjection({ projection: projectThreadProjectionForWire(snapshot.projection), snapshotSequence: snapshot.snapshotSequence, + historyContextItems: snapshot.historyContextItems, }).projection, ), Effect.mapError( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 169acd8630d05..f27a238f8a58c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -36,6 +36,47 @@ import { } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { + it("carries deferred work through a settled turn fold without exposing it before expansion", () => { + const fixture = makeStreamingTimelineFixture("Done"); + const visibleTurnItems = fixture.visibleTurnItems + .filter((row) => row.item.runId === fixture.runId) + .map((row) => ({ + ...row, + item: + row.item.type === "assistant_message" + ? { ...row.item, status: "completed" as const, streaming: false } + : row.item.type === "dynamic_tool" + ? { + ...row.item, + deferredWork: { + cursor: "work", + itemCount: 100, + lastOrdinal: row.item.ordinal, + }, + } + : row.item, + })); + const input = { + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems, + optimisticMessages: [], + }), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }; + const collapsed = deriveMessagesTimelineRows(input); + const fold = collapsed.find((row) => row.kind === "turn-fold"); + expect(fold).toMatchObject({ expanded: false, deferredWorkItemIds: ["live-work"] }); + expect(collapsed.some((row) => row.kind === "work")).toBe(false); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedRunIds: new Set([fixture.runId]), + }); + expect(expanded.some((row) => row.kind === "work")).toBe(true); + }); + it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => { const result = computeMessageDurationStart([ { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0648d1832f078..584c300b3f39d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -566,6 +566,7 @@ type MessagesTimelineRowContent = } | { kind: "turn-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -1678,9 +1679,17 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { + const deferredWorkItemIds = timelineEntries.flatMap((entry) => + turnFold.entryIds.has(entry.id) && + entry.kind === "work" && + entry.entry.projectedItem?.item.deferredWork + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), createdAt: turnFold.createdAt, runId: turnFold.runId, label: turnFold.label, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fbfa67e61bf8e..130049577ea73 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,5 @@ +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { MarkdownFindContext } from "./markdownFindContext"; @@ -31,6 +33,8 @@ import { use, useContext, useLayoutEffect, + useEffect, + useEffectEvent, useRef, useState, type ReactNode, @@ -1552,6 +1556,48 @@ function ContextCompactionTimelineRow({ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const loadWork = useAtomCommand(threadEnvironment.loadWorkHistory, { reportFailure: false }); + const ref = parseScopedThreadKey(ctx.routeThreadKey); + const environmentId = ref?.environmentId; + const threadId = ref?.threadId; + const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; + const [failure, setFailure] = useState<{ attempt: number; message: string } | null>(null); + const [retry, setRetry] = useState(0); + const error = anchorsKey && failure?.attempt === retry ? failure.message : null; + const loading = row.expanded && anchorsKey !== "" && error === null; + const requestWork = useEffectEvent( + (anchorIds: string[], environmentId: EnvironmentId, threadId: ThreadId) => + Promise.all( + anchorIds.map((anchorId) => loadWork({ environmentId, input: { threadId, anchorId } })), + ), + ); + // oxlint-disable-next-line t3code/react-effect-budget -- Expanded folds restore their deferred server history. + useEffect(() => { + if (!row.expanded || !anchorsKey || environmentId === undefined || threadId === undefined) + return; + let active = true; + void requestWork(anchorsKey.split("\n"), environmentId, threadId).then((results) => { + if (!active) return; + const failed = results.some( + (result) => result._tag === "Failure" || result.value._tag === "error", + ); + setFailure( + failed ? { attempt: retry, message: "Couldn't load work. Click to retry." } : null, + ); + }); + return () => { + active = false; + }; + }, [row.expanded, anchorsKey, retry, environmentId, threadId]); + const toggle = () => { + if (error !== null && row.expanded) { + setFailure(null); + setRetry((value) => value + 1); + } else { + setFailure(null); + ctx.onToggleTurnFold(row.runId); + } + }; return (
@@ -1559,10 +1605,14 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.runId)} + onClick={toggle} + aria-busy={loading} className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > - {row.label} + + {row.label} + {loading ? " · Loading work..." : error ? ` · ${error}` : ""} + -
+
{detail ? ( {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 51760756c1085..733dbf14116af 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -15,7 +15,9 @@ The v2 checks pin these invariants: - Cold opens and history pages stop at about 1 MiB of encoded rows. A window that holds a user turn also stops at 10 user turns (20 on older pages) or 500 rows, so ordinary turns stay whole. Any other window, including the inside of one turn longer than 500 rows, stops at 75 rows. The - snapshot query applies the same rule before it decodes payloads. Required control records and + snapshot query applies the same rule before it decodes payloads. Clients can opt into deferring + completed work groups above 75 items, retaining one anchor and fetching their work on expansion. + Messages and items outside those groups remain in normal history paging. Required control records and the at-least-one-row rule can exceed the byte target; it is not a hard cap. - The bounded snapshot does not retain the full duplicate conversation-message table. - Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll @@ -32,8 +34,9 @@ The v2 checks pin these invariants: projects small can increase server allocations on repeated large tool updates. - Raw command output, dynamic-tool result bodies, and inline file-change bodies are omitted at the wire boundary, including small outputs. Explicit failure flags and bounded result IDs - preserve status and grouped action counts without shipping those bodies. Subagent prompts and - results, reasoning text, command lines, search results, and checkpoint file lists still ship. + preserve status and grouped action counts without shipping those bodies. Reasoning text, + command lines, and search results ship when their work group loads. Subagent prompts and results + and checkpoint control records still accompany the initial window. Persisted events remain complete; the existing diff endpoints still provide file content when requested. Explicit cross-environment transcript exports preserve full tool bodies so the destination diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index c7006fd0909dd..42be7e3474859 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -30,7 +30,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1" }, + query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1", deferWork: "1" }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index e0680c17c46f8..f6ba3e9f0c1a7 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -219,8 +219,9 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.visibleTurnItems[0]).toBe(visibleTurnItems[0]); }); - it("replaces only the updated visible item when membership is unchanged", () => { - const first = commandItem("item-first", "first"); + it("replaces only the updated visible item and retains its deferred work cursor", () => { + const deferredWork = { cursor: "remaining-work", itemCount: 100, lastOrdinal: 101 }; + const first = { ...commandItem("item-first", "first"), deferredWork }; const second = commandItem("item-second", "second"); const firstRow = { position: 0, @@ -253,9 +254,10 @@ describe("applyOrchestrationV2ProjectionEvent", () => { } as OrchestrationV2DomainEvent; const next = applyOrchestrationV2ProjectionEvent(projection, event); + expect(next?.visibleTurnItems[0]?.item.deferredWork).toEqual(deferredWork); expect(next?.visibleTurnItems).not.toBe(projection.visibleTurnItems); expect(next?.visibleTurnItems[0]).not.toBe(firstRow); - expect(next?.visibleTurnItems[0]?.item).toBe(updated); + expect(next?.visibleTurnItems[0]?.item).toEqual({ ...updated, deferredWork }); expect(next?.visibleTurnItems[1]).toBe(secondRow); }); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index ffe9eb14dfa2d..fbba7d1bd9dd4 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -370,12 +370,15 @@ export function applyOrchestrationV2ProjectionEvent( case "plan.updated": return { ...base, plans: upsertEntity(base.plans, event.payload) }; case "turn-item.updated": { - const payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); + let payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); // Streamed updates land on the newest items, so search from the end. let itemIndex = projection.turnItems.length - 1; while (itemIndex >= 0 && projection.turnItems[itemIndex]!.id !== payload.id) { itemIndex -= 1; } + const deferredWork = + itemIndex === -1 ? undefined : projection.turnItems[itemIndex]?.deferredWork; + if (deferredWork !== undefined) payload = { ...payload, deferredWork }; if ( partialTimeline && itemIndex === -1 && diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index d61213c243ea8..1799f3bd2482b 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -127,6 +127,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio register: () => Effect.die("unused"), unregister: () => Effect.void, loadEarlier: () => Effect.die("unused"), + loadWork: () => Effect.die("unused"), reconcilePendingWork: (_environmentId, threadId) => options?.reconcilePendingWork?.(threadId) ?? Queue.offer(reconciled, threadId), }), diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ad0cd9ec2a3b4..f3a85c0bc1ad7 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -166,6 +166,19 @@ export function createThreadEnvironmentAtoms( input.throughEntryId, ); }); + const loadWorkHistory = Effect.fn("ThreadCommands.loadWorkHistory")(function* (input: { + readonly threadId: ThreadId; + readonly anchorId: string; + }) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const controller = yield* Effect.serviceOption(ThreadHistoryController.ThreadHistoryController); + if (Option.isNone(controller)) return { _tag: "noop" } as const; + return yield* controller.value.loadWork( + supervisor.target.environmentId, + input.threadId, + input.anchorId, + ); + }); // Runs whether or not the server accepted the interrupt, so a stale cached item still // settles when Stop had nothing to interrupt; the interrupt's own failure still surfaces. @@ -428,6 +441,16 @@ export function createThreadEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), }, }), + loadWorkHistory: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:load-work-history", + execute: loadWorkHistory, + scheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.anchorId]), + }, + }), uploadFeedback: createEnvironmentRpcCommand(runtime, { label: "environment-data:commands:thread:upload-feedback", tag: WS_METHODS.providerUploadFeedback, diff --git a/packages/client-runtime/src/state/threadHistoryController.ts b/packages/client-runtime/src/state/threadHistoryController.ts index c9ae1843ff27d..09b594a0acda8 100644 --- a/packages/client-runtime/src/state/threadHistoryController.ts +++ b/packages/client-runtime/src/state/threadHistoryController.ts @@ -13,6 +13,7 @@ export type ThreadHistoryLoadEarlierResult = | { readonly _tag: "error"; readonly message: string }; export type ThreadHistoryHandler = { + readonly loadWork?: (anchorId: string) => Effect.Effect; readonly loadEarlier: (throughEntryId?: string) => Effect.Effect; readonly reconcilePendingWork: () => Effect.Effect; }; @@ -37,6 +38,11 @@ export class ThreadHistoryController extends Context.Service< threadId: ThreadId, throughEntryId?: string, ) => Effect.Effect; + readonly loadWork: ( + environmentId: EnvironmentId, + threadId: ThreadId, + anchorId: string, + ) => Effect.Effect; readonly reconcilePendingWork: ( environmentId: EnvironmentId, threadId: ThreadId, @@ -93,6 +99,17 @@ export const layer: Layer.Layer = Layer.effect( ); }, ), + loadWork: Effect.fn("ThreadHistoryController.loadWork")( + function (environmentId, threadId, anchorId) { + return Ref.get(handlers).pipe( + Effect.flatMap( + (current) => + current.get(threadKey({ environmentId, threadId }))?.loadWork?.(anchorId) ?? + Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult), + ), + ); + }, + ), reconcilePendingWork: Effect.fn("ThreadHistoryController.reconcilePendingWork")( function (environmentId, threadId) { return Ref.get(handlers).pipe( diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index a9714172e44bd..9947d1f077d61 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -19,7 +19,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( readonly threadId: ThreadId; readonly cursor: string; readonly throughEntryId?: string | undefined; - readonly view?: "conversation" | "activity" | undefined; + readonly view?: "conversation" | "activity" | "work" | undefined; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; @@ -29,6 +29,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( query: { cursor: input.cursor, compactIds: "1", + deferWork: "1", ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 4dd4e7cef5210..91d05ff859670 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -1,9 +1,15 @@ -import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; +import type { + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; -import { mergeOlderHistoryIntoProjection } from "./threadHistoryMerge.ts"; +import { + mergeOlderHistoryIntoProjection, + mergeDeferredWorkIntoProjection, +} from "./threadHistoryMerge.ts"; import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); @@ -39,6 +45,93 @@ function row(index: number): OrchestrationV2ProjectedTurnItem { } describe("threadHistoryMerge", () => { + it("fills deferred work around visible rows and keeps newer edits and inherited ownership", () => { + for (const inherited of [false, true]) { + const source = (index: number) => ({ + ...row(index), + visibility: inherited ? ("inherited" as const) : ("local" as const), + }); + const anchor = { + ...source(1), + item: { + ...source(1).item, + deferredWork: { cursor: "first", itemCount: 4, lastOrdinal: 5 }, + }, + }; + const visible = [source(0), anchor, source(3), source(5)]; + const projection = { + ...v2Projection, + visibleTurnItems: visible, + turnItems: inherited ? [] : visible.map((row) => row.item), + }; + const partial = mergeDeferredWorkIntoProjection( + projection, + anchor.sourceItemId, + [source(4)], + "next", + ); + expect(partial.visibleTurnItems.map((row) => row.sourceItemId)).toEqual([ + "item-0", + "item-1", + "item-3", + "item-4", + "item-5", + ]); + expect(partial.visibleTurnItems[1]?.item.deferredWork?.cursor).toBe("next"); + const stale = { ...source(3), item: { ...source(3).item, title: "stale" } }; + const complete = mergeDeferredWorkIntoProjection( + partial, + anchor.sourceItemId, + [source(1), source(2), stale], + null, + ); + expect(complete.visibleTurnItems.map((row) => row.sourceItemId)).toEqual( + Array.from({ length: 6 }, (_, i) => `item-${i}`), + ); + expect(complete.visibleTurnItems[3]?.item.title).toBe("Command 3"); + expect(complete.visibleTurnItems[1]?.item.deferredWork).toBeUndefined(); + expect(complete.turnItems).toHaveLength(inherited ? 0 : 6); + expect(mergeDeferredWorkIntoProjection(complete, anchor.sourceItemId, [], null)).toBe( + complete, + ); + } + }); + + it("keeps a retained user prompt ahead of work as older pages fill the same turn", () => { + const first = row(0); + if (first.item.type !== "command_execution") throw new Error("Expected command fixture"); + const prompt: OrchestrationV2ProjectedTurnItem = { + ...first, + item: { + ...first.item, + type: "user_message", + messageId: "prompt" as never, + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Investigate WebGL", + attachments: [], + }, + }; + const recent = [prompt, row(5), row(6)]; + let projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + turnItems: recent.map((entry) => entry.item), + visibleTurnItems: recent, + }; + for (const page of [[prompt, row(3), row(4)], [prompt, row(1), row(2)], [prompt]]) { + projection = mergeOlderHistoryIntoProjection(projection, page); + expect(projection.visibleTurnItems[0]?.item).toBe(prompt.item); + expect(projection.visibleTurnItems.map((entry) => entry.item.ordinal)).toEqual( + projection.visibleTurnItems.map((entry) => entry.item.ordinal).toSorted((a, b) => a - b), + ); + } + expect(projection.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual( + Array.from({ length: 7 }, (_, index) => `item-${index}`), + ); + expect(projection.turnItems).toHaveLength(7); + }); + it("fills activity around partial messages in canonical order and preserves live edits", () => { const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] }; const preview = [row(1), row(3)]; diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 24840ee28420a..fd3a2f2b45a1a 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -128,6 +128,70 @@ export function applyHistoryPageMeta( }; } +export function mergeDeferredWorkIntoProjection( + projection: OrchestrationV2ThreadProjection, + anchorId: string, + items: ReadonlyArray, + nextCursor: string | null, +): OrchestrationV2ThreadProjection { + const start = projection.visibleTurnItems.findIndex((row) => row.sourceItemId === anchorId); + const anchor = projection.visibleTurnItems[start]; + const deferred = anchor?.item.deferredWork; + if (anchor === undefined || deferred === undefined) return projection; + let end = start + 1; + while (end < projection.visibleTurnItems.length) { + const row = projection.visibleTurnItems[end]!; + if ( + row.sourceThreadId !== anchor.sourceThreadId || + row.visibility === "synthetic" || + row.item.ordinal > deferred.lastOrdinal + ) + break; + end += 1; + } + const current = new Map( + projection.visibleTurnItems.slice(start, end).map((row) => [projectedItemKey(row), row]), + ); + const local = new Map(projection.turnItems.map((item) => [String(item.id), item])); + for (const row of items) { + if ( + row.sourceThreadId !== anchor.sourceThreadId || + row.item.ordinal < anchor.item.ordinal || + row.item.ordinal > deferred.lastOrdinal || + current.has(projectedItemKey(row)) + ) + continue; + if (row.visibility === "local" && local.has(String(row.item.id))) continue; + current.set(projectedItemKey(row), row); + if (row.visibility === "local") local.set(String(row.item.id), row.item); + } + const { deferredWork: _deferred, ...complete } = anchor.item; + const updated = { + ...anchor, + item: + nextCursor === null + ? complete + : { + ...anchor.item, + deferredWork: { ...deferred, cursor: nextCursor }, + }, + }; + current.set(projectedItemKey(anchor), updated); + if (anchor.visibility === "local") local.set(String(anchor.item.id), updated.item); + const section = [...current.values()].sort( + (left, right) => left.item.ordinal - right.item.ordinal, + ); + return { + ...projection, + turnItems: [...local.values()], + visibleTurnItems: renumberVisible([ + ...projection.visibleTurnItems.slice(0, start), + ...section, + ...projection.visibleTurnItems.slice(end), + ]), + }; +} + export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean { return history.hasMoreHistory || history.error !== null; } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 3c1c153621e28..526d739e2f1cc 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -271,6 +271,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o return { threadState, + loadWork: (anchorId: string) => + historyController.loadWork(TARGET.environmentId, THREAD_ID, anchorId), loadEarlier: (throughEntryId?: string) => historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId), reconcilePendingWork: historyController.reconcilePendingWork(TARGET.environmentId, THREAD_ID), @@ -362,6 +364,112 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { + it.effect.each(["expand", "retry", "target"] as const)( + "loads deferred work only on demand and resumes after an error: %s", + (mode) => + Effect.gen(function* () { + const command = ( + ordinal: number, + ): Extract => ({ + id: TurnItemId.make(`work-${ordinal}`), + threadId: THREAD_ID, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: v2Now, + completedAt: v2Now, + updatedAt: v2Now, + type: "command_execution", + input: "pwd", + }); + const anchor = { + ...command(1), + deferredWork: { cursor: "first", itemCount: 3, lastOrdinal: 4 }, + }; + const notice: OrchestrationV2TurnItem = { + ...command(2), + type: "system_notice", + message: "Visible notice", + }; + const row = (item: OrchestrationV2TurnItem) => ({ + position: item.ordinal, + visibility: "local" as const, + sourceThreadId: THREAD_ID, + sourceItemId: item.id, + item, + }); + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + historyHttpClient: HttpClient.make((request, url) => { + expect(url.searchParams.get("view")).toBe("work"); + expect(url.searchParams.get("throughEntryId")).toBe(anchor.id); + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "second" && mode === "retry" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 14, + items: [row(command(cursor === "first" ? 4 : 3))], + nextCursor: cursor === "first" ? "second" : null, + hasMoreHistory: cursor === "first", + }), + ), + ); + }), + }); + yield* Queue.offer(harness.inputs, { + ...snapshot( + { + ...BASE_PROJECTION, + turnItems: [anchor, notice], + visibleTurnItems: [row(anchor), row(notice)], + }, + 14, + ), + historyCursor: "older", + hasMoreHistory: true, + latestLocalTurnOrdinal: 4, + }); + yield* awaitThreadState( + harness.observed, + (state) => state.history.historyCursor === "older", + ); + expect(requests).toEqual([]); + const result = yield* mode === "target" + ? harness.loadEarlier("work-3") + : harness.loadWork(anchor.id); + if (mode === "retry") { + expect(result._tag).toBe("error"); + const partial = Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).data); + expect(partial.visibleTurnItems[0]?.item.deferredWork?.cursor).toBe("second"); + expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); + } else expect(result).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + const projection = Option.getOrThrow(state.data); + expect(projection.visibleTurnItems.map((row) => row.item.ordinal)).toEqual([1, 2, 3, 4]); + expect(projection.visibleTurnItems[0]?.item.deferredWork).toBeUndefined(); + expect(state.history.historyCursor).toBe("older"); + expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); + expect(requests).toEqual( + mode === "retry" ? ["first", "second", "second"] : ["first", "second"], + ); + }), + ); + it.effect("discards an oversized version-4 cache and requests a fresh bounded snapshot", () => Effect.gen(function* () { const item: OrchestrationV2TurnItem = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 693af5329fe1b..e5ff90b9d69b0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -45,6 +45,7 @@ import { EMPTY_THREAD_HISTORY_META, isActiveHistoryRequestCursor, mergeOlderHistoryIntoProjection, + mergeDeferredWorkIntoProjection, type ThreadHistoryMeta, } from "./threadHistoryMerge.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; @@ -656,9 +657,106 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + const loadingWork = yield* Ref.make(new Set()); + const loadWork = Effect.fn("EnvironmentThreadState.loadWork")(function* (anchorId: string) { + const acquired = yield* Ref.modify(loadingWork, (current) => + current.has(anchorId) ? [false, current] : [true, new Set([...current, anchorId])], + ); + if (!acquired) return { _tag: "busy" } as const; + return yield* Effect.gen(function* () { + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared) || Option.isNone(httpClient)) + return { _tag: "error", message: "Environment is not connected." } as const; + while (true) { + const current = yield* SubscriptionRef.get(state); + const deferred = Option.getOrNull(current.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === anchorId, + )?.item.deferredWork; + if (deferred === undefined) return { _tag: "loaded" } as const; + const result = yield* fetchEnvironmentThreadHistoryPage({ + prepared: prepared.value, + threadId, + cursor: deferred.cursor, + throughEntryId: anchorId, + view: "work", + signer: dpopSigner, + remoteAuthorization, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); + if (Result.isFailure(result)) + return { _tag: "error", message: formatHistoryError(result.failure) } as const; + const page = result.success; + const applied = yield* applyLock.withPermits(1)( + SubscriptionRef.modify(state, (latest) => { + const projection = Option.getOrNull(latest.data); + if ( + projection?.visibleTurnItems.find((row) => row.sourceItemId === anchorId)?.item + .deferredWork?.cursor !== deferred.cursor + ) + return [false, latest] as const; + return [ + true, + { + ...latest, + data: Option.some( + mergeDeferredWorkIntoProjection( + projection, + anchorId, + page.items, + page.nextCursor, + ), + ), + history: { ...latest.history, expanded: true }, + }, + ] as const; + }), + ); + if (!applied) return { _tag: "noop" } as const; + if (page.nextCursor === null) { + yield* remember; + return { _tag: "loaded" } as const; + } + if (page.nextCursor === deferred.cursor) + return { _tag: "error", message: "Work history did not advance." } as const; + } + }).pipe( + Effect.ensuring( + Ref.update(loadingWork, (current) => { + const next = new Set(current); + next.delete(anchorId); + return next; + }), + ), + ); + }); + const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* ( throughEntryId?: string, ) { + const revealTarget = Effect.fn("EnvironmentThreadState.revealHistoryTarget")(function* () { + if (throughEntryId === undefined) return; + const visible = + Option.getOrNull((yield* SubscriptionRef.get(state)).data)?.visibleTurnItems ?? []; + const hasTarget = () => + SubscriptionRef.get(state).pipe( + Effect.map( + (current) => + Option.getOrNull(current.data)?.visibleTurnItems.some( + ({ item }) => + item.id === throughEntryId || + ("messageId" in item && item.messageId === throughEntryId), + ) === true, + ), + ); + if (yield* hasTarget()) return; + for (const row of visible) { + if (row.item.deferredWork === undefined) continue; + const result = yield* loadWork(row.sourceItemId); + if (result._tag === "error") return result; + if (yield* hasTarget()) return { _tag: "loaded" } as const; + } + }); + const revealed = yield* revealTarget(); + if (revealed !== undefined) return revealed; const current = yield* SubscriptionRef.get(state); if ( current.status === "deleted" || @@ -753,6 +851,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (result._tag !== "loaded") return result; if (complete) yield* remember; } + const revealed = yield* revealTarget(); + if (revealed !== undefined) return revealed; return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; }); @@ -832,6 +932,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (Option.isSome(historyController)) { const scope = yield* Scope.Scope; const registration = yield* historyController.value.register(environmentId, threadId, { + loadWork: (anchorId) => + loadWork(anchorId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), loadEarlier: (throughEntryId) => loadEarlier(throughEntryId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), reconcilePendingWork: () => @@ -972,6 +1074,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const, + acceptDeferredWork: true as const, acceptCompactTurnItems: true as const, acceptCompactIds: true as const, } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 330e292181d46..c196a3c9f7d0b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -673,6 +673,7 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ }); const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + deferWork: Schema.optionalKey(Schema.String), compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), @@ -681,7 +682,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, throughEntryId: Schema.optional(TrimmedNonEmptyString), - view: Schema.optional(Schema.Literals(["conversation", "activity"])), + view: Schema.optional(Schema.Literals(["conversation", "activity", "work"])), + deferWork: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f4e8303972267..6a055850a43dc 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1387,6 +1387,14 @@ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; const OrchestrationV2TurnItemBaseFields = { + /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ + deferredWork: Schema.optionalKey( + Schema.Struct({ + cursor: TrimmedNonEmptyString, + itemCount: PositiveInt, + lastOrdinal: NonNegativeInt, + }), + ), toolNonExecutionKind: Schema.optional(Schema.String), toolSurface: Schema.optional(ToolActivitySurface), toolIcon: Schema.optional(ToolActivityIcon), @@ -3534,6 +3542,7 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + acceptDeferredWork: Schema.optionalKey(Schema.Boolean), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/package.json b/packages/shared/package.json index 0bfb6cdf081e1..1eee3e6f9abf0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./orchestrationV2WorkHistory": { + "types": "./src/orchestrationV2WorkHistory.ts", + "import": "./src/orchestrationV2WorkHistory.ts" + }, "./changedFileKind": { "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" diff --git a/packages/shared/src/orchestrationV2WorkHistory.ts b/packages/shared/src/orchestrationV2WorkHistory.ts new file mode 100644 index 0000000000000..6470da5defa02 --- /dev/null +++ b/packages/shared/src/orchestrationV2WorkHistory.ts @@ -0,0 +1,34 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { htmlRenderFromToolItem, mcpAppFromToolItem } from "./toolOutput.ts"; + +export const DEFERRED_WORK_ITEM_LIMIT = 75; + +const DEFERRED_WORK_ITEM_TYPES = [ + "command_execution", + "reasoning", + "file_change", + "file_search", + "web_search", + "dynamic_tool", +] as const; + +export function isDeferrableWorkItem( + item: Pick & { + readonly persistentOutput?: boolean; + readonly toolName?: string | null; + readonly output?: unknown; + readonly viewedImagePath?: string; + }, +): boolean { + if (item.status !== "completed" || item.persistentOutput === true) return false; + if (item.type === "dynamic_tool") { + const tool = { toolName: item.toolName, output: item.output }; + if ( + item.viewedImagePath !== undefined || + htmlRenderFromToolItem(tool) !== undefined || + mcpAppFromToolItem(tool) !== undefined + ) + return false; + } + return DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type); +} From a7111d40e8ee59c5d066d24b7611d846c04e4bc7 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 21:18:44 -0500 Subject: [PATCH 02/19] fix(history): simplify deferred loading and address review findings --- .../src/features/threads/ThreadFeed.tsx | 57 ++--- apps/mobile/src/lib/threadActivity.test.ts | 29 --- .../src/orchestration-v2/Orchestrator.ts | 1 - .../orchestration-v2/ProjectionStore.test.ts | 70 +++--- .../src/orchestration-v2/ProjectionStore.ts | 147 +++--------- .../src/orchestration-v2/ThreadStream.test.ts | 43 ---- .../src/orchestration-v2/ThreadStream.ts | 7 +- apps/server/src/orchestration-v2/http.ts | 1 - .../threadHistoryPaging.test.ts | 33 --- .../orchestration-v2/threadHistoryPaging.ts | 80 +++---- apps/server/src/ws.ts | 1 - .../chat/MessagesTimeline.logic.test.ts | 41 ---- .../src/components/chat/MessagesTimeline.tsx | 62 +++-- .../src/state/threadCommands.test.ts | 1 - .../src/state/threadCommands.ts | 25 +- .../src/state/threadHistoryController.ts | 27 +-- .../src/state/threadHistoryHttp.ts | 2 +- .../src/state/threadHistoryMerge.test.ts | 120 ++-------- .../src/state/threadHistoryMerge.ts | 126 +++++----- .../src/state/threads-sync.test.ts | 217 +++++++++--------- packages/client-runtime/src/state/threads.ts | 192 +++++----------- packages/shared/package.json | 4 - .../shared/src/orchestrationV2WorkHistory.ts | 34 --- 23 files changed, 445 insertions(+), 875 deletions(-) delete mode 100644 packages/shared/src/orchestrationV2WorkHistory.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 346ee5f873d65..7116f72dff47f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1247,42 +1247,42 @@ function RunFoldButton({ rowSizing: ReturnType; iconColor: ColorValue; }) { - const loadWork = useAtomCommand(threadEnvironment.loadWorkHistory, { reportFailure: false }); + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? ""; - const [failure, setFailure] = useState<{ attempt: number; message: string } | null>(null); - const [retry, setRetry] = useState(0); - const error = anchorsKey && failure?.attempt === retry ? failure.message : null; - const loading = entry.expanded && anchorsKey !== "" && error === null; - const requestWork = useEffectEvent( - (anchorIds: string[], environmentId: EnvironmentId, threadId: ThreadId) => - Promise.all( - anchorIds.map((anchorId) => loadWork({ environmentId, input: { threadId, anchorId } })), - ), - ); - // oxlint-disable-next-line t3code/react-effect-budget -- Expanded folds restore their deferred server history. + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey) return Promise.resolve([]); + return Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ); + }; + // oxlint-disable-next-line t3code/react-effect-budget -- Restored expanded folds also need their server history. useEffect(() => { - if (!entry.expanded || !anchorsKey) return; - let active = true; - void requestWork(anchorsKey.split("\n"), environmentId, threadId).then((results) => { - if (!active) return; - const failed = results.some( - (result) => result._tag === "Failure" || result.value._tag === "error", + if (entry.expanded) + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), ); - setFailure(failed ? { attempt: retry, message: "Couldn't load work. Tap to retry." } : null); - }); - return () => { - active = false; - }; - }, [entry.expanded, anchorsKey, retry, environmentId, threadId]); + }, [entry.expanded, load]); + const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null; + const loading = entry.expanded && anchorsKey !== "" && !failed; const toggle = () => { - if (error !== null && entry.expanded) { - setFailure(null); - setRetry((value) => value + 1); + if (error && entry.expanded) { + setFailed(false); + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); } else { - setFailure(null); + setFailed(false); onToggle(entry.runId); } }; + return ( { - it("keeps deferred work attached to its collapsed run and drops the cursor after loading", () => { - const work = { - ...command(), - deferredWork: { cursor: "work", itemCount: 100, lastOrdinal: 100 }, - }; - const rows = [ - projected(userMessage(), 0), - projected(work, 1), - projected(assistantMessage(), 2), - ]; - const cache = createThreadFeedCache(); - const presented = deriveThreadFeedPresentation( - buildThreadFeed(rows, { cache }), - null, - new Set(), - ); - expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); - expect(presented[1]).toMatchObject({ deferredWorkItemIds: [work.id], expanded: false }); - const loaded = deriveThreadFeedPresentation( - buildThreadFeed([rows[0]!, projected(command(), 1), rows[2]!], { cache }), - null, - new Set([runId]), - ); - expect(loaded.find((entry) => entry.type === "run-fold")).toMatchObject({ expanded: true }); - expect(loaded.find((entry) => entry.type === "run-fold")).not.toHaveProperty( - "deferredWorkItemIds", - ); - }); - it("keeps async answers in question history instead of user bubbles", () => { const requestId = RuntimeRequestId.make("question"); const question: OrchestrationV2TurnItem = { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 50f606a8bf73a..563de061c5c1d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -385,7 +385,6 @@ export interface OrchestratorV2Shape { readonly schemaVersion: number; readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; - readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }, OrchestratorV2Error >; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f4dcc84caf0b0..84928bdec7917 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -187,6 +187,44 @@ const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate return threadId; }); +it.effect("memory windows keep deferred candidates after compaction", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("deferred-window"); + const projection = yield* store.getThreadProjection(threadId); + const run = projection.runs[0]!; + const item = projection.turnItems[0]!; + yield* store.apply({ + id: EventId.make("event:deferred-window:completed"), + type: "run.updated", + threadId, + occurredAt: projection.updatedAt, + payload: { ...run, status: "completed", completedAt: projection.updatedAt }, + }); + for (let ordinal = 1; ordinal <= 80; ordinal += 1) { + yield* store.apply({ + id: EventId.make(`event:deferred-window:${ordinal}`), + type: "turn-item.updated", + threadId, + occurredAt: projection.updatedAt, + payload: { + ...item, + id: ordinal === 1 ? item.id : TurnItemId.make(`item:deferred-window:${ordinal}`), + ordinal, + status: "completed", + }, + }); + } + const snapshot = yield* store.getThreadSnapshotWindow(threadId, { + rowLimit: 77, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(snapshot.projection.visibleTurnItems, 1); + assert.strictEqual(snapshot.projection.visibleTurnItems[0]?.item.deferredWork?.itemCount, 80); + }).pipe(Effect.provide(ProjectionStore.layerMemory)), +); + it.effect.each([ ["memory", ProjectionStore.layerMemory], ["SQL", layerTest], @@ -1924,33 +1962,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') WHERE thread_id = ${threadId} `; - const longTurnSnapshot = yield* humanWindow(); - assert.lengthOf(longTurnSnapshot.projection.visibleTurnItems, sqlPageLimit); - const longTurnPage = buildBoundedThreadProjection({ - ...longTurnSnapshot, - }); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 1000, + payload_json = json_set(payload_json, '$.ordinal', 1000) + WHERE thread_id = ${threadId} AND ordinal = 999`; const promptId = TurnItemId.make("turn-item:bounded-sql-history:1"); - assert.strictEqual(longTurnPage.projection.visibleTurnItems[0]?.sourceItemId, promptId); - const longTurnIds = new Set( - longTurnPage.projection.visibleTurnItems.map((row) => row.sourceItemId), - ); - let longTurnCursor = longTurnPage.historyCursor; - for (let pages = 0; longTurnCursor !== null; pages += 1) { - assert.isBelow(pages, 20); - assert.notStrictEqual(decodeThreadHistoryCursor(longTurnCursor).si, promptId); - const page = yield* projectionStore.getThreadHistoryPage(threadId, longTurnCursor); - assert.strictEqual(page.items[0]?.sourceItemId, promptId); - for (const row of page.items) longTurnIds.add(row.sourceItemId); - longTurnCursor = page.nextCursor; - } - assert.strictEqual(longTurnIds.size, 1_000); - const conversation = yield* projectionStore.getThreadHistoryPage( - threadId, - longTurnPage.historyCursor!, - undefined, - true, - ); - assert.strictEqual(conversation.items[0]?.sourceItemId, promptId); const deferred = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, userTurnLimit: 10, @@ -1976,6 +1991,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { workCursor = page.nextCursor; } assert.strictEqual(workIds.size, 999); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 999, + payload_json = json_set(payload_json, '$.ordinal', 999) + WHERE turn_item_id = 'turn-item:bounded-sql-history:999'`; yield* sql` UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 411da1c4c9ac2..510c97cf1ff93 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -94,7 +94,6 @@ import * as SqlClient from "effect/sql/SqlClient"; import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; -import { DEFERRED_WORK_ITEM_LIMIT } from "@t3tools/shared/orchestrationV2WorkHistory"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; import { isThreadHistoryUserTurn, @@ -102,12 +101,10 @@ import { threadHistoryTurnStarts, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, - missingUserTurnStarts, deferWorkHistoryRows, selectDeferredWorkPage, encodeThreadHistoryCursor, decodeThreadHistoryCursor, - projectedRowEncodedBytes, olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, @@ -651,7 +648,6 @@ export interface ProjectionStoreV2Shape { readonly schemaVersion: number; readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; - readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }, ProjectionStoreV2Error >; @@ -2900,7 +2896,7 @@ export const layer: Layer.Layer = ); // Share selection so counting discarded ancestry preserves payload-read positions. - const retainedWindowRows = ( + const retainedWindowRows = Effect.fn("ProjectionStore.retainedWindowRows")(function* ( threadId: ThreadId, window: { readonly rowLimit: number; @@ -2909,7 +2905,8 @@ export const layer: Layer.Layer = readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; }, - ) => sql` + ) { + const eligible = sql` WITH eligible AS NOT MATERIALIZED ( SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, item.run_id, item.node_id, item.type, item.status, run.status AS run_status @@ -2974,7 +2971,10 @@ export const layer: Layer.Layer = AND request.type = 'run_interrupt_request' ) ) - ), run_starts AS ( + )`; + // Resolve anchors separately: repeated CTE expansion exceeds SQLite's subquery limit. + const anchors = yield* sql<{ readonly ordinal: number; readonly latest: number | null }>` + ${eligible}, run_starts AS ( -- A wake (PR watch, task completion) starts its run with a -- notification instead of a user message. Read the first -- input of each run, newest run first, and add the @@ -3038,7 +3038,12 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors - ), work_segments AS ( + ) + SELECT ordinal, (SELECT MAX(ordinal) FROM user_anchors) AS latest FROM boundary + `; + const boundary = anchors[0]!; + return sql` + ${eligible}, work_segments AS ( SELECT item_rowid, ordinal, turn_item_id, run_id, type, MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, @@ -3055,17 +3060,17 @@ export const layer: Layer.Layer = ) THEN 1 ELSE 0 END AS can_defer FROM eligible WHERE ${window.deferWork ? 1 : 0} = 1 - AND EXISTS (SELECT 1 FROM user_anchors) - AND ordinal >= (SELECT ordinal FROM boundary) + AND ${boundary.latest === null ? 0 : 1} = 1 + AND ordinal >= ${boundary.ordinal} ), deferred_work AS ( SELECT run_id, segment, MIN(ordinal) AS first_ordinal, MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count FROM work_segments WHERE can_defer = 1 - GROUP BY run_id, segment HAVING COUNT(*) > ${DEFERRED_WORK_ITEM_LIMIT} + GROUP BY run_id, segment HAVING COUNT(*) > ${THREAD_HISTORY_PAGE_POLICY.maxItems} ), window_items AS NOT MATERIALIZED ( SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 - OR NOT EXISTS (SELECT 1 FROM user_anchors) + OR ${boundary.latest === null ? 1 : 0} = 1 UNION ALL SELECT item.item_rowid, item.ordinal, item.turn_item_id, item.run_id, item.type FROM work_segments item LEFT JOIN deferred_work deferred @@ -3079,7 +3084,7 @@ export const layer: Layer.Layer = FROM ( SELECT 1 FROM window_items - WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) + WHERE ordinal >= ${boundary.latest} LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} ) ), selected AS ( @@ -3088,7 +3093,7 @@ export const layer: Layer.Layer = -- carrying it through the union and sort cost about a second. SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM window_items - WHERE ordinal >= (SELECT ordinal FROM boundary) + WHERE ordinal >= ${boundary.ordinal} ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 @@ -3121,6 +3126,7 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ) `; + }); const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( function* ( @@ -3169,7 +3175,7 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ` : yield* sql` - ${retainedWindowRows(threadId, window)} + ${yield* retainedWindowRows(threadId, window)} -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite -- skips sorting again once the payloads are attached. Carry the -- rowid through selection to avoid a second string-key index lookup. @@ -3181,7 +3187,11 @@ export const layer: Layer.Layer = LEFT JOIN deferred_work deferred ON deferred.run_id = item.run_id AND deferred.first_ordinal = item.ordinal LEFT JOIN orchestration_v2_projection_turn_items tail - ON tail.thread_id = item.thread_id AND tail.ordinal = deferred.last_ordinal + ON tail.turn_item_id = ( + SELECT turn_item_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = item.thread_id AND ordinal = deferred.last_ordinal + ORDER BY turn_item_id DESC LIMIT 1 + ) ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. @@ -3885,7 +3895,7 @@ export const layer: Layer.Layer = .length >= Math.max(window.userTurnLimit, THREAD_HISTORY_PAGE_POLICY.maxUserTurns) ) { const counted = yield* sql<{ readonly inherited_rows: number | null }>` - ${retainedWindowRows(forkedFrom.threadId, { + ${yield* retainedWindowRows(forkedFrom.threadId, { ...sourceWindow, rowLimit: sourceWindow.suppressLocal ? 0 : sourceWindow.rowLimit, })} @@ -5698,7 +5708,7 @@ export const layer: Layer.Layer = const policy = workMode === "work" ? { - maxItems: DEFERRED_WORK_ITEM_LIMIT, + maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, } : selection.policy; @@ -5754,37 +5764,12 @@ export const layer: Layer.Layer = ? page.items.filter((row) => isConversationHistoryItem(row.item)) : page.items, ); - const prompts = - workMode === "work" - ? [] - : [ - ...items.filter((row) => isThreadHistoryUserTurn(row.item)), - ...(yield* materialize(missingUserTurnStarts(indexed, page.items))), - ]; - const promptBytes = prompts.reduce( - (sum, row) => sum + projectedRowEncodedBytes(row), - 0, - ); - const bounded = selectRecentTimelineWindow({ - items, - snapshotSequence, - policy: { - ...policy, - maxEncodedBytes: Math.max(1, policy.maxEncodedBytes - promptBytes), - }, - }); + const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); const requests = workMode === "work" ? [] : yield* materialize(missingInterruptRequests(indexed, bounded.items)); - const context = [ - ...requests, - ...missingUserTurnStarts( - prompts, - bounded.items.length > 0 ? bounded.items : page.items, - ), - ].sort((left, right) => left.position - right.position); - const pageItems = [...context, ...bounded.items].map((row, position) => ({ + const pageItems = [...requests, ...bounded.items].map((row, position) => ({ ...row, position, })); @@ -5895,44 +5880,6 @@ export const layer: Layer.Layer = : {}), ...(historyAnchor === undefined ? {} : { historyAnchor }), }); - const visible = projection.visibleTurnItems; - const runsWithoutPrompts = new Set( - visible.flatMap((row) => (row.item.runId === null ? [] : [row.item.runId])), - ); - for (const row of visible) { - if (isThreadHistoryUserTurn(row.item) && row.item.runId !== null) - runsWithoutPrompts.delete(row.item.runId); - } - const promptRows = - runsWithoutPrompts.size === 0 - ? [] - : yield* sql` - SELECT prompt.payload_json - FROM json_each(${encodeIdList([...runsWithoutPrompts])}) wanted - CROSS JOIN orchestration_v2_projection_turn_items AS prompt ON prompt.rowid = ( - SELECT input.rowid - FROM orchestration_v2_projection_turn_items AS input - WHERE input.run_id = wanted.value - AND input.type IN ('user_message', 'notification') - ORDER BY input.ordinal - LIMIT 1 - ) - JOIN orchestration_v2_projection_runs AS run ON run.run_id = prompt.run_id - WHERE prompt.type = 'user_message' - AND json_extract(prompt.payload_json, '$.createdBy') = 'user' - AND json_extract(prompt.payload_json, '$.inputIntent') IN ('turn_start', 'queued_turn') - AND run.status <> 'rolled_back' - AND NOT (run.status = 'cancelled' - AND json_extract(prompt.payload_json, '$.inputIntent') = 'queued_turn') - `; - const prompts = yield* decodeRows(decodeTurnItemPayload, threadId)(promptRows); - const historyContextItems = prompts.map((item, position) => ({ - position, - visibility: item.threadId === threadId ? ("local" as const) : ("inherited" as const), - sourceThreadId: item.threadId, - sourceItemId: item.id, - item, - })); const rows = yield* sql<{ readonly snapshot_sequence: number | null }>` SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events @@ -5944,7 +5891,6 @@ export const layer: Layer.Layer = schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, snapshotSequence: rows[0]?.snapshot_sequence ?? 0, projection, - historyContextItems, }; }), ) @@ -7857,17 +7803,7 @@ export const layerMemory: Layer.Layer = Layer.effect( : selectHistoryPageFromCursor({ items: workMode === "deferred" - ? deferWorkHistoryRows( - visible.map((row) => ({ - ...row, - item: { - ...row.item, - runStatus: projection.runs.find( - (run) => run.id === row.item.runId, - )?.status, - }, - })), - ) + ? deferWorkHistoryRows(visible, projection.runs) : visible, cursor, throughEntryId, @@ -7875,7 +7811,6 @@ export const layerMemory: Layer.Layer = Layer.effect( }); const items = [ ...(workMode === "work" ? [] : missingInterruptRequests(visible, page.items)), - ...(workMode === "work" ? [] : missingUserTurnStarts(visible, page.items)), ...page.items, ] .sort((left, right) => left.position - right.position) @@ -7921,16 +7856,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ) + 1; const allCandidates = snapshot.projection.visibleTurnItems.slice(0, anchorIndex); const candidates = options.deferWork - ? deferWorkHistoryRows( - allCandidates.map((row) => ({ - ...row, - item: { - ...row.item, - runStatus: snapshot.projection.runs.find((run) => run.id === row.item.runId) - ?.status, - }, - })), - ) + ? deferWorkHistoryRows(allCandidates, snapshot.projection.runs) : allCandidates; const turnStarts = threadHistoryTurnStarts(candidates.map((row) => row.item)); const turnAnchors = @@ -7947,19 +7873,20 @@ export const layerMemory: Layer.Layer = Layer.effect( ? anchors.length < anchorLimit ? rawStart : anchors.at(-anchorLimit)! - : Math.max(0, anchorIndex - options.rowLimit); - const latestTurnRows = anchors.length > 0 ? anchorIndex - anchors.at(-1)! : 0; + : Math.max(0, candidates.length - options.rowLimit); + const latestTurnRows = anchors.length > 0 ? candidates.length - anchors.at(-1)! : 0; const rowLimit = options.rowLimit > 0 && latestTurnRows > 0 && latestTurnRows <= THREAD_HISTORY_PAGE_POLICY.maxTurnItems ? THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT : options.rowLimit; - const selected = candidates.slice(Math.max(start, anchorIndex - Math.max(0, rowLimit))); + const selected = candidates.slice( + Math.max(start, candidates.length - Math.max(0, rowLimit)), + ); return { ...snapshot, projection: { ...snapshot.projection, visibleTurnItems: selected }, - historyContextItems: missingUserTurnStarts(candidates, selected), }; }), ), diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 9b0cfbeb6a5a3..a5059eada443b 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,8 +1,6 @@ import { EventId, - MessageId, OrchestrationV2ThreadStreamItem, - RunId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -165,47 +163,6 @@ describe("decideThreadResume", () => { expect(snapshot.latestLocalTurnOrdinal).toBe(80); }); - it("includes an out-of-window user prompt in ordinary and compact socket snapshots", () => { - const runId = RunId.make("long-run"); - const full = timelineProjection(80); - const rows = full.visibleTurnItems.map((row) => ({ - ...row, - item: { ...row.item, runId }, - })); - const first = rows[0]!; - if (first.item.type !== "command_execution") throw new Error("Expected command fixture"); - const prompt: OrchestrationV2ProjectedTurnItem = { - ...first, - sourceItemId: TurnItemId.make("prompt-item"), - item: { - ...first.item, - id: TurnItemId.make("prompt-item"), - ordinal: 0, - type: "user_message", - messageId: MessageId.make("prompt"), - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - text: "Investigate WebGL", - attachments: [], - }, - }; - for (const compactTurnItems of [false, true]) { - const snapshot = buildBoundedThreadStreamSnapshot({ - snapshotSequence: 44, - projection: { ...full, visibleTurnItems: rows, turnItems: rows.map((row) => row.item) }, - historyContextItems: [prompt], - compactTurnItems, - }); - const restored = boundedSnapshotProjection(snapshot); - expect(restored.visibleTurnItems[0]?.item).toEqual(prompt.item); - expect(restored.turnItems[0]).toEqual(prompt.item); - expect(restored.visibleTurnItems).toHaveLength(76); - expect(snapshot.hasMoreHistory).toBe(true); - if (compactTurnItems) expect(snapshot.turnItemsOmitLocalVisible).toBe(true); - } - }); - it("rejects a 10 MiB raw replay even when its projected form fits the wire budget", () => { const item = { id: TurnItemId.make("large-dynamic-tool"), diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 414844028ec51..3c4baab80ef07 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,7 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire, projectTurnItemForWire } from "./WireProjection.ts"; +import { projectThreadProjectionForWire } from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -65,15 +65,10 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly compactTurnItems?: boolean; readonly dedupeBodies?: boolean; readonly compactIds?: boolean; - readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"]; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, projection: projectThreadProjectionForWire(input.projection), - historyContextItems: input.historyContextItems?.map((row) => ({ - ...row, - item: projectTurnItemForWire(row.item), - })), }); return { kind: "snapshot", diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 578b1e01f5b89..61ff54d0c794f 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -234,7 +234,6 @@ export const layer = HttpApiBuilder.group( const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, - historyContextItems: snapshot.historyContextItems, }); return { snapshotSequence: snapshot.snapshotSequence, diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 12e1d0109ae47..52b1f40e197b5 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -210,39 +210,6 @@ describe("threadHistoryPaging", () => { expect(deferWorkHistoryRows([...work, failed])).toHaveLength(77); }); - it("retains a long turn's user prompt when the byte budget cuts through its work", () => { - const items: OrchestrationV2ProjectedTurnItem[] = Array.from({ length: 315 }, (_, index) => { - const row = makeRow(index, { outputBytes: 2_000 }); - return { ...row, item: { ...row.item, runId: RUN } }; - }); - items[0] = { - ...items[0]!, - item: { - ...items[0]!.item, - type: "user_message", - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - messageId: MessageId.make("webgl-prompt"), - text: "Investigate WebGL in a separate VM", - attachments: [], - }, - } as OrchestrationV2ProjectedTurnItem; - const bounded = buildBoundedThreadProjection({ - projection: makeProjection(items), - snapshotSequence: 1, - }); - - expect(bounded.projection.visibleTurnItems[0]?.item).toEqual(items[0]!.item); - expect(bounded.projection.visibleTurnItems.at(-1)?.item).toEqual(items.at(-1)!.item); - expect(bounded.projection.turnItems).toContain(items[0]!.item); - expect(bounded.projection.visibleTurnItems.length).toBeLessThan(items.length); - expect(bounded.hasMoreHistory).toBe(true); - expect(boundedTimelineEncodedBytes(bounded.projection)).toBeLessThanOrEqual( - THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, - ); - }); - it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { const humanTurn = (length: number) => { const items = Array.from({ length }, (_, index) => makeRow(index)); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index fd85232f3c271..aecdbb1460129 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -5,10 +5,7 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; -import { - DEFERRED_WORK_ITEM_LIMIT, - isDeferrableWorkItem, -} from "@t3tools/shared/orchestrationV2WorkHistory"; +import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; /** * Every window stops at the byte budget. A window that holds a user turn also @@ -182,7 +179,41 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } -export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { +const DEFERRED_WORK_ITEM_TYPES = [ + "command_execution", + "reasoning", + "file_change", + "file_search", + "web_search", + "dynamic_tool", +] as const; + +function isDeferrableWorkItem( + item: Pick & { + readonly persistentOutput?: boolean; + readonly toolName?: string | null; + readonly output?: unknown; + readonly viewedImagePath?: string; + }, +): boolean { + if (item.status !== "completed" || item.persistentOutput === true) return false; + if (item.type === "dynamic_tool") { + const tool = { toolName: item.toolName, output: item.output }; + if ( + item.viewedImagePath !== undefined || + htmlRenderFromToolItem(tool) !== undefined || + mcpAppFromToolItem(tool) !== undefined + ) + return false; + } + return DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type); +} + +export function deferWorkHistoryRows( + items: ReadonlyArray, + runs: ReadonlyArray<{ readonly id: string; readonly status: string }> = [], +): Row[] { + const statuses = new Map(runs.map((run) => [run.id, run.status])); const blocked = new Set( items.flatMap(({ item }) => item.runId != null && @@ -200,7 +231,7 @@ export function deferWorkHistoryRows(items: ReadonlyArra if (item.runId == null) continue; if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); if ( - item.runStatus !== "completed" || + (item.runStatus ?? statuses.get(item.runId)) !== "completed" || blocked.has(item.runId) || !isDeferrableWorkItem({ ...item, status: item.status ?? "idle" }) ) @@ -213,7 +244,7 @@ export function deferWorkHistoryRows(items: ReadonlyArra const omitted = new Set(); const anchors = new Map(); for (const group of groups.values()) { - if (group.length <= DEFERRED_WORK_ITEM_LIMIT) continue; + if (group.length <= THREAD_HISTORY_PAGE_POLICY.maxItems) continue; const first = group[0]!; const last = group.at(-1)!; for (const row of group.slice(1)) omitted.add(row); @@ -264,7 +295,7 @@ export function selectDeferredWorkPage(input: { snapshotSequence: input.snapshotSequence, rowEncodedBytes: input.rowEncodedBytes, policy: { - maxItems: DEFERRED_WORK_ITEM_LIMIT, + maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, }, }); @@ -476,22 +507,6 @@ export function missingInterruptRequests( ); } -/** A window can start inside a long run, but its work still needs the user's prompt. */ -export function missingUserTurnStarts( - items: ReadonlyArray, - page: ReadonlyArray>, -): Row[] { - const runs = new Set(page.flatMap((row) => (row.item.runId == null ? [] : [row.item.runId]))); - for (const row of page) { - if (isThreadHistoryUserTurn(row.item) && row.item.runId != null) runs.delete(row.item.runId); - } - if (runs.size === 0) return []; - return items.filter( - (row) => - isThreadHistoryUserTurn(row.item) && row.item.runId != null && runs.has(row.item.runId), - ); -} - function isLocalProjectedRow( projection: OrchestrationV2ThreadProjection, row: OrchestrationV2ProjectedTurnItem, @@ -602,7 +617,6 @@ export function buildBoundedThreadProjection(input: { readonly projection: OrchestrationV2ThreadProjection; readonly snapshotSequence: number; readonly policy?: ThreadHistoryPagePolicy | undefined; - readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"] | undefined; }): BoundedProjectionResult { const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY; const threadId = input.projection.thread.id; @@ -639,14 +653,6 @@ export function buildBoundedThreadProjection(input: { reserve += bytesOfJson(item); } } - for (const row of [ - ...(input.historyContextItems ?? []), - ...controlProjection.visibleTurnItems, - ]) { - if (isThreadHistoryUserTurn(row.item)) { - reserve += projectedRowBoundedSnapshotEncodedBytes(row, threadId); - } - } return reserve; })(); const controlBytes = bytesOfJson({ @@ -669,13 +675,7 @@ export function buildBoundedThreadProjection(input: { policy: windowPolicy, rowEncodedBytes: (row) => projectedRowBoundedSnapshotEncodedBytes(row, threadId), }); - const visibleTurnItems = renumberPositions([ - ...missingUserTurnStarts( - [...(input.historyContextItems ?? []), ...controlProjection.visibleTurnItems], - window.items, - ), - ...window.items, - ]); + const visibleTurnItems = renumberPositions(window.items); const windowTurnItems = localTurnItemsForVisibleWindow(controlProjection, visibleTurnItems); const dependencyTurnItems = retainedInterruptRequestTurnItems( controlProjection, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3afd4eb795748..2829824b8f990 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2054,7 +2054,6 @@ export const layerWsRpc = ( buildBoundedThreadProjection({ projection: projectThreadProjectionForWire(snapshot.projection), snapshotSequence: snapshot.snapshotSequence, - historyContextItems: snapshot.historyContextItems, }).projection, ), Effect.mapError( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f27a238f8a58c..169acd8630d05 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -36,47 +36,6 @@ import { } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { - it("carries deferred work through a settled turn fold without exposing it before expansion", () => { - const fixture = makeStreamingTimelineFixture("Done"); - const visibleTurnItems = fixture.visibleTurnItems - .filter((row) => row.item.runId === fixture.runId) - .map((row) => ({ - ...row, - item: - row.item.type === "assistant_message" - ? { ...row.item, status: "completed" as const, streaming: false } - : row.item.type === "dynamic_tool" - ? { - ...row.item, - deferredWork: { - cursor: "work", - itemCount: 100, - lastOrdinal: row.item.ordinal, - }, - } - : row.item, - })); - const input = { - timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ - visibleTurnItems, - optimisticMessages: [], - }), - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaries: [], - supportsConversationRollback: false, - }; - const collapsed = deriveMessagesTimelineRows(input); - const fold = collapsed.find((row) => row.kind === "turn-fold"); - expect(fold).toMatchObject({ expanded: false, deferredWorkItemIds: ["live-work"] }); - expect(collapsed.some((row) => row.kind === "work")).toBe(false); - const expanded = deriveMessagesTimelineRows({ - ...input, - expandedRunIds: new Set([fixture.runId]), - }); - expect(expanded.some((row) => row.kind === "work")).toBe(true); - }); - it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => { const result = computeMessageDurationStart([ { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 130049577ea73..2962955b19b24 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -34,7 +34,6 @@ import { useContext, useLayoutEffect, useEffect, - useEffectEvent, useRef, useState, type ReactNode, @@ -1462,7 +1461,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "thinking" ? : null} ) : null} - {row.kind === "turn-fold" ? : null} + {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1556,45 +1555,42 @@ function ContextCompactionTimelineRow({ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const loadWork = useAtomCommand(threadEnvironment.loadWorkHistory, { reportFailure: false }); + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); const ref = parseScopedThreadKey(ctx.routeThreadKey); const environmentId = ref?.environmentId; const threadId = ref?.threadId; const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; - const [failure, setFailure] = useState<{ attempt: number; message: string } | null>(null); - const [retry, setRetry] = useState(0); - const error = anchorsKey && failure?.attempt === retry ? failure.message : null; - const loading = row.expanded && anchorsKey !== "" && error === null; - const requestWork = useEffectEvent( - (anchorIds: string[], environmentId: EnvironmentId, threadId: ThreadId) => - Promise.all( - anchorIds.map((anchorId) => loadWork({ environmentId, input: { threadId, anchorId } })), - ), - ); - // oxlint-disable-next-line t3code/react-effect-budget -- Expanded folds restore their deferred server history. + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey || environmentId === undefined || threadId === undefined) + return Promise.resolve([]); + return Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ); + }; + // oxlint-disable-next-line t3code/react-effect-budget -- Restored expanded folds also need their server history. useEffect(() => { - if (!row.expanded || !anchorsKey || environmentId === undefined || threadId === undefined) - return; - let active = true; - void requestWork(anchorsKey.split("\n"), environmentId, threadId).then((results) => { - if (!active) return; - const failed = results.some( - (result) => result._tag === "Failure" || result.value._tag === "error", - ); - setFailure( - failed ? { attempt: retry, message: "Couldn't load work. Click to retry." } : null, + if (row.expanded) + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), ); - }); - return () => { - active = false; - }; - }, [row.expanded, anchorsKey, retry, environmentId, threadId]); + }, [row.expanded, load]); + const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; + const loading = row.expanded && anchorsKey !== "" && !failed; const toggle = () => { - if (error !== null && row.expanded) { - setFailure(null); - setRetry((value) => value + 1); + if (error && row.expanded) { + setFailed(false); + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); } else { - setFailure(null); + setFailed(false); ctx.onToggleTurnFold(row.runId); } }; diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 1799f3bd2482b..d61213c243ea8 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -127,7 +127,6 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio register: () => Effect.die("unused"), unregister: () => Effect.void, loadEarlier: () => Effect.die("unused"), - loadWork: () => Effect.die("unused"), reconcilePendingWork: (_environmentId, threadId) => options?.reconcilePendingWork?.(threadId) ?? Queue.offer(reconciled, threadId), }), diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index f3a85c0bc1ad7..b220d7f741bfb 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -100,6 +100,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts"; export type LoadEarlierThreadHistoryInput = { readonly threadId: ThreadId; readonly throughEntryId?: string; + readonly workAnchorId?: string; }; export type { @@ -164,19 +165,7 @@ export function createThreadEnvironmentAtoms( supervisor.target.environmentId, input.threadId, input.throughEntryId, - ); - }); - const loadWorkHistory = Effect.fn("ThreadCommands.loadWorkHistory")(function* (input: { - readonly threadId: ThreadId; - readonly anchorId: string; - }) { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const controller = yield* Effect.serviceOption(ThreadHistoryController.ThreadHistoryController); - if (Option.isNone(controller)) return { _tag: "noop" } as const; - return yield* controller.value.loadWork( - supervisor.target.environmentId, - input.threadId, - input.anchorId, + input.workAnchorId, ); }); @@ -441,16 +430,6 @@ export function createThreadEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), }, }), - loadWorkHistory: createEnvironmentCommand(runtime, { - label: "environment-data:commands:thread:load-work-history", - execute: loadWorkHistory, - scheduler, - concurrency: { - mode: "serial", - key: ({ environmentId, input }) => - JSON.stringify([environmentId, input.threadId, input.anchorId]), - }, - }), uploadFeedback: createEnvironmentRpcCommand(runtime, { label: "environment-data:commands:thread:upload-feedback", tag: WS_METHODS.providerUploadFeedback, diff --git a/packages/client-runtime/src/state/threadHistoryController.ts b/packages/client-runtime/src/state/threadHistoryController.ts index 09b594a0acda8..1c8818fd54db1 100644 --- a/packages/client-runtime/src/state/threadHistoryController.ts +++ b/packages/client-runtime/src/state/threadHistoryController.ts @@ -13,8 +13,10 @@ export type ThreadHistoryLoadEarlierResult = | { readonly _tag: "error"; readonly message: string }; export type ThreadHistoryHandler = { - readonly loadWork?: (anchorId: string) => Effect.Effect; - readonly loadEarlier: (throughEntryId?: string) => Effect.Effect; + readonly loadEarlier: ( + throughEntryId?: string, + workAnchorId?: string, + ) => Effect.Effect; readonly reconcilePendingWork: () => Effect.Effect; }; @@ -37,11 +39,7 @@ export class ThreadHistoryController extends Context.Service< environmentId: EnvironmentId, threadId: ThreadId, throughEntryId?: string, - ) => Effect.Effect; - readonly loadWork: ( - environmentId: EnvironmentId, - threadId: ThreadId, - anchorId: string, + workAnchorId?: string, ) => Effect.Effect; readonly reconcilePendingWork: ( environmentId: EnvironmentId, @@ -87,29 +85,18 @@ export const layer: Layer.Layer = Layer.effect( }); }), loadEarlier: Effect.fn("ThreadHistoryController.loadEarlier")( - function (environmentId, threadId, throughEntryId) { + function (environmentId, threadId, throughEntryId, workAnchorId) { return Ref.get(handlers).pipe( Effect.flatMap((current) => { const handler = current.get(threadKey({ environmentId, threadId })); if (handler === undefined) { return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult); } - return handler.loadEarlier(throughEntryId); + return handler.loadEarlier(throughEntryId, workAnchorId); }), ); }, ), - loadWork: Effect.fn("ThreadHistoryController.loadWork")( - function (environmentId, threadId, anchorId) { - return Ref.get(handlers).pipe( - Effect.flatMap( - (current) => - current.get(threadKey({ environmentId, threadId }))?.loadWork?.(anchorId) ?? - Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult), - ), - ); - }, - ), reconcilePendingWork: Effect.fn("ThreadHistoryController.reconcilePendingWork")( function (environmentId, threadId) { return Ref.get(handlers).pipe( diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index 9947d1f077d61..f0ca836248aff 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -29,7 +29,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( query: { cursor: input.cursor, compactIds: "1", - deferWork: "1", + ...(input.throughEntryId === undefined ? { deferWork: "1" } : {}), ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 91d05ff859670..59e1ea3652e52 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -1,15 +1,9 @@ -import type { - OrchestrationV2ProjectedTurnItem, - OrchestrationV2ThreadProjection, -} from "@t3tools/contracts"; +import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts"; -import { - mergeOlderHistoryIntoProjection, - mergeDeferredWorkIntoProjection, -} from "./threadHistoryMerge.ts"; +import { mergeOlderHistoryIntoProjection } from "./threadHistoryMerge.ts"; import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); @@ -45,93 +39,6 @@ function row(index: number): OrchestrationV2ProjectedTurnItem { } describe("threadHistoryMerge", () => { - it("fills deferred work around visible rows and keeps newer edits and inherited ownership", () => { - for (const inherited of [false, true]) { - const source = (index: number) => ({ - ...row(index), - visibility: inherited ? ("inherited" as const) : ("local" as const), - }); - const anchor = { - ...source(1), - item: { - ...source(1).item, - deferredWork: { cursor: "first", itemCount: 4, lastOrdinal: 5 }, - }, - }; - const visible = [source(0), anchor, source(3), source(5)]; - const projection = { - ...v2Projection, - visibleTurnItems: visible, - turnItems: inherited ? [] : visible.map((row) => row.item), - }; - const partial = mergeDeferredWorkIntoProjection( - projection, - anchor.sourceItemId, - [source(4)], - "next", - ); - expect(partial.visibleTurnItems.map((row) => row.sourceItemId)).toEqual([ - "item-0", - "item-1", - "item-3", - "item-4", - "item-5", - ]); - expect(partial.visibleTurnItems[1]?.item.deferredWork?.cursor).toBe("next"); - const stale = { ...source(3), item: { ...source(3).item, title: "stale" } }; - const complete = mergeDeferredWorkIntoProjection( - partial, - anchor.sourceItemId, - [source(1), source(2), stale], - null, - ); - expect(complete.visibleTurnItems.map((row) => row.sourceItemId)).toEqual( - Array.from({ length: 6 }, (_, i) => `item-${i}`), - ); - expect(complete.visibleTurnItems[3]?.item.title).toBe("Command 3"); - expect(complete.visibleTurnItems[1]?.item.deferredWork).toBeUndefined(); - expect(complete.turnItems).toHaveLength(inherited ? 0 : 6); - expect(mergeDeferredWorkIntoProjection(complete, anchor.sourceItemId, [], null)).toBe( - complete, - ); - } - }); - - it("keeps a retained user prompt ahead of work as older pages fill the same turn", () => { - const first = row(0); - if (first.item.type !== "command_execution") throw new Error("Expected command fixture"); - const prompt: OrchestrationV2ProjectedTurnItem = { - ...first, - item: { - ...first.item, - type: "user_message", - messageId: "prompt" as never, - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - text: "Investigate WebGL", - attachments: [], - }, - }; - const recent = [prompt, row(5), row(6)]; - let projection: OrchestrationV2ThreadProjection = { - ...v2Projection, - turnItems: recent.map((entry) => entry.item), - visibleTurnItems: recent, - }; - for (const page of [[prompt, row(3), row(4)], [prompt, row(1), row(2)], [prompt]]) { - projection = mergeOlderHistoryIntoProjection(projection, page); - expect(projection.visibleTurnItems[0]?.item).toBe(prompt.item); - expect(projection.visibleTurnItems.map((entry) => entry.item.ordinal)).toEqual( - projection.visibleTurnItems.map((entry) => entry.item.ordinal).toSorted((a, b) => a - b), - ); - } - expect(projection.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual( - Array.from({ length: 7 }, (_, index) => `item-${index}`), - ); - expect(projection.turnItems).toHaveLength(7); - }); - it("fills activity around partial messages in canonical order and preserves live edits", () => { const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] }; const preview = [row(1), row(3)]; @@ -157,6 +64,29 @@ describe("threadHistoryMerge", () => { expect(complete.visibleTurnItems[1]?.item).toBe(updated.item); expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4]); expect(new Set(complete.turnItems.map((item) => item.id)).size).toBe(5); + const anchorItem = { + ...updated.item, + deferredWork: { cursor: "work", itemCount: 4, lastOrdinal: 5 }, + }; + const deferred = { + ...live, + turnItems: live.turnItems.map((item) => (item.id === anchorItem.id ? anchorItem : item)), + visibleTurnItems: live.visibleTurnItems.map((entry) => + entry.item.id === anchorItem.id ? { ...entry, item: anchorItem } : entry, + ), + }; + const loadedWork = mergeOlderHistoryIntoProjection(deferred, [row(1), row(2), row(3)], { + anchorId: anchorItem.id, + nextCursor: null, + }); + expect(loadedWork.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-1", + "item-2", + "item-3", + "item-4", + ]); + expect(loadedWork.visibleTurnItems[0]?.item.title).toBe("newer live title"); + expect(loadedWork.visibleTurnItems[0]?.item.deferredWork).toBeUndefined(); const hidden = { ...partial, visibleTurnItems: partial.visibleTurnItems.filter( diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index fd3a2f2b45a1a..8d1150d45cf4d 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -52,11 +52,19 @@ function renumberVisible( export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, + work?: { readonly anchorId: string; readonly nextCursor: string | null }, ): OrchestrationV2ThreadProjection { - if (olderItems.length === 0) { + if (olderItems.length === 0 && work === undefined) { return projection; } + const start = + work === undefined + ? -1 + : projection.visibleTurnItems.findIndex((row) => row.sourceItemId === work.anchorId); + const anchor = projection.visibleTurnItems[start]; + const deferred = anchor?.item.deferredWork; + if (work !== undefined && deferred === undefined) return projection; const currentByKey = new Map( projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]), ); @@ -64,6 +72,15 @@ export function mergeOlderHistoryIntoProjection( const pageKeys = new Set(); const page: OrchestrationV2ProjectedTurnItem[] = []; for (const pageRow of olderItems) { + if ( + work && + anchor && + deferred && + (pageRow.sourceThreadId !== anchor.sourceThreadId || + pageRow.item.ordinal < anchor.item.ordinal || + pageRow.item.ordinal > deferred.lastOrdinal) + ) + continue; const key = projectedItemKey(pageRow); if (pageKeys.has(key)) continue; let row = currentByKey.get(key) ?? pageRow; @@ -94,11 +111,50 @@ export function mergeOlderHistoryIntoProjection( ) turnItemById.set(String(row.sourceItemId), row.item); } - if (page.length === 0) return projection; - const visible = [ + if (page.length === 0 && work === undefined) return projection; + let visible = [ ...page, ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), ]; + if (work && anchor && deferred) { + let end = start + 1; + while (end < projection.visibleTurnItems.length) { + const row = projection.visibleTurnItems[end]!; + if ( + row.sourceThreadId !== anchor.sourceThreadId || + row.visibility === "synthetic" || + row.item.ordinal > deferred.lastOrdinal + ) + break; + end += 1; + } + const { deferredWork: _deferred, ...complete } = anchor.item; + const updated = { + ...anchor, + item: + work.nextCursor === null + ? complete + : { + ...anchor.item, + deferredWork: { ...deferred, cursor: work.nextCursor }, + }, + }; + const section = [ + ...page, + ...projection.visibleTurnItems + .slice(start, end) + .filter((row) => !pageKeys.has(projectedItemKey(row))), + ]; + const ordered = section + .map((row) => (row.sourceItemId === anchor.sourceItemId ? updated : row)) + .sort((left, right) => left.item.ordinal - right.item.ordinal); + visible = [ + ...projection.visibleTurnItems.slice(0, start), + ...ordered, + ...projection.visibleTurnItems.slice(end), + ]; + if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); + } if ( visible.length === projection.visibleTurnItems.length && visible.every((row, index) => row === projection.visibleTurnItems[index]) @@ -128,70 +184,6 @@ export function applyHistoryPageMeta( }; } -export function mergeDeferredWorkIntoProjection( - projection: OrchestrationV2ThreadProjection, - anchorId: string, - items: ReadonlyArray, - nextCursor: string | null, -): OrchestrationV2ThreadProjection { - const start = projection.visibleTurnItems.findIndex((row) => row.sourceItemId === anchorId); - const anchor = projection.visibleTurnItems[start]; - const deferred = anchor?.item.deferredWork; - if (anchor === undefined || deferred === undefined) return projection; - let end = start + 1; - while (end < projection.visibleTurnItems.length) { - const row = projection.visibleTurnItems[end]!; - if ( - row.sourceThreadId !== anchor.sourceThreadId || - row.visibility === "synthetic" || - row.item.ordinal > deferred.lastOrdinal - ) - break; - end += 1; - } - const current = new Map( - projection.visibleTurnItems.slice(start, end).map((row) => [projectedItemKey(row), row]), - ); - const local = new Map(projection.turnItems.map((item) => [String(item.id), item])); - for (const row of items) { - if ( - row.sourceThreadId !== anchor.sourceThreadId || - row.item.ordinal < anchor.item.ordinal || - row.item.ordinal > deferred.lastOrdinal || - current.has(projectedItemKey(row)) - ) - continue; - if (row.visibility === "local" && local.has(String(row.item.id))) continue; - current.set(projectedItemKey(row), row); - if (row.visibility === "local") local.set(String(row.item.id), row.item); - } - const { deferredWork: _deferred, ...complete } = anchor.item; - const updated = { - ...anchor, - item: - nextCursor === null - ? complete - : { - ...anchor.item, - deferredWork: { ...deferred, cursor: nextCursor }, - }, - }; - current.set(projectedItemKey(anchor), updated); - if (anchor.visibility === "local") local.set(String(anchor.item.id), updated.item); - const section = [...current.values()].sort( - (left, right) => left.item.ordinal - right.item.ordinal, - ); - return { - ...projection, - turnItems: [...local.values()], - visibleTurnItems: renumberVisible([ - ...projection.visibleTurnItems.slice(0, start), - ...section, - ...projection.visibleTurnItems.slice(end), - ]), - }; -} - export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean { return history.hasMoreHistory || history.error !== null; } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 526d739e2f1cc..778927396bb14 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -272,7 +272,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o return { threadState, loadWork: (anchorId: string) => - historyController.loadWork(TARGET.environmentId, THREAD_ID, anchorId), + historyController.loadEarlier(TARGET.environmentId, THREAD_ID, undefined, anchorId), loadEarlier: (throughEntryId?: string) => historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId), reconcilePendingWork: historyController.reconcilePendingWork(TARGET.environmentId, THREAD_ID), @@ -364,112 +364,6 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { - it.effect.each(["expand", "retry", "target"] as const)( - "loads deferred work only on demand and resumes after an error: %s", - (mode) => - Effect.gen(function* () { - const command = ( - ordinal: number, - ): Extract => ({ - id: TurnItemId.make(`work-${ordinal}`), - threadId: THREAD_ID, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: v2Now, - completedAt: v2Now, - updatedAt: v2Now, - type: "command_execution", - input: "pwd", - }); - const anchor = { - ...command(1), - deferredWork: { cursor: "first", itemCount: 3, lastOrdinal: 4 }, - }; - const notice: OrchestrationV2TurnItem = { - ...command(2), - type: "system_notice", - message: "Visible notice", - }; - const row = (item: OrchestrationV2TurnItem) => ({ - position: item.ordinal, - visibility: "local" as const, - sourceThreadId: THREAD_ID, - sourceItemId: item.id, - item, - }); - const requests: string[] = []; - let failed = false; - const harness = yield* makeHarness({ - historyHttpClient: HttpClient.make((request, url) => { - expect(url.searchParams.get("view")).toBe("work"); - expect(url.searchParams.get("throughEntryId")).toBe(anchor.id); - const cursor = url.searchParams.get("cursor")!; - requests.push(cursor); - if (cursor === "second" && mode === "retry" && !failed) { - failed = true; - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })), - ); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - snapshotSequence: 14, - items: [row(command(cursor === "first" ? 4 : 3))], - nextCursor: cursor === "first" ? "second" : null, - hasMoreHistory: cursor === "first", - }), - ), - ); - }), - }); - yield* Queue.offer(harness.inputs, { - ...snapshot( - { - ...BASE_PROJECTION, - turnItems: [anchor, notice], - visibleTurnItems: [row(anchor), row(notice)], - }, - 14, - ), - historyCursor: "older", - hasMoreHistory: true, - latestLocalTurnOrdinal: 4, - }); - yield* awaitThreadState( - harness.observed, - (state) => state.history.historyCursor === "older", - ); - expect(requests).toEqual([]); - const result = yield* mode === "target" - ? harness.loadEarlier("work-3") - : harness.loadWork(anchor.id); - if (mode === "retry") { - expect(result._tag).toBe("error"); - const partial = Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).data); - expect(partial.visibleTurnItems[0]?.item.deferredWork?.cursor).toBe("second"); - expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); - } else expect(result).toEqual({ _tag: "loaded" }); - const state = yield* SubscriptionRef.get(harness.threadState); - const projection = Option.getOrThrow(state.data); - expect(projection.visibleTurnItems.map((row) => row.item.ordinal)).toEqual([1, 2, 3, 4]); - expect(projection.visibleTurnItems[0]?.item.deferredWork).toBeUndefined(); - expect(state.history.historyCursor).toBe("older"); - expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); - expect(requests).toEqual( - mode === "retry" ? ["first", "second", "second"] : ["first", "second"], - ); - }), - ); - it.effect("discards an oversized version-4 cache and requests a fresh bounded snapshot", () => Effect.gen(function* () { const item: OrchestrationV2TurnItem = { @@ -801,7 +695,7 @@ describe("EnvironmentThreads", () => { }), ); - describe("Stop on cached work the server already finished (#18188)", () => { + describe("cached work (#18188)", () => { const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); const parentThreadId = ThreadId.make("thread:parent"); const command = ( @@ -809,7 +703,7 @@ describe("EnvironmentThreads", () => { status: "running" | "completed", exitCode?: number, ordinal = id === "older-command" ? 1 : 2, - ): OrchestrationV2TurnItem => ({ + ): Extract => ({ id: TurnItemId.make(id), threadId: THREAD_ID, runId: null, @@ -836,6 +730,111 @@ describe("EnvironmentThreads", () => { sourceItemId: item.id, item, }); + it.effect("loads deferred work on demand and resumes a failed page", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed", 0, 1), + deferredWork: { cursor: "first", itemCount: 3, lastOrdinal: 3 }, + }; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: { + ...BASE_PROJECTION, + turnItems: [anchor], + visibleTurnItems: [localRow(anchor, 0)], + }, + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "second" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 7, + items: [ + localRow( + command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), + 0, + ), + ], + nextCursor: cursor === "first" ? "second" : null, + hasMoreHistory: cursor === "first", + }), + ), + ); + }), + }); + expect(requests).toEqual([]); + expect((yield* harness.loadWork(anchor.id))._tag).toBe("error"); + const partial = Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).data); + expect(partial.visibleTurnItems[0]?.item.deferredWork?.cursor).toBe("second"); + expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.item.ordinal), + ).toEqual([1, 2, 3]); + expect(state.history.historyCursor).toBe("older"); + expect(requests).toEqual(["first", "second", "second"]); + }), + ); + + it.effect("loads targeted messages without fetching unrelated deferred work", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "work", itemCount: 80, lastOrdinal: 80 }, + }; + const message = { + ...command("older-message", "completed"), + type: "assistant_message" as const, + messageId: MessageId.make("older-message"), + text: "Older response", + streaming: false, + }; + const views: Array = []; + const harness = yield* makeHarness({ + cached: { + ...BASE_PROJECTION, + turnItems: [anchor], + visibleTurnItems: [localRow(anchor, 0)], + }, + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + views.push(url.searchParams.get("view")); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + url.searchParams.get("view") === "work" + ? new Response("Unavailable", { status: 503 }) + : Response.json({ + snapshotSequence: 7, + items: [localRow(message, 0)], + nextCursor: null, + hasMoreHistory: false, + }), + ), + ); + }), + }); + expect(yield* harness.loadEarlier(message.messageId)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.some( + (row) => row.sourceItemId === message.id, + ), + ).toBe(true); + expect(views).toEqual(["conversation", null]); + }), + ); + // A fork's parent row keeps its own source; the child's Stop must not touch it. const inheritedRow = { position: 0, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index e5ff90b9d69b0..97afa271dc70a 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -45,7 +45,6 @@ import { EMPTY_THREAD_HISTORY_META, isActiveHistoryRequestCursor, mergeOlderHistoryIntoProjection, - mergeDeferredWorkIntoProjection, type ThreadHistoryMeta, } from "./threadHistoryMerge.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; @@ -657,127 +656,36 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); - const loadingWork = yield* Ref.make(new Set()); - const loadWork = Effect.fn("EnvironmentThreadState.loadWork")(function* (anchorId: string) { - const acquired = yield* Ref.modify(loadingWork, (current) => - current.has(anchorId) ? [false, current] : [true, new Set([...current, anchorId])], - ); - if (!acquired) return { _tag: "busy" } as const; - return yield* Effect.gen(function* () { - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared) || Option.isNone(httpClient)) - return { _tag: "error", message: "Environment is not connected." } as const; - while (true) { - const current = yield* SubscriptionRef.get(state); - const deferred = Option.getOrNull(current.data)?.visibleTurnItems.find( - (row) => row.sourceItemId === anchorId, - )?.item.deferredWork; - if (deferred === undefined) return { _tag: "loaded" } as const; - const result = yield* fetchEnvironmentThreadHistoryPage({ - prepared: prepared.value, - threadId, - cursor: deferred.cursor, - throughEntryId: anchorId, - view: "work", - signer: dpopSigner, - remoteAuthorization, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); - if (Result.isFailure(result)) - return { _tag: "error", message: formatHistoryError(result.failure) } as const; - const page = result.success; - const applied = yield* applyLock.withPermits(1)( - SubscriptionRef.modify(state, (latest) => { - const projection = Option.getOrNull(latest.data); - if ( - projection?.visibleTurnItems.find((row) => row.sourceItemId === anchorId)?.item - .deferredWork?.cursor !== deferred.cursor - ) - return [false, latest] as const; - return [ - true, - { - ...latest, - data: Option.some( - mergeDeferredWorkIntoProjection( - projection, - anchorId, - page.items, - page.nextCursor, - ), - ), - history: { ...latest.history, expanded: true }, - }, - ] as const; - }), - ); - if (!applied) return { _tag: "noop" } as const; - if (page.nextCursor === null) { - yield* remember; - return { _tag: "loaded" } as const; - } - if (page.nextCursor === deferred.cursor) - return { _tag: "error", message: "Work history did not advance." } as const; - } - }).pipe( - Effect.ensuring( - Ref.update(loadingWork, (current) => { - const next = new Set(current); - next.delete(anchorId); - return next; - }), - ), - ); - }); - const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* ( throughEntryId?: string, - ) { - const revealTarget = Effect.fn("EnvironmentThreadState.revealHistoryTarget")(function* () { - if (throughEntryId === undefined) return; - const visible = - Option.getOrNull((yield* SubscriptionRef.get(state)).data)?.visibleTurnItems ?? []; - const hasTarget = () => - SubscriptionRef.get(state).pipe( - Effect.map( - (current) => - Option.getOrNull(current.data)?.visibleTurnItems.some( - ({ item }) => - item.id === throughEntryId || - ("messageId" in item && item.messageId === throughEntryId), - ) === true, - ), - ); - if (yield* hasTarget()) return; - for (const row of visible) { - if (row.item.deferredWork === undefined) continue; - const result = yield* loadWork(row.sourceItemId); - if (result._tag === "error") return result; - if (yield* hasTarget()) return { _tag: "loaded" } as const; - } - }); - const revealed = yield* revealTarget(); - if (revealed !== undefined) return revealed; + workAnchorId?: string, + ): Effect.fn.Return { const current = yield* SubscriptionRef.get(state); + const work = Option.getOrNull(current.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === workAnchorId, + )?.item.deferredWork; + const requestCursor = + work?.cursor ?? (workAnchorId === undefined ? current.history.historyCursor : null); if ( current.status === "deleted" || Option.isNone(current.data) || - !current.history.hasMoreHistory || - current.history.historyCursor === null + requestCursor === null || + (workAnchorId === undefined && !current.history.hasMoreHistory) ) { return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; } - if (current.history.loading) { + if (workAnchorId === undefined && current.history.loading) { return { _tag: "busy" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; } - // Capture the cursor that initiated this request. Completions/failures must - // no-op if a socket or new bounded snapshot replaced progressive meta mid-flight. - const requestCursor = current.history.historyCursor; - yield* patchHistoryMeta((history) => - isActiveHistoryRequestCursor(requestCursor, history) - ? { ...history, loading: true, error: null } - : history, - ); + const isCurrent = (latest: EnvironmentThreadState) => + workAnchorId === undefined + ? isActiveHistoryRequestCursor(requestCursor, latest.history) + : Option.getOrNull(latest.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === workAnchorId, + )?.item.deferredWork?.cursor === requestCursor; + if (workAnchorId === undefined) + yield* patchHistoryMeta((history) => ({ ...history, loading: true, error: null })); const failLoad = (message: string) => SubscriptionRef.modify( @@ -788,12 +696,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ThreadHistoryController.ThreadHistoryLoadEarlierResult, EnvironmentThreadState, ] => - isActiveHistoryRequestCursor(requestCursor, latest.history) + isCurrent(latest) ? [ { _tag: "error", message }, { ...latest, - history: { ...latest.history, loading: false, error: message }, + history: + workAnchorId === undefined + ? { ...latest.history, loading: false, error: message } + : latest.history, }, ] : [{ _tag: "noop" }, latest], @@ -805,13 +716,17 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return yield* failLoad("Environment is not connected."); const views = - throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined]; + workAnchorId !== undefined + ? ["work" as const] + : throughEntryId === undefined + ? [undefined] + : ["conversation" as const, undefined]; for (const view of views) { const pageResult = yield* fetchEnvironmentThreadHistoryPage({ prepared: preparedOption.value, threadId, cursor: requestCursor, - throughEntryId, + throughEntryId: workAnchorId ?? throughEntryId, view, signer: dpopSigner, remoteAuthorization, @@ -819,7 +734,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (Result.isFailure(pageResult)) return yield* failLoad(formatHistoryError(pageResult.failure)); const page = pageResult.success; - const complete = view === undefined; + const complete = view !== "conversation"; const waiting = yield* Ref.get(awaitingCompletion); const result = yield* applyLock.withPermits(1)( SubscriptionRef.modify( @@ -830,19 +745,30 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ThreadHistoryController.ThreadHistoryLoadEarlierResult, EnvironmentThreadState, ] => { - if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) - return [{ _tag: "noop" }, latest]; + if (!isCurrent(latest)) return [{ _tag: "noop" }, latest]; if (Option.isNone(latest.data) || latest.status === "deleted") return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; return [ { _tag: "loaded" }, { ...latest, - data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)), - status: complete && waiting ? "synchronizing" : latest.status, - history: complete - ? applyHistoryPageMeta(latest.history, page) - : { ...latest.history, expanded: true }, + data: Option.some( + mergeOlderHistoryIntoProjection( + latest.data.value, + page.items, + workAnchorId === undefined + ? undefined + : { anchorId: workAnchorId, nextCursor: page.nextCursor }, + ), + ), + status: + workAnchorId === undefined && complete && waiting + ? "synchronizing" + : latest.status, + history: + workAnchorId === undefined && complete + ? applyHistoryPageMeta(latest.history, page) + : { ...latest.history, expanded: true }, }, ]; }, @@ -851,12 +777,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (result._tag !== "loaded") return result; if (complete) yield* remember; } - const revealed = yield* revealTarget(); - if (revealed !== undefined) return revealed; return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; }); - return yield* runLoad.pipe( + const result = yield* runLoad.pipe( Effect.onInterrupt(() => SubscriptionRef.update(state, (latest) => ({ ...latest, @@ -864,6 +788,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make })), ), ); + if (workAnchorId !== undefined && result._tag === "loaded") { + const remaining = Option.getOrNull( + (yield* SubscriptionRef.get(state)).data, + )?.visibleTurnItems.find((row) => row.sourceItemId === workAnchorId)?.item.deferredWork; + if (remaining?.cursor === requestCursor) + return { _tag: "error", message: "Work history did not advance." }; + if (remaining !== undefined) return yield* loadEarlier(undefined, workAnchorId); + } + return result; }); // A cached item can stay running after the server finished it, and no event will ever @@ -932,10 +865,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (Option.isSome(historyController)) { const scope = yield* Scope.Scope; const registration = yield* historyController.value.register(environmentId, threadId, { - loadWork: (anchorId) => - loadWork(anchorId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), - loadEarlier: (throughEntryId) => - loadEarlier(throughEntryId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), + loadEarlier: (throughEntryId, workAnchorId) => + loadEarlier(throughEntryId, workAnchorId).pipe( + Effect.forkIn(scope), + Effect.flatMap(Fiber.join), + ), reconcilePendingWork: () => reconcilePendingWork().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), diff --git a/packages/shared/package.json b/packages/shared/package.json index 1eee3e6f9abf0..0bfb6cdf081e1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,10 +3,6 @@ "private": true, "type": "module", "exports": { - "./orchestrationV2WorkHistory": { - "types": "./src/orchestrationV2WorkHistory.ts", - "import": "./src/orchestrationV2WorkHistory.ts" - }, "./changedFileKind": { "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" diff --git a/packages/shared/src/orchestrationV2WorkHistory.ts b/packages/shared/src/orchestrationV2WorkHistory.ts deleted file mode 100644 index 6470da5defa02..0000000000000 --- a/packages/shared/src/orchestrationV2WorkHistory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; -import { htmlRenderFromToolItem, mcpAppFromToolItem } from "./toolOutput.ts"; - -export const DEFERRED_WORK_ITEM_LIMIT = 75; - -const DEFERRED_WORK_ITEM_TYPES = [ - "command_execution", - "reasoning", - "file_change", - "file_search", - "web_search", - "dynamic_tool", -] as const; - -export function isDeferrableWorkItem( - item: Pick & { - readonly persistentOutput?: boolean; - readonly toolName?: string | null; - readonly output?: unknown; - readonly viewedImagePath?: string; - }, -): boolean { - if (item.status !== "completed" || item.persistentOutput === true) return false; - if (item.type === "dynamic_tool") { - const tool = { toolName: item.toolName, output: item.output }; - if ( - item.viewedImagePath !== undefined || - htmlRenderFromToolItem(tool) !== undefined || - mcpAppFromToolItem(tool) !== undefined - ) - return false; - } - return DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type); -} From 698fed5efa83a52f0bd2fa11c44081800dc33ddb Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 21:21:06 -0500 Subject: [PATCH 03/19] fix(ui): explain deferred history effects under current lint rule --- apps/mobile/src/features/threads/ThreadFeed.tsx | 2 +- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b01f61bed86f9..4e9c0d6917be6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1260,7 +1260,7 @@ function RunFoldButton({ .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), ); }; - // oxlint-disable-next-line t3code/react-effect-budget -- Restored expanded folds also need their server history. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { if (entry.expanded) void load().then((results) => diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5ff221a6e005b..48feb955d0a32 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1579,7 +1579,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract loadWork({ environmentId, input: { threadId, workAnchorId } })), ); }; - // oxlint-disable-next-line t3code/react-effect-budget -- Restored expanded folds also need their server history. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { if (row.expanded) void load().then((results) => From 20f8aced24e8c03915c6fe797ff3f5021ac6ae8c Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 21:30:08 -0500 Subject: [PATCH 04/19] fix(server): keep interrupt requests before paged results --- .../orchestration-v2/ProjectionStore.test.ts | 55 +++++++++++++------ .../src/orchestration-v2/ProjectionStore.ts | 4 +- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 0e287fbc478e4..865bcc7c8cb1c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -187,13 +187,13 @@ const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate return threadId; }); -it.effect("memory windows keep deferred candidates after compaction", () => +it.effect("memory windows keep compacted work and interrupt history in order", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const threadId = yield* addRolledBackRecoveryCandidate("deferred-window"); const projection = yield* store.getThreadProjection(threadId); const run = projection.runs[0]!; - const item = projection.turnItems[0]!; + const item = projection.turnItems.find((item) => item.type === "command_execution")!; yield* store.apply({ id: EventId.make("event:deferred-window:completed"), type: "run.updated", @@ -201,27 +201,50 @@ it.effect("memory windows keep deferred candidates after compaction", () => occurredAt: projection.updatedAt, payload: { ...run, status: "completed", completedAt: projection.updatedAt }, }); - for (let ordinal = 1; ordinal <= 80; ordinal += 1) { + for (let ordinal = 1; ordinal <= 163; ordinal += 1) { + const interrupt = + ordinal === 81 ? "run_interrupt_request" : ordinal === 162 ? "run_interrupt_result" : null; + const base = { + ...item, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + id: ordinal === 1 ? item.id : TurnItemId.make(`item:deferred-window:${ordinal}`), + ordinal, + status: "completed" as const, + }; yield* store.apply({ id: EventId.make(`event:deferred-window:${ordinal}`), type: "turn-item.updated", threadId, occurredAt: projection.updatedAt, - payload: { - ...item, - id: ordinal === 1 ? item.id : TurnItemId.make(`item:deferred-window:${ordinal}`), - ordinal, - status: "completed", - }, + payload: interrupt === null ? base : { ...base, type: interrupt, message: interrupt }, }); + if (ordinal === 80) { + const snapshot = yield* store.getThreadSnapshotWindow(threadId, { + rowLimit: 77, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(snapshot.projection.visibleTurnItems, 1); + assert.strictEqual( + snapshot.projection.visibleTurnItems[0]?.item.deferredWork?.itemCount, + 80, + ); + } } - const snapshot = yield* store.getThreadSnapshotWindow(threadId, { - rowLimit: 77, - userTurnLimit: 10, - deferWork: true, - }); - assert.lengthOf(snapshot.projection.visibleTurnItems, 1); - assert.strictEqual(snapshot.projection.visibleTurnItems[0]?.item.deferredWork?.itemCount, 80); + const page = yield* store.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("item:deferred-window:163"), + position: 162, + }), + ); + assert.strictEqual(page.items[0]?.item.type, "run_interrupt_request"); + assert.strictEqual(page.items.at(-1)?.item.type, "run_interrupt_result"); }).pipe(Effect.provide(ProjectionStore.layerMemory)), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a797b3807df6f..fdd9461348afd 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -7908,9 +7908,7 @@ export const layerMemory: Layer.Layer = Layer.effect( const items = [ ...(workMode === "work" ? [] : missingInterruptRequests(visible, page.items)), ...page.items, - ] - .sort((left, right) => left.position - right.position) - .map((row, position) => ({ ...row, position })); + ].map((row, position) => ({ ...row, position })); return { ...page, snapshotSequence, From 30ba916d91702dfa2d4728cef91bc2db86c7a170 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 21:40:03 -0500 Subject: [PATCH 05/19] perf(server): scan work output only for deferred history --- .../src/orchestration-v2/ProjectionStore.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6995def43e845..36488aaccbb1c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5310,6 +5310,7 @@ export const layer: Layer.Layer = const readTimelineIndex: ( threadId: ThreadId, seen: ReadonlySet, + includePersistentOutput?: boolean, ) => Effect.Effect< { readonly records: ProjectionRecords<"runs" | "attempts">; @@ -5318,7 +5319,7 @@ export const layer: Layer.Layer = }, ProjectionStoreV2Error > = Effect.fn("ProjectionStore.readTimelineIndex")( - function* (threadId: ThreadId, seen: ReadonlySet) { + function* (threadId: ThreadId, seen: ReadonlySet, includePersistentOutput = false) { const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); const rows = yield* sql<{ turn_item_id: string; @@ -5333,9 +5334,13 @@ export const layer: Layer.Layer = ordinal: number; persistent_output: number; }>`SELECT turn_item_id, run_id, node_id, parent_item_id, type, status, ordinal, - CASE WHEN type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' - OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} - OR payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END AS persistent_output, + ${ + includePersistentOutput + ? sql`CASE WHEN type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' + OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END` + : sql`0` + } AS persistent_output, CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id @@ -5371,7 +5376,11 @@ export const layer: Layer.Layer = const visible = local.filter((row) => isVisible(row.item)); const fork = records.thread.forkedFrom; if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; - const source = yield* readTimelineIndex(fork.threadId, new Set([...seen, threadId])); + const source = yield* readTimelineIndex( + fork.threadId, + new Set([...seen, threadId]), + includePersistentOutput, + ); const sourceRun = source.records.runs.find((run) => run.id === fork.runId); let inherited: Array = []; if (sourceRun !== undefined) { @@ -5442,7 +5451,7 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - const index = yield* readTimelineIndex(threadId, new Set()); + const index = yield* readTimelineIndex(threadId, new Set(), workMode !== undefined); if (index.records.thread.deletedAt !== null) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const revisions = yield* sql<{ From 9a0ab900e648e8d90bd6994525ef86fd7f2c5055 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 21:46:36 -0500 Subject: [PATCH 06/19] perf(server): compute blocked history runs once per snapshot --- .../src/orchestration-v2/ProjectionStore.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 36488aaccbb1c..fca65791c24ff 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2829,7 +2829,13 @@ export const layer: Layer.Layer = `; const boundary = anchors[0]!; return sql` - ${eligible}, work_segments AS ( + ${eligible}, blocked_runs AS MATERIALIZED ( + SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND run_id IS NOT NULL + AND ${window.deferWork ? 1 : 0} = 1 + AND (type IN ('run_interrupt_request', 'run_interrupt_result') + OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) + ), work_segments AS ( SELECT item_rowid, ordinal, turn_item_id, run_id, type, MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, @@ -2838,12 +2844,8 @@ export const layer: Layer.Layer = AND (type <> 'dynamic_tool' OR ( payload_json NOT LIKE '%htmlRender%' AND payload_json NOT LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} AND payload_json NOT LIKE '%"viewedImagePath"%')) - AND NOT EXISTS ( - SELECT 1 FROM orchestration_v2_projection_turn_items blocked - WHERE blocked.run_id = eligible.run_id - AND (blocked.type IN ('run_interrupt_request', 'run_interrupt_result') - OR (blocked.type = 'error' AND blocked.status = 'failed' AND blocked.parent_item_id IS NULL)) - ) THEN 1 ELSE 0 END AS can_defer + AND run_id NOT IN (SELECT run_id FROM blocked_runs) + THEN 1 ELSE 0 END AS can_defer FROM eligible WHERE ${window.deferWork ? 1 : 0} = 1 AND ${boundary.latest === null ? 0 : 1} = 1 From e61e8b9e072ed95476d8289d64f9516268613963 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 23:29:39 -0500 Subject: [PATCH 07/19] fix(chat): avoid repeating failed deferred work loads on render --- apps/mobile/src/features/threads/ThreadFeed.tsx | 16 +++++++++------- .../src/components/chat/MessagesTimeline.tsx | 17 ++++++++++------- .../src/state/threads-sync.test.ts | 1 + 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2b031370f882b..58424bd4d8d55 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1233,15 +1233,17 @@ function RunFoldButton({ .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), ); }; + const loadOnExpand = useEffectEvent(() => { + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { - if (entry.expanded) - void load().then((results) => - setFailed( - results.some((result) => result._tag === "Failure" || result.value._tag === "error"), - ), - ); - }, [entry.expanded, load]); + if (entry.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [entry.expanded, anchorsKey, environmentId, threadId]); const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null; const loading = entry.expanded && anchorsKey !== "" && !failed; const toggle = () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 158c3865cdee5..4bc0272726fe7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -34,6 +34,7 @@ import { useContext, useLayoutEffect, useEffect, + useEffectEvent, useRef, useState, type ReactNode, @@ -1570,15 +1571,17 @@ function TurnFoldTimelineRow({ row }: { row: Extract loadWork({ environmentId, input: { threadId, workAnchorId } })), ); }; + const loadOnExpand = useEffectEvent(() => { + void load().then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { - if (row.expanded) - void load().then((results) => - setFailed( - results.some((result) => result._tag === "Failure" || result.value._tag === "error"), - ), - ); - }, [row.expanded, load]); + if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [row.expanded, anchorsKey, environmentId, threadId]); const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; const loading = row.expanded && anchorsKey !== "" && !failed; const toggle = () => { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 50b27d8561a63..02b77f9d7d64f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -741,6 +741,7 @@ describe("EnvironmentThreads", () => { }, cachedHistory: { historyCursor: "older", hasMoreHistory: true }, historyHttpClient: HttpClient.make((request, url) => { + expect(url.searchParams.get("view")).toBe("work"); const cursor = url.searchParams.get("cursor")!; requests.push(cursor); if (cursor === "second" && !failed) { From ecf9842400d1bad044a4b0d8d1e32cff64f7a963 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 23:44:29 -0500 Subject: [PATCH 08/19] fix(history): make replaced work requests retryable --- .../src/state/threads-sync.test.ts | 59 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 4 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 02b77f9d7d64f..c9c722f668b6b 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -782,6 +782,65 @@ describe("EnvironmentThreads", () => { }), ); + it.effect.each(["deferred", "complete"] as const)( + "settles a replaced work request when the new snapshot is %s", + (mode) => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "before", itemCount: 80, lastOrdinal: 80 }, + }; + const asked = yield* Deferred.make(); + const release = yield* Deferred.make(); + const harness = yield* makeHarness({ + cached: { + ...BASE_PROJECTION, + turnItems: [anchor], + visibleTurnItems: [localRow(anchor, 0)], + }, + historyHttpClient: HttpClient.make((request) => + Effect.gen(function* () { + yield* Deferred.succeed(asked, undefined); + yield* Deferred.await(release); + return HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 7, + items: [], + nextCursor: null, + hasMoreHistory: false, + }), + ); + }), + ), + }); + const loading = yield* harness.loadWork(anchor.id).pipe(Effect.forkChild); + yield* Deferred.await(asked); + const replacement = + mode === "deferred" + ? { ...anchor, deferredWork: { ...anchor.deferredWork, cursor: "after" } } + : command("anchor", "completed"); + yield* Queue.offer( + harness.inputs, + snapshot( + { + ...BASE_PROJECTION, + thread: { ...BASE_PROJECTION.thread, title: "Replacement" }, + turnItems: [replacement], + visibleTurnItems: [localRow(replacement, 0)], + }, + CACHED_SNAPSHOT_SEQUENCE + 1, + ), + ); + yield* awaitThreadState( + harness.observed, + (value) => Option.getOrNull(value.data)?.thread.title === "Replacement", + ); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(loading))._tag).toBe(mode === "deferred" ? "error" : "noop"); + }), + ); + it.effect("loads targeted messages without fetching unrelated deferred work", () => Effect.gen(function* () { const anchor = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index f1789f47d396e..de927527a7832 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -788,10 +788,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make })), ), ); - if (workAnchorId !== undefined && result._tag === "loaded") { + if (workAnchorId !== undefined && (result._tag === "loaded" || result._tag === "noop")) { const remaining = Option.getOrNull( (yield* SubscriptionRef.get(state)).data, )?.visibleTurnItems.find((row) => row.sourceItemId === workAnchorId)?.item.deferredWork; + if (result._tag === "noop" && remaining !== undefined) + return { _tag: "error", message: "Work history changed. Retry loading it." }; if (remaining?.cursor === requestCursor) return { _tag: "error", message: "Work history did not advance." }; if (remaining !== undefined) return yield* loadEarlier(undefined, workAnchorId); From ba78593bc15cb7046a39c5916568c2c645afe280 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 27 Oct 2026 23:56:59 -0500 Subject: [PATCH 09/19] fix(web): refresh fold rows when deferred work changes --- .../chat/MessagesTimeline.logic.test.ts | 29 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 10 ++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index cc4b386f17e89..f1f7c43160d05 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2969,6 +2969,35 @@ describe("v2 run and attempt history", () => { }); }); +it.each([ + [undefined, ["first"]], + [["first"], ["second"]], + [["first"], []], + [ + ["first", "second"], + ["second", "first"], + ], + [undefined, []], +] as const)("keeps deferred fold anchors current from %j to %j", (before, after) => { + const row: Extract = { + kind: "turn-fold", + id: "fold", + runId: RunId.make("run"), + createdAt: "2026-01-01T00:00:00Z", + label: "Worked for 1m", + expanded: true, + ...(before === undefined ? {} : { deferredWorkItemIds: before }), + }; + const previous = computeStableMessagesTimelineRows([row], { result: [], byId: new Map() }); + const nextRow = { ...row, deferredWorkItemIds: after }; + const next = computeStableMessagesTimelineRows([nextRow], previous); + const fold = next.result[0]; + expect(fold?.kind === "turn-fold" ? (fold.deferredWorkItemIds ?? []) : null).toEqual(after); + expect( + computeStableMessagesTimelineRows([{ ...nextRow, deferredWorkItemIds: [...after] }], next), + ).toBe(next); +}); + describe("streaming v2 row projection", () => { function fixture(text = "") { const source = makeStreamingTimelineFixture(text); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7931021f75b86..99788c0c1bca4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2312,7 +2312,15 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + const anchors = a.deferredWorkItemIds ?? []; + const nextAnchors = bf.deferredWorkItemIds ?? []; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + anchors.length === nextAnchors.length && + anchors.every((id, index) => id === nextAnchors[index]) + ); } case "attempt-fold": { From 65ea7bcf8b6a4a8375e1a9389554415a1f39f2f1 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 00:50:42 -0500 Subject: [PATCH 10/19] fix(history): show workspace preparation with deferred work - Keep deferred workspace preparation rows visible in thread history - Show loading skeletons while deferred work is fetched --- .../src/features/threads/ThreadFeed.tsx | 60 ++++++++++++------- apps/mobile/src/lib/threadActivity.ts | 2 +- .../src/components/chat/MessagesTimeline.tsx | 58 +++++++++++------- apps/web/src/session-logic.test.ts | 14 +++++ apps/web/src/session-logic.ts | 2 +- 5 files changed, 92 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 58424bd4d8d55..3f13ec347f08d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1261,29 +1261,45 @@ function RunFoldButton({ }; return ( - - + - {entry.label} - {loading ? " · Loading work..." : error ? ` · ${error}` : ""} - - - + + {entry.label} + {error ? ` · ${error}` : ""} + + + + {loading ? ( + + {(["40%", "60%", "33%"] as const).map((width) => ( + + + + + ))} + + ) : null} + ); } diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e5c06be1709ec..78626faee997c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2203,7 +2203,7 @@ export function buildThreadFeed( for (let index = restart?.row ?? 0; index < visibleTurnItems.length; index += 1) { const row = visibleTurnItems[index]!; const item = row.item; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; if ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4bc0272726fe7..5eeeb1c0af04e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -51,6 +51,7 @@ import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; +import { Skeleton } from "../ui/skeleton"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { CHAT_TIMELINE_ANCHOR_OFFSET, @@ -1599,26 +1600,43 @@ function TurnFoldTimelineRow({ row }: { row: Extract - - +
+
+ + +
+ {loading ? ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ) : null}
); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index fd10c96aa1b24..c4024df79438b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -557,6 +557,20 @@ describe("V2 session presentation", () => { if (threadCreatedEntry?.kind === "work") { expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created"); } + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + ...visibleTurnItems.at(-1)!, + item: { + ...workspacePreparationItem, + deferredWork: { cursor: "work-page", itemCount: 100, lastOrdinal: 107 }, + }, + }, + ], + optimisticMessages: [], + }).map((entry) => entry.id), + ).toEqual([workspacePreparationItem.id]); }); it.each(["pending", "running", "completed"] as const)( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index eb61b244075a3..6e95bc8658c74 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -601,7 +601,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( ); for (const row of input.visibleTurnItems) { const { item } = row; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "checkpoint") continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; const createdAt = projectedItemCreatedAt(row); From 83b6ee27d4a84bcb6f1c60f521e2c51e0176bf11 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 01:27:58 -0500 Subject: [PATCH 11/19] fix(history): load the last user message across surfaces - Use client-specific inline work limits for bounded snapshots and history pages - Keep the latest user message available when loading paged history --- .../src/orchestration-v2/Orchestrator.ts | 12 +++++++++-- .../orchestration-v2/ProjectionStore.test.ts | 21 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 16 ++++++++++++-- .../ThreadManagementService.ts | 3 ++- apps/server/src/orchestration-v2/http.ts | 4 ++++ .../threadHistoryPaging.test.ts | 9 +++++++- .../orchestration-v2/threadHistoryPaging.ts | 3 ++- apps/server/src/ws.ts | 2 ++ .../state/boundedThreadSnapshotHttp.test.ts | 11 ++++++---- .../src/state/boundedThreadSnapshotHttp.ts | 10 ++++++++- .../src/state/threadHistoryHttp.ts | 16 ++++++++++++++ packages/client-runtime/src/state/threads.ts | 7 ++++++- packages/contracts/src/environmentHttp.ts | 2 ++ packages/contracts/src/orchestrationV2.ts | 1 + 14 files changed, 104 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 69b1410ecc31a..5545e4e74e1dd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -345,6 +345,7 @@ export interface OrchestratorV2Shape { throughEntryId?: string, conversationOnly?: boolean, workMode?: "deferred" | "work", + inlineWorkItemLimit?: number, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -13278,9 +13279,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }), getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly, workMode) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode, inlineWorkItemLimit) { return projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode) + .getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + workMode, + inlineWorkItemLimit, + ) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index efec9b73205e2..7d7e0af896131 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2009,6 +2009,27 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { workCursor = page.nextCursor; } assert.strictEqual(workIds.size, 999); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL, + payload_json = json_set(payload_json, '$.runId', NULL) + WHERE thread_id = ${threadId} AND ordinal > 1 AND ordinal < 851`; + for (const inlineWorkItemLimit of [75, 150]) { + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + inlineWorkItemLimit, + }); + assert.strictEqual( + window.projection.visibleTurnItems.some( + (row) => row.item.deferredWork?.itemCount === 150, + ), + inlineWorkItemLimit === 75, + ); + } + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId}`; yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 999, payload_json = json_set(payload_json, '$.ordinal', 999) WHERE turn_item_id = 'turn-item:bounded-sql-history:999'`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index fca65791c24ff..885f099a992f1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -421,6 +421,7 @@ export interface ProjectionStoreV2Shape { throughEntryId?: string, conversationOnly?: boolean, workMode?: "deferred" | "work", + inlineWorkItemLimit?: number, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -617,6 +618,7 @@ export interface ProjectionStoreV2Shape { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; + readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -2688,6 +2690,7 @@ export const layer: Layer.Layer = readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; + readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; }, @@ -2854,7 +2857,7 @@ export const layer: Layer.Layer = SELECT run_id, segment, MIN(ordinal) AS first_ordinal, MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count FROM work_segments WHERE can_defer = 1 - GROUP BY run_id, segment HAVING COUNT(*) > ${THREAD_HISTORY_PAGE_POLICY.maxItems} + GROUP BY run_id, segment HAVING COUNT(*) > ${window.inlineWorkItemLimit ?? THREAD_HISTORY_PAGE_POLICY.maxItems} ), window_items AS NOT MATERIALIZED ( SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 @@ -2923,6 +2926,7 @@ export const layer: Layer.Layer = readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; + readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3533,6 +3537,7 @@ export const layer: Layer.Layer = readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; + readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3550,6 +3555,7 @@ export const layer: Layer.Layer = readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; + readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3663,6 +3669,7 @@ export const layer: Layer.Layer = rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, deferWork: window.deferWork, + inlineWorkItemLimit: window.inlineWorkItemLimit, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -5449,6 +5456,7 @@ export const layer: Layer.Layer = throughEntryId, conversationOnly, workMode, + inlineWorkItemLimit, ) => sql .withTransaction( @@ -5467,7 +5475,10 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); - let historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; + let historyItems = + workMode === "deferred" + ? deferWorkHistoryRows(indexed, [], inlineWorkItemLimit) + : indexed; if (workMode === "deferred") { const decoded = yield* Effect.try({ try: () => decodeThreadHistoryCursor(cursor), @@ -5683,6 +5694,7 @@ export const layer: Layer.Layer = rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, deferWork: options.deferWork, + inlineWorkItemLimit: options.inlineWorkItemLimit, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 615ea352f25af..30669b3edd637 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1278,7 +1278,7 @@ const make = Effect.gen(function* () { }), dispatch, getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly, workMode) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode, inlineWorkItemLimit) { return ensureProjectionTranscript(threadId).pipe( Effect.andThen( orchestrator.getThreadHistoryPage( @@ -1287,6 +1287,7 @@ const make = Effect.gen(function* () { throughEntryId, conversationOnly, workMode, + inlineWorkItemLimit, ), ), ); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 61ff54d0c794f..4e6385c8cd481 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -134,11 +134,13 @@ export const layer = HttpApiBuilder.group( typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], deferWork = false, + inlineWorkItemLimit?: number, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, deferWork, + inlineWorkItemLimit, userTurnLimit: anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns @@ -230,6 +232,7 @@ export const layer = HttpApiBuilder.group( undefined, undefined, args.query.deferWork === "1", + args.query.inlineWorkItemLimit === "150" ? 150 : 75, ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, @@ -271,6 +274,7 @@ export const layer = HttpApiBuilder.group( : args.query.deferWork === "1" ? "deferred" : undefined, + args.query.inlineWorkItemLimit === "150" ? 150 : 75, ) .pipe( Effect.map((page) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 52b1f40e197b5..9b72faf585e55 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -156,12 +156,19 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { - it("defers only completed work beyond 75 items and keeps conversation and persistent rows", () => { + it("defers completed work above the client limit and keeps conversation and persistent rows", () => { const work = Array.from({ length: 76 }, (_, index) => { const row = makeRow(index); return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; }); expect(deferWorkHistoryRows(work.slice(0, 75))).toHaveLength(75); + expect(deferWorkHistoryRows(work, [], 150)).toHaveLength(76); + const longWork = Array.from({ length: 151 }, (_, index) => ({ + ...makeRow(index), + item: { ...makeRow(index).item, runId: RUN, runStatus: "completed" }, + })); + expect(deferWorkHistoryRows(longWork.slice(0, 150), [], 150)).toHaveLength(150); + expect(deferWorkHistoryRows(longWork, [], 150)[0]?.item.deferredWork?.itemCount).toBe(151); const compaction = { ...work[0]!, sourceItemId: TurnItemId.make("compaction"), diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index dae3e44087d4b..4898a5571043e 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -212,6 +212,7 @@ function isDeferrableWorkItem( export function deferWorkHistoryRows( items: ReadonlyArray, runs: ReadonlyArray<{ readonly id: string; readonly status: string }> = [], + inlineWorkItemLimit: number = THREAD_HISTORY_PAGE_POLICY.maxItems, ): Row[] { const statuses = new Map(runs.map((run) => [run.id, run.status])); const blocked = new Set( @@ -244,7 +245,7 @@ export function deferWorkHistoryRows( const omitted = new Set(); const anchors = new Map(); for (const group of groups.values()) { - if (group.length <= THREAD_HISTORY_PAGE_POLICY.maxItems) continue; + if (group.length <= inlineWorkItemLimit) continue; const first = group[0]!; const last = group.at(-1)!; for (const row of group.slice(1)) omitted.add(row); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8e0f75f225c4b..293774f7c64f5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -771,6 +771,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; + readonly inlineWorkItemLimit?: 75 | 150; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; readonly acceptCompactIds?: boolean; @@ -859,6 +860,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, deferWork: input.acceptDeferredWork === true, + inlineWorkItemLimit: input.inlineWorkItemLimit, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 743457e22185e..7bab1629453d3 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -8,6 +8,7 @@ import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/ import * as RpcHttp from "../rpc/http.ts"; import * as BoundedThreadSnapshotHttp from "./boundedThreadSnapshotHttp.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; +import { ClientPresentation } from "../platform/capabilities.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: "environment-bounded" as never, @@ -130,10 +131,10 @@ const idTableBody = () => { describe("boundedThreadSnapshotLoader", () => { it.effect.each([ - ["an older server that ignores the opt-in", () => boundedBody(false)], - ["a server that sends compact turnItems", () => boundedBody(true)], - ["a server that also sends an ID table", idTableBody], - ] as const)("requests compact turnItems and loads %s", ([, body]) => { + ["an older server that ignores the opt-in", () => boundedBody(false), "web", "150"], + ["a server that sends compact turnItems", () => boundedBody(true), "mobile", "75"], + ["a server that also sends an ID table", idTableBody, "desktop", "150"], + ] as const)("requests compact turnItems and loads %s", ([, body, surface, workLimit]) => { const urls: string[] = []; const fetchFn = ((input: RequestInfo | URL) => { urls.push(String(input)); @@ -145,6 +146,7 @@ describe("boundedThreadSnapshotLoader", () => { const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); expect(new URL(urls[0]!).searchParams.get("compactIds")).toBe("1"); + expect(new URL(urls[0]!).searchParams.get("inlineWorkItemLimit")).toBe(workLimit); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ @@ -162,6 +164,7 @@ describe("boundedThreadSnapshotLoader", () => { Effect.provide( Layer.provide(BoundedThreadSnapshotHttp.layer, RpcHttp.layerRemoteHttpClient(fetchFn)), ), + Effect.provideService(ClientPresentation, { metadata: { surface } }), ); }); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 4d128477e7272..19cf17e3d3f66 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -14,6 +14,7 @@ import { withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; +import { clientInlineWorkItemLimit } from "./threadHistoryHttp.ts"; const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; @@ -28,9 +29,16 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( >; readonly timeoutMs?: number; }) { + const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1", deferWork: "1" }, + query: { + compactTurnItems: "1", + dedupeBodies: "1", + compactIds: "1", + deferWork: "1", + inlineWorkItemLimit: inlineWorkItemLimit === 150 ? ("150" as const) : ("75" as const), + }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index f0ca836248aff..ea90d5f16b575 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { executeAuthenticatedEnvironmentHttpRequest, withOrchestrationProtocolHeader, @@ -12,6 +13,19 @@ import { const DEFAULT_THREAD_HISTORY_TIMEOUT_MS = 6_000; +export const clientInlineWorkItemLimit = Effect.serviceOption( + ClientCapabilities.ClientPresentation, +).pipe( + Effect.map((presentation) => + Option.exists( + presentation, + ({ metadata }) => metadata.surface === "web" || metadata.surface === "desktop", + ) + ? (150 as const) + : (75 as const), + ), +); + export const fetchEnvironmentThreadHistoryPage = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadHistoryPage", )(function* (input: { @@ -24,11 +38,13 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; }) { + const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const endpoint = { params: { threadId: input.threadId }, query: { cursor: input.cursor, compactIds: "1", + inlineWorkItemLimit: inlineWorkItemLimit === 150 ? ("150" as const) : ("75" as const), ...(input.throughEntryId === undefined ? { deferWork: "1" } : {}), ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index de927527a7832..2416c4a25b5b2 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -38,7 +38,10 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; -import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts"; +import { + clientInlineWorkItemLimit, + fetchEnvironmentThreadHistoryPage, +} from "./threadHistoryHttp.ts"; import { applyHistoryPageMeta, clearActiveHistoryLoading, @@ -193,6 +196,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make resumeCache?: ThreadResumeCache, ) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const cache = yield* Persistence.EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const historyController = yield* Effect.serviceOption( @@ -1001,6 +1005,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ? { acceptBoundedSnapshot: true as const, acceptDeferredWork: true as const, + inlineWorkItemLimit, acceptCompactTurnItems: true as const, acceptCompactIds: true as const, } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 30c33deebd55c..be293e7b7ef91 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -658,6 +658,7 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ deferWork: Schema.optionalKey(Schema.String), + inlineWorkItemLimit: Schema.optionalKey(Schema.Literals(["75", "150"])), compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), @@ -668,6 +669,7 @@ const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ throughEntryId: Schema.optional(TrimmedNonEmptyString), view: Schema.optional(Schema.Literals(["conversation", "activity", "work"])), deferWork: Schema.optionalKey(Schema.String), + inlineWorkItemLimit: Schema.optionalKey(Schema.Literals(["75", "150"])), compactIds: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 56d910b752deb..9212c645787e6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3689,6 +3689,7 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), acceptDeferredWork: Schema.optionalKey(Schema.Boolean), + inlineWorkItemLimit: Schema.optionalKey(Schema.Literals([75, 150])), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), From 6f480de09a2b451434376af9cb0eca33eb64cc31 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:08:59 -0500 Subject: [PATCH 12/19] fix(history): load superseded work and resume long history pages --- apps/mobile/src/lib/threadActivity.test.ts | 15 ++ apps/mobile/src/lib/threadActivity.ts | 17 ++ .../orchestration-v2/ProjectionStore.test.ts | 61 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 57 +++++-- .../threadHistoryPaging.test.ts | 13 ++ .../orchestration-v2/threadHistoryPaging.ts | 8 +- .../chat/MessagesTimeline.logic.test.ts | 54 ++++++ .../components/chat/MessagesTimeline.logic.ts | 32 +++- .../src/components/chat/MessagesTimeline.tsx | 88 ++++++---- .../src/state/threads-sync.test.ts | 108 +++++++++++- packages/client-runtime/src/state/threads.ts | 154 ++++++++++-------- 11 files changed, 481 insertions(+), 126 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 0961ccc238063..eba206536a588 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -809,6 +809,21 @@ describe("buildThreadFeed", () => { "message", "message", ]); + const anchor = { + ...command(), + deferredWork: { cursor: "old-work", itemCount: 100, lastOrdinal: 100 }, + }; + const deferredFeed = buildThreadFeed([projected(userMessage(), 0), projected(anchor, 1)]); + const running = { ...latestRun, status: "running" as const, completedAt: null }; + const deferredRows = deriveThreadFeedPresentation(deferredFeed, running, new Set()); + const disclosure = deferredRows.find((entry) => entry.type === "run-fold"); + expect(disclosure).toMatchObject({ deferredWorkItemIds: [anchor.id], expanded: false }); + if (disclosure?.type !== "run-fold") throw new Error("Expected deferred work disclosure"); + expect( + deriveThreadFeedPresentation(deferredFeed, running, new Set([disclosure.runId])).some( + (entry) => entry.type === "activity-group", + ), + ).toBe(true); expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); const active = deriveThreadFeedPresentation( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 78626faee997c..ea3e8385a6ebe 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1298,6 +1298,23 @@ function deriveThreadFeedRunFolds( : "Worked", }); } + const covered = new Set( + [...foldsByAnchorId.values()].flatMap((fold) => [...fold.hiddenEntryIds]), + ); + for (const entry of feed) { + if (entry.type !== "activity-group" || covered.has(entry.id)) continue; + const deferredWorkItemIds = entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork ? [String(activity.projectedItem.sourceItemId)] : [], + ); + if (deferredWorkItemIds.length === 0) continue; + foldsByAnchorId.set(entry.id, { + runId: RunId.make(`deferred-work:${entry.id}`), + createdAt: entry.createdAt, + hiddenEntryIds: new Set([entry.id]), + label: "Earlier work", + deferredWorkItemIds, + }); + } return foldsByAnchorId; } diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 7d7e0af896131..06612f46a33d9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2009,6 +2009,67 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { workCursor = page.nextCursor; } assert.strictEqual(workIds.size, 999); + const sharedRunId = "run:bounded-sql-history:1"; + for (const [ordinal, status] of [ + [0, "completed"], + [1, "superseded"], + [2, "running"], + ] as const) { + const attempt = { + id: `attempt:bounded-sql-history:${ordinal}`, + runId: sharedRunId, + attemptOrdinal: ordinal + 1, + rootNodeId: `node:bounded-sql-history:attempt-${ordinal === 0 ? 1 : ordinal}`, + providerInstanceId, + providerThreadId: "provider-thread:bounded-sql-history", + providerTurnId: null, + reason: ordinal === 1 ? "initial" : "steering_restart", + status, + startedAt: nowIso, + completedAt: status === "superseded" ? nowIso : null, + }; + yield* sql`INSERT INTO orchestration_v2_projection_run_attempts + (attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider, provider_instance_id, provider_thread_id, provider_turn_id, status, payload_json) + VALUES (${attempt.id}, ${threadId}, ${sharedRunId}, ${attempt.attemptOrdinal}, ${attempt.rootNodeId}, ${providerInstanceId}, ${providerInstanceId}, ${attempt.providerThreadId}, NULL, ${status}, ${encodeUnknownJsonString(attempt)})`; + } + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET node_id = CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END, + payload_json = json_set(payload_json, '$.nodeId', CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END) + WHERE thread_id = ${threadId}`; + for (const status of ["completed", "running"]) { + yield* sql`UPDATE orchestration_v2_projection_runs SET status = ${status}, + payload_json = json_set(payload_json, '$.status', ${status}) WHERE run_id = ${sharedRunId}`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + inlineWorkItemLimit: 150, + }); + const oldWork = window.projection.visibleTurnItems.find((row) => row.item.deferredWork); + assert.strictEqual(oldWork?.item.deferredWork?.itemCount, 987); + let cursor: string | null = oldWork!.item.deferredWork!.cursor; + const loaded = new Set(); + for (let pages = 0; cursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = + yield* projectionStore.getThreadHistoryPage( + threadId, + cursor, + oldWork!.sourceItemId, + false, + "work", + ); + for (const row of page.items) loaded.add(row.sourceItemId); + cursor = page.nextCursor; + } + assert.strictEqual(loaded.size, 987); + assert.isFalse(loaded.has(TurnItemId.make("turn-item:bounded-sql-history:10"))); + } + yield* sql`DELETE FROM orchestration_v2_projection_run_attempts WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_runs SET status = 'completed', + payload_json = json_set(payload_json, '$.status', 'completed') WHERE run_id = ${sharedRunId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET node_id = NULL, + payload_json = json_set(payload_json, '$.nodeId', NULL) WHERE thread_id = ${threadId}`; yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL, payload_json = json_set(payload_json, '$.runId', NULL) WHERE thread_id = ${threadId} AND ordinal > 1 AND ordinal < 851`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 885f099a992f1..c364651c17547 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2698,10 +2698,19 @@ export const layer: Layer.Layer = const eligible = sql` WITH eligible AS NOT MATERIALIZED ( SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, - item.run_id, item.node_id, item.type, item.status, run.status AS run_status + item.run_id, item.node_id, item.type, item.status, run.status AS run_status, + attempt.attempt_id, attempt.status AS attempt_status FROM orchestration_v2_projection_turn_items AS item LEFT JOIN orchestration_v2_projection_runs AS run ON run.run_id = item.run_id + LEFT JOIN orchestration_v2_projection_nodes AS node + ON ${window.deferWork ? 1 : 0} = 1 AND node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt + ON ${window.deferWork ? 1 : 0} = 1 AND attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + ) WHERE item.thread_id = ${threadId} AND item.ordinal <= COALESCE( ( @@ -2839,10 +2848,10 @@ export const layer: Layer.Layer = AND (type IN ('run_interrupt_request', 'run_interrupt_result') OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) ), work_segments AS ( - SELECT item_rowid, ordinal, turn_item_id, run_id, type, + SELECT item_rowid, ordinal, turn_item_id, run_id, type, attempt_id, MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, - CASE WHEN status = 'completed' AND run_status = 'completed' + CASE WHEN status = 'completed' AND (run_status = 'completed' OR attempt_status = 'superseded') AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') AND (type <> 'dynamic_tool' OR ( payload_json NOT LIKE '%htmlRender%' AND payload_json NOT LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} @@ -2854,10 +2863,10 @@ export const layer: Layer.Layer = AND ${boundary.latest === null ? 0 : 1} = 1 AND ordinal >= ${boundary.ordinal} ), deferred_work AS ( - SELECT run_id, segment, MIN(ordinal) AS first_ordinal, + SELECT run_id, segment, attempt_id, MIN(ordinal) AS first_ordinal, MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count FROM work_segments WHERE can_defer = 1 - GROUP BY run_id, segment HAVING COUNT(*) > ${window.inlineWorkItemLimit ?? THREAD_HISTORY_PAGE_POLICY.maxItems} + GROUP BY run_id, segment, attempt_id HAVING COUNT(*) > ${window.inlineWorkItemLimit ?? THREAD_HISTORY_PAGE_POLICY.maxItems} ), window_items AS NOT MATERIALIZED ( SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 @@ -2866,6 +2875,7 @@ export const layer: Layer.Layer = SELECT item.item_rowid, item.ordinal, item.turn_item_id, item.run_id, item.type FROM work_segments item LEFT JOIN deferred_work deferred ON deferred.run_id = item.run_id AND deferred.segment = item.segment + AND deferred.attempt_id IS item.attempt_id WHERE item.can_defer = 0 OR deferred.run_id IS NULL OR item.ordinal = deferred.first_ordinal ), latest_user_turn AS ( -- Mirrors selectOlderTimelinePage: whole turns while the @@ -5306,6 +5316,8 @@ export const layer: Layer.Layer = readonly createdBy?: "user" | "agent" | "system"; readonly messageId?: MessageId; readonly runStatus?: string | undefined; + readonly attemptId?: string | null; + readonly attemptStatus?: string | null; readonly persistentOutput?: boolean; readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; @@ -5342,19 +5354,34 @@ export const layer: Layer.Layer = status: OrchestrationV2TurnItem["status"]; ordinal: number; persistent_output: number; - }>`SELECT turn_item_id, run_id, node_id, parent_item_id, type, status, ordinal, + attempt_id: string | null; + attempt_status: string | null; + }>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal, + ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id, + ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status, ${ includePersistentOutput - ? sql`CASE WHEN type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' - OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} - OR payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END` + ? sql`CASE WHEN item.type = 'dynamic_tool' AND (item.payload_json LIKE '%htmlRender%' + OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR item.payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END` : sql`0` } AS persistent_output, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, - CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id - FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} - ORDER BY ordinal ASC, turn_item_id ASC`; + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, + CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id + FROM orchestration_v2_projection_turn_items AS item + ${ + includePersistentOutput + ? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + )` + : sql`` + } + WHERE item.thread_id = ${threadId} + ORDER BY item.ordinal ASC, item.turn_item_id ASC`; const runStatuses = new Map(records.runs.map((run) => [String(run.id), run.status])); const items: Array = rows.map((row) => ({ id: TurnItemId.make(row.turn_item_id), @@ -5366,6 +5393,8 @@ export const layer: Layer.Layer = status: row.status, ordinal: row.ordinal, runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id), + attemptId: row.attempt_id, + attemptStatus: row.attempt_status, persistentOutput: row.persistent_output === 1, ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), ...(row.created_by === null ? {} : { createdBy: row.created_by }), diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 9b72faf585e55..0c628524b6606 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -169,6 +169,19 @@ describe("threadHistoryPaging", () => { })); expect(deferWorkHistoryRows(longWork.slice(0, 150), [], 150)).toHaveLength(150); expect(deferWorkHistoryRows(longWork, [], 150)[0]?.item.deferredWork?.itemCount).toBe(151); + const superseded = work.map((row) => ({ + ...row, + item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, + })); + const active = work.map((row, index) => ({ + ...row, + sourceItemId: TurnItemId.make(`active-${index}`), + position: row.position + work.length, + item: { ...row.item, runStatus: "running", attemptId: "active", attemptStatus: "running" }, + })); + const attempts = deferWorkHistoryRows([...superseded, ...active]); + expect(attempts[0]?.item.deferredWork?.itemCount).toBe(76); + expect(attempts.slice(1)).toEqual(active); const compaction = { ...work[0]!, sourceItemId: TurnItemId.make("compaction"), diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 4898a5571043e..ced4e1045f43b 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -65,6 +65,8 @@ type HistoryRow = Pick< readonly parentItemId?: string | null; readonly ordinal?: number; readonly runStatus?: string | undefined; + readonly attemptId?: string | null | undefined; + readonly attemptStatus?: string | null | undefined; readonly persistentOutput?: boolean; readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; @@ -232,12 +234,13 @@ export function deferWorkHistoryRows( if (item.runId == null) continue; if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); if ( - (item.runStatus ?? statuses.get(item.runId)) !== "completed" || + ((item.runStatus ?? statuses.get(item.runId)) !== "completed" && + item.attemptStatus !== "superseded") || blocked.has(item.runId) || !isDeferrableWorkItem({ ...item, status: item.status ?? "idle" }) ) continue; - const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}`; + const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; const group = groups.get(key) ?? []; group.push(row); groups.set(key, group); @@ -288,6 +291,7 @@ export function selectDeferredWorkPage(input: { (row) => row.sourceThreadId === anchor.sourceThreadId && row.item.runId === anchor.item.runId && + row.item.attemptId === anchor.item.attemptId && isDeferrableWorkItem({ ...row.item, status: row.item.status ?? "idle" }), ); return selectOlderTimelinePage({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f1f7c43160d05..f52a412dc5fa1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2649,6 +2649,13 @@ describe("v2 run and attempt history", () => { runId, label: "Old command", tone: "tool" as const, + projectedItem: { + sourceItemId: "old-work-anchor", + item: { + type: "command_execution", + deferredWork: { cursor: "old-work", itemCount: 200, lastOrdinal: 200 }, + }, + } as never, }, }, { @@ -2720,6 +2727,7 @@ describe("v2 run and attempt history", () => { runId, label: "Superseded attempt", expanded: false, + deferredWorkItemIds: ["old-work-anchor"], }); const expandedRows = deriveMessagesTimelineRows({ @@ -2735,6 +2743,52 @@ describe("v2 run and attempt history", () => { "steer-user-entry", "active-assistant-entry", ]); + const settledRows = deriveMessagesTimelineRows({ + ...common, + latestRun: { ...common.latestRun, status: "completed", completedAt: "2026-01-01T00:00:08Z" }, + timelineEntries: timelineEntries.map((entry) => + entry.kind === "message" && entry.id === "active-assistant-entry" + ? { ...entry, message: { ...entry.message, streaming: false } } + : entry, + ), + expandedRunIds: new Set([runId]), + }); + expect(settledRows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + deferredWorkItemIds: ["old-work-anchor"], + }); + expect( + settledRows + .filter((row) => row.kind === "turn-fold") + .flatMap((row) => row.deferredWorkItemIds ?? []), + ).not.toContain("old-work-anchor"); + const deferredTailRows = deriveMessagesTimelineRows({ + ...common, + timelineEntries: [ + timelineEntries[0]!, + timelineEntries[2]!, + timelineEntries[1]!, + ...timelineEntries.slice(3), + ].map((entry) => ({ + ...entry, + attempt: + entry.attempt.id === supersededAttemptId + ? { ...entry.attempt, completedAt: DateTime.makeUnsafe("2026-01-01T00:00:08Z") } + : entry.attempt, + ...(entry.id === "superseded-assistant-entry" + ? { + projectedItem: { + item: { + type: "assistant_message", + ordinal: 2, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-01-01T00:00:03Z"), + }, + } as never, + } + : {}), + })), + }); + expect(deferredTailRows.some((row) => row.kind === "attempt-fold")).toBe(true); }); describe("a steer that supersedes an attempt", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 99788c0c1bca4..ac29afd62b449 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -561,6 +561,7 @@ type MessagesTimelineRowContent = } | { kind: "attempt-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -709,6 +710,7 @@ interface TurnFold { } interface SupersededAttemptFold { + readonly deferredWorkItemIds: ReadonlyArray; readonly runId: RunId; readonly attemptId: RunAttemptId; readonly anchorEntryId: string; @@ -731,6 +733,18 @@ function completeSupersededReplyEntryIds( ) { continue; } + const previous = lastOutputByAttemptId.get(entry.attempt.id); + const deferredEnd = + previous?.kind === "work" + ? previous.entry.projectedItem?.item.deferredWork?.lastOrdinal + : undefined; + const ordinal = + entry.kind === "work" + ? entry.entry.projectedItem?.item.ordinal + : entry.kind === "message" + ? entry.projectedItem?.item.ordinal + : undefined; + if (deferredEnd !== undefined && ordinal !== undefined && deferredEnd > ordinal) continue; lastOutputByAttemptId.set(entry.attempt.id, entry); } const replyEntryIds = new Set(); @@ -789,6 +803,11 @@ function deriveSupersededAttemptFolds( const attempt = firstEntry?.attempt; if (firstEntry === undefined || attempt === undefined) continue; foldsByAnchorEntryId.set(firstEntry.id, { + deferredWorkItemIds: entries.flatMap((entry) => + entry.kind === "work" && entry.entry.projectedItem?.item.deferredWork + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ), runId: attempt.runId, attemptId: attempt.id, anchorEntryId: firstEntry.id, @@ -1389,6 +1408,9 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyIds, ); + const supersededWorkItemIds = new Set( + [...supersededFoldsByAnchorEntryId.values()].flatMap((fold) => fold.deferredWorkItemIds), + ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, unsettledRunId, @@ -1642,7 +1664,8 @@ export function deriveMessagesTimelineRows(input: { const deferredWorkItemIds = timelineEntries.flatMap((entry) => turnFold.entryIds.has(entry.id) && entry.kind === "work" && - entry.entry.projectedItem?.item.deferredWork + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) ? [String(entry.entry.projectedItem.sourceItemId)] : [], ); @@ -1665,6 +1688,7 @@ export function deriveMessagesTimelineRows(input: { if (supersededFold) { nextRows.push({ kind: "attempt-fold", + deferredWorkItemIds: supersededFold.deferredWorkItemIds, id: `attempt-fold:${supersededFold.attemptId}`, createdAt: supersededFold.createdAt, runId: supersededFold.runId, @@ -2310,6 +2334,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); } + case "attempt-fold": case "turn-fold": { const bf = b as typeof a; const anchors = a.deferredWorkItemIds ?? []; @@ -2323,11 +2348,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); } - case "attempt-fold": { - const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; - } - case "context-compaction": { const bc = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5eeeb1c0af04e..2bb06b4ed5106 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1555,7 +1555,10 @@ function ContextCompactionTimelineRow({ ); } -function TurnFoldTimelineRow({ row }: { row: Extract }) { +function useDeferredWorkLoading( + row: { expanded: boolean; deferredWorkItemIds?: ReadonlyArray }, + onToggle: () => void, +) { const ctx = use(TimelineRowCtx); const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); const ref = parseScopedThreadKey(ctx.routeThreadKey); @@ -1595,10 +1598,35 @@ function TurnFoldTimelineRow({ row }: { row: Extract + {[0, 1, 2].map((index) => ( + + ))} +
+ ); +} + +function TurnFoldTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleTurnFold(row.runId), + ); + return (
@@ -1622,43 +1650,39 @@ function TurnFoldTimelineRow({ row }: { row: Extract
- {loading ? ( -
- {[0, 1, 2].map((index) => ( - - ))} -
- ) : null} + {loading ? : null}
); } function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleAttemptFold(row.attemptId), + ); return ( - +
+ + {loading ? : null} +
); } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c9c722f668b6b..11e39914b2606 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -725,6 +725,85 @@ describe("EnvironmentThreads", () => { sourceItemId: item.id, item, }); + it.effect("loads a long unwrapped turn through its user message and resumes failed pages", () => + Effect.gen(function* () { + const prompt = { + ...command("prompt", "completed", 0, 0), + type: "user_message" as const, + messageId: MessageId.make("prompt"), + inputIntent: "turn_start" as const, + createdBy: "user" as const, + creationSource: "web" as const, + text: "Do the work", + attachments: [], + }; + const work = Array.from({ length: 1_000 }, (_, index) => + command(`work-${index + 1}`, "completed", 0, index + 1), + ); + const older = [prompt, ...work.slice(0, -1)]; + const tail = work.at(-1)!; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: { ...BASE_PROJECTION, turnItems: [tail], visibleTurnItems: [localRow(tail, 0)] }, + cachedHistory: { historyCursor: "1000", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "before-user") + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + items: [], + nextCursor: cursor, + hasMoreHistory: true, + }), + ), + ); + if (cursor === "925" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); + } + const end = Number(cursor); + expect(Number.isFinite(end)).toBe(true); + const start = Math.max(0, end - 75); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + items: older.slice(start, end).map(localRow), + nextCursor: start === 0 ? "before-user" : String(start), + hasMoreHistory: true, + }), + ), + ); + }), + }); + expect((yield* harness.loadEarlier())._tag).toBe("error"); + expect(requests).toEqual(["1000", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.sourceItemId), + ).toEqual([prompt, ...work].map((item) => item.id)); + expect(state.history).toMatchObject({ + historyCursor: "before-user", + hasMoreHistory: true, + loading: false, + }); + expect(requests.slice(1, 3)).toEqual(["925", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ + _tag: "error", + message: "History did not advance.", + }); + expect((yield* SubscriptionRef.get(harness.threadState)).history.loading).toBe(false); + }), + ); it.effect("loads deferred work on demand and resumes a failed page", () => Effect.gen(function* () { const anchor = { @@ -1415,6 +1494,17 @@ describe("EnvironmentThreads", () => { text: "COD4 result", streaming: false, }; + const prompt = { + ...message, + id: "item:older-prompt", + messageId: "message:older-prompt", + ordinal: 0, + type: "user_message", + inputIntent: "turn_start", + createdBy: "user", + creationSource: "web", + attachments: [], + }; const activity = { ...common, id: "item:older-command", @@ -1437,6 +1527,16 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ historyHttpClient: HttpClient.make((request, url) => Effect.gen(function* () { + if (url.searchParams.get("cursor") === "next-cursor") + return HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 14, + items: [projected(prompt, 0)], + nextCursor: "before-prompt", + hasMoreHistory: true, + }), + ); expect(url.searchParams.get("cursor")).toBe("progressive-cursor"); const conversationOnly = url.searchParams.get("view") === "conversation"; if (!conversationOnly && activityRequests++ === 0) { @@ -1508,14 +1608,16 @@ describe("EnvironmentThreads", () => { const complete = yield* SubscriptionRef.get(harness.threadState); expect(complete.history).toMatchObject({ loading: false, - historyCursor: "next-cursor", + historyCursor: mode === "retry" ? "before-prompt" : "next-cursor", error: null, latestLocalTurnOrdinal: 47, }); expect( Option.getOrThrow(complete.data).visibleTurnItems.map((row) => row.sourceItemId), - ).toEqual([message.id, activity.id]); - expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(2); + ).toEqual( + mode === "retry" ? [prompt.id, message.id, activity.id] : [message.id, activity.id], + ); + expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(mode === "retry" ? 3 : 2); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 2416c4a25b5b2..0980907ceacdd 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -668,12 +668,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const work = Option.getOrNull(current.data)?.visibleTurnItems.find( (row) => row.sourceItemId === workAnchorId, )?.item.deferredWork; - const requestCursor = + const initialCursor = work?.cursor ?? (workAnchorId === undefined ? current.history.historyCursor : null); if ( current.status === "deleted" || Option.isNone(current.data) || - requestCursor === null || + initialCursor === null || (workAnchorId === undefined && !current.history.hasMoreHistory) ) { return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; @@ -681,6 +681,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (workAnchorId === undefined && current.history.loading) { return { _tag: "busy" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; } + let requestCursor = initialCursor; const isCurrent = (latest: EnvironmentThreadState) => workAnchorId === undefined @@ -714,75 +715,90 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : [{ _tag: "noop" }, latest], ); - const runLoad = Effect.gen(function* () { - const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(preparedOption) || Option.isNone(httpClient)) - return yield* failLoad("Environment is not connected."); + const runLoad: Effect.Effect = + Effect.gen(function* () { + const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(preparedOption) || Option.isNone(httpClient)) + return yield* failLoad("Environment is not connected."); - const views = - workAnchorId !== undefined - ? ["work" as const] - : throughEntryId === undefined - ? [undefined] - : ["conversation" as const, undefined]; - for (const view of views) { - const pageResult = yield* fetchEnvironmentThreadHistoryPage({ - prepared: preparedOption.value, - threadId, - cursor: requestCursor, - throughEntryId: workAnchorId ?? throughEntryId, - view, - signer: dpopSigner, - remoteAuthorization, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); - if (Result.isFailure(pageResult)) - return yield* failLoad(formatHistoryError(pageResult.failure)); - const page = pageResult.success; - const complete = view !== "conversation"; - const waiting = yield* Ref.get(awaitingCompletion); - const result = yield* applyLock.withPermits(1)( - SubscriptionRef.modify( - state, - ( - latest, - ): readonly [ - ThreadHistoryController.ThreadHistoryLoadEarlierResult, - EnvironmentThreadState, - ] => { - if (!isCurrent(latest)) return [{ _tag: "noop" }, latest]; - if (Option.isNone(latest.data) || latest.status === "deleted") - return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; - return [ - { _tag: "loaded" }, - { - ...latest, - data: Option.some( - mergeOlderHistoryIntoProjection( - latest.data.value, - page.items, - workAnchorId === undefined - ? undefined - : { anchorId: workAnchorId, nextCursor: page.nextCursor }, + const views = + workAnchorId !== undefined + ? ["work" as const] + : throughEntryId === undefined + ? [undefined] + : ["conversation" as const, undefined]; + for (const view of views) { + const pageResult = yield* fetchEnvironmentThreadHistoryPage({ + prepared: preparedOption.value, + threadId, + cursor: requestCursor, + throughEntryId: workAnchorId ?? throughEntryId, + view, + signer: dpopSigner, + remoteAuthorization, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); + if (Result.isFailure(pageResult)) + return yield* failLoad(formatHistoryError(pageResult.failure)); + const page = pageResult.success; + const complete = view !== "conversation"; + const continueToUser = + complete && + workAnchorId === undefined && + throughEntryId === undefined && + page.hasMoreHistory && + !page.items.some( + ({ item }) => item.type === "user_message" && item.createdBy === "user", + ); + if (continueToUser && (page.nextCursor === null || page.nextCursor === requestCursor)) + return yield* failLoad("History did not advance."); + const waiting = yield* Ref.get(awaitingCompletion); + const result = yield* applyLock.withPermits(1)( + SubscriptionRef.modify( + state, + ( + latest, + ): readonly [ + ThreadHistoryController.ThreadHistoryLoadEarlierResult, + EnvironmentThreadState, + ] => { + if (!isCurrent(latest)) return [{ _tag: "noop" }, latest]; + if (Option.isNone(latest.data) || latest.status === "deleted") + return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; + return [ + { _tag: "loaded" }, + { + ...latest, + data: Option.some( + mergeOlderHistoryIntoProjection( + latest.data.value, + page.items, + workAnchorId === undefined + ? undefined + : { anchorId: workAnchorId, nextCursor: page.nextCursor }, + ), ), - ), - status: - workAnchorId === undefined && complete && waiting - ? "synchronizing" - : latest.status, - history: - workAnchorId === undefined && complete - ? applyHistoryPageMeta(latest.history, page) - : { ...latest.history, expanded: true }, - }, - ]; - }, - ), - ); - if (result._tag !== "loaded") return result; - if (complete) yield* remember; - } - return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - }); + status: + workAnchorId === undefined && complete && waiting + ? "synchronizing" + : latest.status, + history: + workAnchorId === undefined && complete + ? { ...applyHistoryPageMeta(latest.history, page), loading: continueToUser } + : { ...latest.history, expanded: true }, + }, + ]; + }, + ), + ); + if (result._tag !== "loaded") return result; + if (complete) yield* remember; + if (continueToUser && page.nextCursor !== null) { + requestCursor = page.nextCursor; + return yield* runLoad; + } + } + return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; + }); const result = yield* runLoad.pipe( Effect.onInterrupt(() => From a325bf7647692c215dea2dc478480494092c42ed Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:27:09 -0500 Subject: [PATCH 13/19] refactor(history): simplify deferred work paging --- .../src/features/threads/ThreadFeed.tsx | 26 ++--- .../src/orchestration-v2/ProjectionStore.ts | 87 +++----------- .../threadHistoryPaging.test.ts | 18 ++- .../orchestration-v2/threadHistoryPaging.ts | 48 +++----- .../src/components/chat/MessagesTimeline.tsx | 27 ++--- docs/internals/performance-regressions.md | 4 +- .../src/state/threadHistoryMerge.ts | 15 +-- .../src/state/threads-sync.test.ts | 106 +++++++----------- packages/client-runtime/src/state/threads.ts | 88 +++++++-------- 9 files changed, 145 insertions(+), 274 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3f13ec347f08d..1e02f79089150 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1226,20 +1226,18 @@ function RunFoldButton({ const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? ""; const [failed, setFailed] = useState(false); const load = () => { - if (!anchorsKey) return Promise.resolve([]); - return Promise.all( + if (!anchorsKey) return; + void Promise.all( anchorsKey .split("\n") .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), - ); - }; - const loadOnExpand = useEffectEvent(() => { - void load().then((results) => + ).then((results) => setFailed( results.some((result) => result._tag === "Failure" || result.value._tag === "error"), ), ); - }); + }; + const loadOnExpand = useEffectEvent(load); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { if (entry.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); @@ -1247,17 +1245,9 @@ function RunFoldButton({ const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null; const loading = entry.expanded && anchorsKey !== "" && !failed; const toggle = () => { - if (error && entry.expanded) { - setFailed(false); - void load().then((results) => - setFailed( - results.some((result) => result._tag === "Failure" || result.value._tag === "error"), - ), - ); - } else { - setFailed(false); - onToggle(entry.runId); - } + setFailed(false); + if (error && entry.expanded) load(); + else onToggle(entry.runId); }; return ( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c364651c17547..2a59cb127fa53 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -100,7 +100,6 @@ import { deferWorkHistoryRows, selectDeferredWorkPage, encodeThreadHistoryCursor, - decodeThreadHistoryCursor, olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, @@ -633,6 +632,14 @@ export interface ProjectionStoreV2Shape { >; } +type ProjectionWindow = Omit< + Parameters[1], + "anchorThreadId" +> & { + readonly suppressLocal?: boolean | undefined; + readonly historyAnchor?: { readonly threadId: ThreadId; readonly itemId: TurnItemId } | undefined; +}; + export class ProjectionStoreV2 extends Context.Service()( "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2", ) {} @@ -2686,14 +2693,7 @@ export const layer: Layer.Layer = // Share selection so counting discarded ancestry preserves payload-read positions. const retainedWindowRows = Effect.fn("ProjectionStore.retainedWindowRows")(function* ( threadId: ThreadId, - window: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly deferWork?: boolean | undefined; - readonly inlineWorkItemLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - }, + window: ProjectionWindow, ) { const eligible = sql` WITH eligible AS NOT MATERIALIZED ( @@ -2932,15 +2932,7 @@ export const layer: Layer.Layer = const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( function* ( threadId: ThreadId, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly deferWork?: boolean | undefined; - readonly inlineWorkItemLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - }, + window?: ProjectionWindow, fields?: ReadonlyArray, filter?: ProjectionRecordFilter, ) { @@ -3543,36 +3535,14 @@ export const layer: Layer.Layer = const readProjection: ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly deferWork?: boolean | undefined; - readonly inlineWorkItemLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) => Effect.Effect = Effect.fn( "ProjectionStore.readProjection", )( function* ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly deferWork?: boolean | undefined; - readonly inlineWorkItemLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) { const localWindow = window?.suppressLocal === true || @@ -5504,31 +5474,10 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); - let historyItems = + const historyItems = workMode === "deferred" - ? deferWorkHistoryRows(indexed, [], inlineWorkItemLimit) + ? deferWorkHistoryRows(indexed, inlineWorkItemLimit) : indexed; - if (workMode === "deferred") { - const decoded = yield* Effect.try({ - try: () => decodeThreadHistoryCursor(cursor), - catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), - }); - const anchor = indexed.find( - (row) => row.sourceThreadId === decoded.st && row.sourceItemId === decoded.si, - ); - if ( - anchor !== undefined && - !historyItems.some( - (row) => - row.sourceThreadId === anchor.sourceThreadId && - row.sourceItemId === anchor.sourceItemId, - ) - ) { - historyItems = [...historyItems, anchor].sort( - (left, right) => left.canonicalPosition - right.canonicalPosition, - ); - } - } const selection = yield* Effect.try({ try: () => { const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); @@ -5553,13 +5502,7 @@ export const layer: Layer.Layer = catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); const { page } = selection; - const policy = - workMode === "work" - ? { - maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, - maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, - } - : selection.policy; + const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : selection.policy; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 0c628524b6606..d5efe521632ae 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -162,13 +162,13 @@ describe("threadHistoryPaging", () => { return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; }); expect(deferWorkHistoryRows(work.slice(0, 75))).toHaveLength(75); - expect(deferWorkHistoryRows(work, [], 150)).toHaveLength(76); + expect(deferWorkHistoryRows(work, 150)).toHaveLength(76); const longWork = Array.from({ length: 151 }, (_, index) => ({ ...makeRow(index), item: { ...makeRow(index).item, runId: RUN, runStatus: "completed" }, })); - expect(deferWorkHistoryRows(longWork.slice(0, 150), [], 150)).toHaveLength(150); - expect(deferWorkHistoryRows(longWork, [], 150)[0]?.item.deferredWork?.itemCount).toBe(151); + expect(deferWorkHistoryRows(longWork.slice(0, 150), 150)).toHaveLength(150); + expect(deferWorkHistoryRows(longWork, 150)[0]?.item.deferredWork?.itemCount).toBe(151); const superseded = work.map((row) => ({ ...row, item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, @@ -469,6 +469,18 @@ describe("threadHistoryPaging", () => { expect(page.items.map((row) => row.sourceItemId)).toEqual(["item-3", "item-4"]); expect(page.hasMoreHistory).toBe(true); expect(page.nextCursor).not.toBeNull(); + const sparse = [makeRow(0), makeRow(10), makeRow(20)]; + const middle = selectHistoryPageFromCursor({ + items: sparse, + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: "item-hidden", + position: 15, + }), + snapshotSequence: 3, + }); + expect(middle.items.map((row) => row.sourceItemId)).toEqual(["item-0", "item-10"]); }); it("keeps cursor resolution stable when newer items append", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index ced4e1045f43b..01b60acb28f1b 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -5,7 +5,6 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; -import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; /** * Every window stops at the byte budget. A window that holds a user turn also @@ -61,7 +60,6 @@ type HistoryRow = Pick< readonly createdBy?: string; readonly runId?: string | null; readonly status?: OrchestrationV2TurnItem["status"]; - readonly nodeId?: string | null; readonly parentItemId?: string | null; readonly ordinal?: number; readonly runStatus?: string | undefined; @@ -190,33 +188,18 @@ const DEFERRED_WORK_ITEM_TYPES = [ "dynamic_tool", ] as const; -function isDeferrableWorkItem( - item: Pick & { - readonly persistentOutput?: boolean; - readonly toolName?: string | null; - readonly output?: unknown; - readonly viewedImagePath?: string; - }, -): boolean { - if (item.status !== "completed" || item.persistentOutput === true) return false; - if (item.type === "dynamic_tool") { - const tool = { toolName: item.toolName, output: item.output }; - if ( - item.viewedImagePath !== undefined || - htmlRenderFromToolItem(tool) !== undefined || - mcpAppFromToolItem(tool) !== undefined - ) - return false; - } - return DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type); +function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { + return ( + item.status === "completed" && + item.persistentOutput !== true && + DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type) + ); } export function deferWorkHistoryRows( items: ReadonlyArray, - runs: ReadonlyArray<{ readonly id: string; readonly status: string }> = [], inlineWorkItemLimit: number = THREAD_HISTORY_PAGE_POLICY.maxItems, ): Row[] { - const statuses = new Map(runs.map((run) => [run.id, run.status])); const blocked = new Set( items.flatMap(({ item }) => item.runId != null && @@ -234,10 +217,9 @@ export function deferWorkHistoryRows( if (item.runId == null) continue; if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); if ( - ((item.runStatus ?? statuses.get(item.runId)) !== "completed" && - item.attemptStatus !== "superseded") || + (item.runStatus !== "completed" && item.attemptStatus !== "superseded") || blocked.has(item.runId) || - !isDeferrableWorkItem({ ...item, status: item.status ?? "idle" }) + !isDeferrableWorkItem(item) ) continue; const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; @@ -292,17 +274,14 @@ export function selectDeferredWorkPage(input: { row.sourceThreadId === anchor.sourceThreadId && row.item.runId === anchor.item.runId && row.item.attemptId === anchor.item.attemptId && - isDeferrableWorkItem({ ...row.item, status: row.item.status ?? "idle" }), + isDeferrableWorkItem(row.item), ); return selectOlderTimelinePage({ items, exclusiveEndIndex: items.length, snapshotSequence: input.snapshotSequence, rowEncodedBytes: input.rowEncodedBytes, - policy: { - maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, - maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, - }, + policy: THREAD_HISTORY_PAGE_POLICY, }); } @@ -415,7 +394,7 @@ export function selectRecentTimelineWindow(input: { } function findCursorIndex( - items: ReadonlyArray>, + items: ReadonlyArray>, cursor: ThreadHistoryCursorPayload, ): number { const byIdentity = items.findIndex( @@ -424,7 +403,8 @@ function findCursorIndex( if (byIdentity !== -1) { return byIdentity; } - return Math.min(cursor.p, items.length); + const byPosition = items.findIndex((row) => row.position >= cursor.p); + return byPosition === -1 ? items.length : byPosition; } /** @@ -490,7 +470,7 @@ export function selectHistoryPageFromCursor(input: { */ export function missingInterruptRequests( items: ReadonlyArray, - page: ReadonlyArray>, + page: ReadonlyArray>, ): Row[] { const runs = new Set(); for (const row of page) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2bb06b4ed5106..4f64e870e3b92 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1567,21 +1567,18 @@ function useDeferredWorkLoading( const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; const [failed, setFailed] = useState(false); const load = () => { - if (!anchorsKey || environmentId === undefined || threadId === undefined) - return Promise.resolve([]); - return Promise.all( + if (!anchorsKey || environmentId === undefined || threadId === undefined) return; + void Promise.all( anchorsKey .split("\n") .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), - ); - }; - const loadOnExpand = useEffectEvent(() => { - void load().then((results) => + ).then((results) => setFailed( results.some((result) => result._tag === "Failure" || result.value._tag === "error"), ), ); - }); + }; + const loadOnExpand = useEffectEvent(load); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); @@ -1589,17 +1586,9 @@ function useDeferredWorkLoading( const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; const loading = row.expanded && anchorsKey !== "" && !failed; const toggle = () => { - if (error && row.expanded) { - setFailed(false); - void load().then((results) => - setFailed( - results.some((result) => result._tag === "Failure" || result.value._tag === "error"), - ), - ); - } else { - setFailed(false); - onToggle(); - } + setFailed(false); + if (error && row.expanded) load(); + else onToggle(); }; return { loading, error, toggle }; diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 733dbf14116af..4458f1af93461 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -16,8 +16,8 @@ The v2 checks pin these invariants: turn also stops at 10 user turns (20 on older pages) or 500 rows, so ordinary turns stay whole. Any other window, including the inside of one turn longer than 500 rows, stops at 75 rows. The snapshot query applies the same rule before it decodes payloads. Clients can opt into deferring - completed work groups above 75 items, retaining one anchor and fetching their work on expansion. - Messages and items outside those groups remain in normal history paging. Required control records and + large work groups until expansion. Messages and items outside those groups remain in normal + history paging. Required control records and the at-least-one-row rule can exceed the byte target; it is not a hard cap. - The bounded snapshot does not retain the full duplicate conversation-message table. - Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 8d1150d45cf4d..6fd678b66b129 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -128,17 +128,10 @@ export function mergeOlderHistoryIntoProjection( break; end += 1; } - const { deferredWork: _deferred, ...complete } = anchor.item; - const updated = { - ...anchor, - item: - work.nextCursor === null - ? complete - : { - ...anchor.item, - deferredWork: { ...deferred, cursor: work.nextCursor }, - }, - }; + const item = { ...anchor.item }; + if (work.nextCursor === null) delete item.deferredWork; + else item.deferredWork = { ...deferred, cursor: work.nextCursor }; + const updated = { ...anchor, item }; const section = [ ...page, ...projection.visibleTurnItems diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 11e39914b2606..6128babc475a2 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -725,6 +725,25 @@ describe("EnvironmentThreads", () => { sourceItemId: item.id, item, }); + const cachedItem = (item: OrchestrationV2TurnItem) => ({ + ...BASE_PROJECTION, + turnItems: [item], + visibleTurnItems: [localRow(item, 0)], + }); + const historyPage = ( + request: Parameters[0], + items: ReadonlyArray>, + nextCursor: string | null, + ) => + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + items, + nextCursor, + hasMoreHistory: nextCursor !== null, + }), + ); it.effect("loads a long unwrapped turn through its user message and resumes failed pages", () => Effect.gen(function* () { const prompt = { @@ -745,23 +764,12 @@ describe("EnvironmentThreads", () => { const requests: string[] = []; let failed = false; const harness = yield* makeHarness({ - cached: { ...BASE_PROJECTION, turnItems: [tail], visibleTurnItems: [localRow(tail, 0)] }, + cached: cachedItem(tail), cachedHistory: { historyCursor: "1000", hasMoreHistory: true }, historyHttpClient: HttpClient.make((request, url) => { const cursor = url.searchParams.get("cursor")!; requests.push(cursor); - if (cursor === "before-user") - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, - items: [], - nextCursor: cursor, - hasMoreHistory: true, - }), - ), - ); + if (cursor === "before-user") return Effect.succeed(historyPage(request, [], cursor)); if (cursor === "925" && !failed) { failed = true; return Effect.succeed( @@ -772,14 +780,10 @@ describe("EnvironmentThreads", () => { expect(Number.isFinite(end)).toBe(true); const start = Math.max(0, end - 75); return Effect.succeed( - HttpClientResponse.fromWeb( + historyPage( request, - Response.json({ - snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, - items: older.slice(start, end).map(localRow), - nextCursor: start === 0 ? "before-user" : String(start), - hasMoreHistory: true, - }), + older.slice(start, end).map(localRow), + start === 0 ? "before-user" : String(start), ), ); }), @@ -813,11 +817,7 @@ describe("EnvironmentThreads", () => { const requests: string[] = []; let failed = false; const harness = yield* makeHarness({ - cached: { - ...BASE_PROJECTION, - turnItems: [anchor], - visibleTurnItems: [localRow(anchor, 0)], - }, + cached: cachedItem(anchor), cachedHistory: { historyCursor: "older", hasMoreHistory: true }, historyHttpClient: HttpClient.make((request, url) => { expect(url.searchParams.get("view")).toBe("work"); @@ -830,19 +830,15 @@ describe("EnvironmentThreads", () => { ); } return Effect.succeed( - HttpClientResponse.fromWeb( + historyPage( request, - Response.json({ - snapshotSequence: 7, - items: [ - localRow( - command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), - 0, - ), - ], - nextCursor: cursor === "first" ? "second" : null, - hasMoreHistory: cursor === "first", - }), + [ + localRow( + command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), + 0, + ), + ], + cursor === "first" ? "second" : null, ), ); }), @@ -872,24 +868,12 @@ describe("EnvironmentThreads", () => { const asked = yield* Deferred.make(); const release = yield* Deferred.make(); const harness = yield* makeHarness({ - cached: { - ...BASE_PROJECTION, - turnItems: [anchor], - visibleTurnItems: [localRow(anchor, 0)], - }, + cached: cachedItem(anchor), historyHttpClient: HttpClient.make((request) => Effect.gen(function* () { yield* Deferred.succeed(asked, undefined); yield* Deferred.await(release); - return HttpClientResponse.fromWeb( - request, - Response.json({ - snapshotSequence: 7, - items: [], - nextCursor: null, - hasMoreHistory: false, - }), - ); + return historyPage(request, [], null); }), ), }); @@ -935,26 +919,14 @@ describe("EnvironmentThreads", () => { }; const views: Array = []; const harness = yield* makeHarness({ - cached: { - ...BASE_PROJECTION, - turnItems: [anchor], - visibleTurnItems: [localRow(anchor, 0)], - }, + cached: cachedItem(anchor), cachedHistory: { historyCursor: "older", hasMoreHistory: true }, historyHttpClient: HttpClient.make((request, url) => { views.push(url.searchParams.get("view")); return Effect.succeed( - HttpClientResponse.fromWeb( - request, - url.searchParams.get("view") === "work" - ? new Response("Unavailable", { status: 503 }) - : Response.json({ - snapshotSequence: 7, - items: [localRow(message, 0)], - nextCursor: null, - hasMoreHistory: false, - }), - ), + url.searchParams.get("view") === "work" + ? HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })) + : historyPage(request, [localRow(message, 0)], null), ); }), }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 0980907ceacdd..973ce94b89c9d 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -664,34 +664,34 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make throughEntryId?: string, workAnchorId?: string, ): Effect.fn.Return { + const workCursor = (latest: EnvironmentThreadState) => + Option.getOrNull(latest.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === workAnchorId, + )?.item.deferredWork?.cursor ?? null; const current = yield* SubscriptionRef.get(state); - const work = Option.getOrNull(current.data)?.visibleTurnItems.find( - (row) => row.sourceItemId === workAnchorId, - )?.item.deferredWork; const initialCursor = - work?.cursor ?? (workAnchorId === undefined ? current.history.historyCursor : null); + workAnchorId === undefined ? current.history.historyCursor : workCursor(current); if ( current.status === "deleted" || Option.isNone(current.data) || initialCursor === null || (workAnchorId === undefined && !current.history.hasMoreHistory) - ) { - return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - if (workAnchorId === undefined && current.history.loading) { - return { _tag: "busy" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } + ) + return { _tag: "noop" }; + if (workAnchorId === undefined && current.history.loading) return { _tag: "busy" }; let requestCursor = initialCursor; - const isCurrent = (latest: EnvironmentThreadState) => workAnchorId === undefined ? isActiveHistoryRequestCursor(requestCursor, latest.history) - : Option.getOrNull(latest.data)?.visibleTurnItems.find( - (row) => row.sourceItemId === workAnchorId, - )?.item.deferredWork?.cursor === requestCursor; + : workCursor(latest) === requestCursor; + const staleResult = ( + latest: EnvironmentThreadState, + ): ThreadHistoryController.ThreadHistoryLoadEarlierResult => + workAnchorId !== undefined && workCursor(latest) !== null + ? { _tag: "error", message: "Work history changed. Retry loading it." } + : { _tag: "noop" }; if (workAnchorId === undefined) yield* patchHistoryMeta((history) => ({ ...history, loading: true, error: null })); - const failLoad = (message: string) => SubscriptionRef.modify( state, @@ -712,21 +712,21 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : latest.history, }, ] - : [{ _tag: "noop" }, latest], + : [staleResult(latest), latest], ); - const runLoad: Effect.Effect = - Effect.gen(function* () { + const runLoad = Effect.gen(function* () { + while (true) { const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); if (Option.isNone(preparedOption) || Option.isNone(httpClient)) return yield* failLoad("Environment is not connected."); - const views = workAnchorId !== undefined ? ["work" as const] : throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined]; + let nextCursor: string | null = null; for (const view of views) { const pageResult = yield* fetchEnvironmentThreadHistoryPage({ prepared: preparedOption.value, @@ -741,16 +741,21 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return yield* failLoad(formatHistoryError(pageResult.failure)); const page = pageResult.success; const complete = view !== "conversation"; - const continueToUser = + const keepPaging = complete && - workAnchorId === undefined && - throughEntryId === undefined && - page.hasMoreHistory && - !page.items.some( - ({ item }) => item.type === "user_message" && item.createdBy === "user", + (workAnchorId !== undefined + ? page.nextCursor !== null + : throughEntryId === undefined && + page.hasMoreHistory && + !page.items.some( + ({ item }) => item.type === "user_message" && item.createdBy === "user", + )); + if (keepPaging && (page.nextCursor === null || page.nextCursor === requestCursor)) + return yield* failLoad( + workAnchorId === undefined + ? "History did not advance." + : "Work history did not advance.", ); - if (continueToUser && (page.nextCursor === null || page.nextCursor === requestCursor)) - return yield* failLoad("History did not advance."); const waiting = yield* Ref.get(awaitingCompletion); const result = yield* applyLock.withPermits(1)( SubscriptionRef.modify( @@ -761,7 +766,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ThreadHistoryController.ThreadHistoryLoadEarlierResult, EnvironmentThreadState, ] => { - if (!isCurrent(latest)) return [{ _tag: "noop" }, latest]; + if (!isCurrent(latest)) return [staleResult(latest), latest]; if (Option.isNone(latest.data) || latest.status === "deleted") return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; return [ @@ -783,7 +788,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : latest.status, history: workAnchorId === undefined && complete - ? { ...applyHistoryPageMeta(latest.history, page), loading: continueToUser } + ? { ...applyHistoryPageMeta(latest.history, page), loading: keepPaging } : { ...latest.history, expanded: true }, }, ]; @@ -792,15 +797,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); if (result._tag !== "loaded") return result; if (complete) yield* remember; - if (continueToUser && page.nextCursor !== null) { - requestCursor = page.nextCursor; - return yield* runLoad; - } + nextCursor = keepPaging ? page.nextCursor : null; } - return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - }); - - const result = yield* runLoad.pipe( + if (nextCursor === null) return { _tag: "loaded" } as const; + requestCursor = nextCursor; + } + }); + return yield* runLoad.pipe( Effect.onInterrupt(() => SubscriptionRef.update(state, (latest) => ({ ...latest, @@ -808,17 +811,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make })), ), ); - if (workAnchorId !== undefined && (result._tag === "loaded" || result._tag === "noop")) { - const remaining = Option.getOrNull( - (yield* SubscriptionRef.get(state)).data, - )?.visibleTurnItems.find((row) => row.sourceItemId === workAnchorId)?.item.deferredWork; - if (result._tag === "noop" && remaining !== undefined) - return { _tag: "error", message: "Work history changed. Retry loading it." }; - if (remaining?.cursor === requestCursor) - return { _tag: "error", message: "Work history did not advance." }; - if (remaining !== undefined) return yield* loadEarlier(undefined, workAnchorId); - } - return result; }); // A cached item can stay running after the server finished it, and no event will ever From da1e4fed1663e71ecd3d60604c2b70c55036b48c Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:37:27 -0500 Subject: [PATCH 14/19] fix(history): preserve hidden anchors and replacement cursors --- .../orchestration-v2/ProjectionStore.test.ts | 35 ++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 17 ++++---- .../src/state/threads-sync.test.ts | 42 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 6 ++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 06612f46a33d9..900e8adad33da 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2009,6 +2009,41 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { workCursor = page.nextCursor; } assert.strictEqual(workIds.size, 999); + for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) { + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool', + payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName}) + WHERE thread_id = ${threadId} AND ordinal = 2`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + inlineWorkItemLimit: 150, + }); + assert.isUndefined( + window.projection.visibleTurnItems.find((row) => row.item.ordinal === 2)?.item + .deferredWork, + ); + assert.strictEqual( + window.projection.visibleTurnItems.find((row) => row.item.deferredWork)?.item.ordinal, + 3, + ); + const page = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: "turn-item:bounded-sql-history:1000", + position: 1000, + }), + undefined, + false, + "deferred", + 150, + ); + assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); + } + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', + payload_json = json_set(payload_json, '$.type', 'command_execution') WHERE thread_id = ${threadId} AND ordinal = 2`; const sharedRunId = "run:bounded-sql-history:1"; for (const [ordinal, status] of [ [0, "completed"], diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2a59cb127fa53..74383463d1cdd 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1811,6 +1811,11 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND ( + item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR item.payload_json LIKE '%"viewedImagePath"%' + OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE WHEN json_type(excluded.payload_json, ${path}) IS NULL @@ -2853,12 +2858,10 @@ export const layer: Layer.Layer = OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, CASE WHEN status = 'completed' AND (run_status = 'completed' OR attempt_status = 'superseded') AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') - AND (type <> 'dynamic_tool' OR ( - payload_json NOT LIKE '%htmlRender%' AND payload_json NOT LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} - AND payload_json NOT LIKE '%"viewedImagePath"%')) + AND NOT (${inlineWorkOutput}) AND run_id NOT IN (SELECT run_id FROM blocked_runs) THEN 1 ELSE 0 END AS can_defer - FROM eligible + FROM eligible AS item WHERE ${window.deferWork ? 1 : 0} = 1 AND ${boundary.latest === null ? 0 : 1} = 1 AND ordinal >= ${boundary.ordinal} @@ -5330,11 +5333,7 @@ export const layer: Layer.Layer = ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id, ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status, ${ - includePersistentOutput - ? sql`CASE WHEN item.type = 'dynamic_tool' AND (item.payload_json LIKE '%htmlRender%' - OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} - OR item.payload_json LIKE '%"viewedImagePath"%') THEN 1 ELSE 0 END` - : sql`0` + includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0` } AS persistent_output, CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 6128babc475a2..89d7f5b43fa55 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -22,6 +22,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -904,6 +905,47 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not mark replacement history loading when a queued request becomes stale", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_PROJECTION, + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request) => + Effect.succeed(historyPage(request, [], null)), + ), + }); + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const replacement = yield* SubscriptionRef.updateEffect(harness.threadState, (latest) => + Deferred.succeed(held, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ ...latest, history: { ...latest.history, historyCursor: "replacement" } }), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held); + const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher(); + const loading = yield* harness + .loadEarlier() + .pipe( + Effect.forkChild({ startImmediately: true }), + Effect.provideService(Scheduler.Scheduler, { + executionMode: "sync", + shouldYield: () => false, + makeDispatcher: () => dispatcher, + }), + ); + dispatcher.flush(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(replacement); + dispatcher.flush(); + expect(yield* Fiber.join(loading)).toEqual({ _tag: "noop" }); + expect((yield* SubscriptionRef.get(harness.threadState)).history).toMatchObject({ + historyCursor: "replacement", + loading: false, + }); + }), + ); + it.effect("loads targeted messages without fetching unrelated deferred work", () => Effect.gen(function* () { const anchor = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 973ce94b89c9d..551ca0c8db390 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -691,7 +691,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ? { _tag: "error", message: "Work history changed. Retry loading it." } : { _tag: "noop" }; if (workAnchorId === undefined) - yield* patchHistoryMeta((history) => ({ ...history, loading: true, error: null })); + yield* patchHistoryMeta((history) => + isActiveHistoryRequestCursor(requestCursor, history) + ? { ...history, loading: true, error: null } + : history, + ); const failLoad = (message: string) => SubscriptionRef.modify( state, From ce1dbbd1abea264992e31743aae5d393c2f02c2a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:48:29 -0500 Subject: [PATCH 15/19] style(history): format merged history regression test --- .../src/state/threads-sync.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 89d7f5b43fa55..c8c212db41ca1 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -924,16 +924,14 @@ describe("EnvironmentThreads", () => { ).pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(held); const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher(); - const loading = yield* harness - .loadEarlier() - .pipe( - Effect.forkChild({ startImmediately: true }), - Effect.provideService(Scheduler.Scheduler, { - executionMode: "sync", - shouldYield: () => false, - makeDispatcher: () => dispatcher, - }), - ); + const loading = yield* harness.loadEarlier().pipe( + Effect.forkChild({ startImmediately: true }), + Effect.provideService(Scheduler.Scheduler, { + executionMode: "sync", + shouldYield: () => false, + makeDispatcher: () => dispatcher, + }), + ); dispatcher.flush(); yield* Deferred.succeed(release, undefined); yield* Fiber.join(replacement); From 9d2f1d1110ec58f0e42d5fc5794c6ff309bb4dce Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 04:03:22 -0500 Subject: [PATCH 16/19] fix(web): guard deferred work loading without a thread ref --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a8f0e73519981..46490f6b35cf2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1641,7 +1641,7 @@ function useDeferredWorkLoading( if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); }, [row.expanded, anchorsKey, environmentId, threadId]); const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; - const loading = row.expanded && anchorsKey !== "" && !failed; + const loading = ref !== null && row.expanded && anchorsKey !== "" && !failed; const toggle = () => { setFailed(false); if (error && row.expanded) load(); From 23c8c77f1e431effdd84b4c6f7c9342233306199 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 04:25:17 -0500 Subject: [PATCH 17/19] fix(history): resolve hidden deferred tails by source ordinal --- .../threadHistoryPaging.test.ts | 27 ++++++++++++++++++- .../orchestration-v2/threadHistoryPaging.ts | 21 +++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index d5efe521632ae..c78fdb3a44653 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -15,10 +15,12 @@ import { deferWorkHistoryRows, boundedTimelineEncodedBytes, computeLatestLocalTurnOrdinal, + decodeThreadHistoryCursor, encodeThreadHistoryCursor, projectedRowBoundedSnapshotEncodedBytes, projectedRowEncodedBytes, selectHistoryPageFromCursor, + selectDeferredWorkPage, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; @@ -168,7 +170,9 @@ describe("threadHistoryPaging", () => { item: { ...makeRow(index).item, runId: RUN, runStatus: "completed" }, })); expect(deferWorkHistoryRows(longWork.slice(0, 150), 150)).toHaveLength(150); - expect(deferWorkHistoryRows(longWork, 150)[0]?.item.deferredWork?.itemCount).toBe(151); + const deferredLongWork = deferWorkHistoryRows(longWork, 150)[0]!.item.deferredWork!; + expect(deferredLongWork.itemCount).toBe(151); + expect(decodeThreadHistoryCursor(deferredLongWork.cursor).p).toBe(151); const superseded = work.map((row) => ({ ...row, item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, @@ -481,6 +485,27 @@ describe("threadHistoryPaging", () => { snapshotSequence: 3, }); expect(middle.items.map((row) => row.sourceItemId)).toEqual(["item-0", "item-10"]); + const work = Array.from({ length: 80 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, ordinal: index + 100 } }; + }); + const tail = work[75]!; + const deferred = selectDeferredWorkPage({ + items: work.filter((row) => row !== tail).map((row, position) => ({ ...row, position })), + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: tail.sourceItemId, + position: tail.item.ordinal, + inclusive: true, + }), + throughEntryId: work[0]!.sourceItemId, + snapshotSequence: 3, + }); + expect(deferred.items.map((row) => row.sourceItemId)).toEqual( + work.slice(0, 75).map((row) => row.sourceItemId), + ); + expect(deferred.nextCursor).toBeNull(); }); it("keeps cursor resolution stable when newer items append", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 01b60acb28f1b..f0b36ba1d63de 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -243,7 +243,7 @@ export function deferWorkHistoryRows( snapshotSequence: 0, sourceThreadId: last.sourceThreadId, sourceItemId: last.sourceItemId, - position: last.position, + position: last.item.ordinal ?? last.position, inclusive: true, }), itemCount: group.length, @@ -263,7 +263,7 @@ export function selectDeferredWorkPage(input: { readonly rowEncodedBytes?: ((row: Row) => number) | undefined; }): SelectTimelinePageResult { const cursor = decodeThreadHistoryCursor(input.cursor); - const end = findCursorIndex(input.items, cursor) + (cursor.inclusive ? 1 : 0); + const end = findCursorEndIndex(input.items, cursor); const start = input.items.findIndex((row) => row.sourceItemId === input.throughEntryId); if (start < 0 || start >= end) throw new InvalidThreadHistoryCursorError(); const anchor = input.items[start]!; @@ -393,17 +393,22 @@ export function selectRecentTimelineWindow(input: { }); } -function findCursorIndex( - items: ReadonlyArray>, +function findCursorEndIndex( + items: ReadonlyArray, cursor: ThreadHistoryCursorPayload, ): number { const byIdentity = items.findIndex( (row) => String(row.sourceThreadId) === cursor.st && String(row.sourceItemId) === cursor.si, ); if (byIdentity !== -1) { - return byIdentity; + return byIdentity + (cursor.inclusive ? 1 : 0); } - const byPosition = items.findIndex((row) => row.position >= cursor.p); + // Inclusive deferred cursors carry source ordinals; ordinary cursors carry visible positions. + const byPosition = items.findIndex((row) => + cursor.inclusive + ? String(row.sourceThreadId) === cursor.st && (row.item.ordinal ?? row.position) > cursor.p + : row.position >= cursor.p, + ); return byPosition === -1 ? items.length : byPosition; } @@ -423,7 +428,7 @@ export function olderHistoryPagePolicy(input: { maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, }; if (input.throughEntryId === undefined) return policy; - const anchorIndex = findCursorIndex(input.items, decodeThreadHistoryCursor(input.cursor)); + const anchorIndex = findCursorEndIndex(input.items, decodeThreadHistoryCursor(input.cursor)); const targetIndex = input.items.findIndex( (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, ); @@ -456,7 +461,7 @@ export function selectHistoryPageFromCursor(input: { const decoded = decodeThreadHistoryCursor(input.cursor); return selectOlderTimelinePage({ items: input.items, - exclusiveEndIndex: findCursorIndex(input.items, decoded), + exclusiveEndIndex: findCursorEndIndex(input.items, decoded), snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), policy: olderHistoryPagePolicy(input), From 1f9a6ed2605d25ac8912e95ae6972db612654615 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 05:18:26 -0500 Subject: [PATCH 18/19] refactor(history): use a fixed 150-item work threshold --- .../src/orchestration-v2/Orchestrator.ts | 12 ++----- .../orchestration-v2/ProjectionStore.test.ts | 36 ++++++++++--------- .../src/orchestration-v2/ProjectionStore.ts | 13 ++----- .../ThreadManagementService.ts | 3 +- apps/server/src/orchestration-v2/http.ts | 4 --- .../threadHistoryPaging.test.ts | 28 ++++++--------- .../orchestration-v2/threadHistoryPaging.ts | 9 +++-- apps/server/src/ws.ts | 2 -- .../state/boundedThreadSnapshotHttp.test.ts | 11 +++--- .../src/state/boundedThreadSnapshotHttp.ts | 3 -- .../src/state/threadHistoryHttp.ts | 16 --------- packages/client-runtime/src/state/threads.ts | 7 +--- packages/contracts/src/environmentHttp.ts | 2 -- packages/contracts/src/orchestrationV2.ts | 1 - 14 files changed, 45 insertions(+), 102 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4684a126013f7..91acb881637c1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -345,7 +345,6 @@ export interface OrchestratorV2Shape { throughEntryId?: string, conversationOnly?: boolean, workMode?: "deferred" | "work", - inlineWorkItemLimit?: number, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -13283,16 +13282,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }), getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly, workMode, inlineWorkItemLimit) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return projectionStore - .getThreadHistoryPage( - threadId, - cursor, - throughEntryId, - conversationOnly, - workMode, - inlineWorkItemLimit, - ) + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 900e8adad33da..1ac85067234d4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2017,7 +2017,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { rowLimit: sqlPageLimit, userTurnLimit: 10, deferWork: true, - inlineWorkItemLimit: 150, }); assert.isUndefined( window.projection.visibleTurnItems.find((row) => row.item.ordinal === 2)?.item @@ -2038,7 +2037,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { undefined, false, "deferred", - 150, ); assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); } @@ -2078,7 +2076,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { rowLimit: sqlPageLimit, userTurnLimit: 10, deferWork: true, - inlineWorkItemLimit: 150, }); const oldWork = window.projection.visibleTurnItems.find((row) => row.item.deferredWork); assert.strictEqual(oldWork?.item.deferredWork?.itemCount, 987); @@ -2108,20 +2105,25 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL, payload_json = json_set(payload_json, '$.runId', NULL) WHERE thread_id = ${threadId} AND ordinal > 1 AND ordinal < 851`; - for (const inlineWorkItemLimit of [75, 150]) { - const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: sqlPageLimit, - userTurnLimit: 10, - deferWork: true, - inlineWorkItemLimit, - }); - assert.strictEqual( - window.projection.visibleTurnItems.some( - (row) => row.item.deferredWork?.itemCount === 150, - ), - inlineWorkItemLimit === 75, - ); - } + const inlineWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isFalse(inlineWindow.projection.visibleTurnItems.some((row) => row.item.deferredWork)); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = ${sharedRunId}, + payload_json = json_set(payload_json, '$.runId', ${sharedRunId}) + WHERE thread_id = ${threadId} AND ordinal = 850`; + const deferredWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isTrue( + deferredWindow.projection.visibleTurnItems.some( + (row) => row.item.deferredWork?.itemCount === 151, + ), + ); yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = 'run:bounded-sql-history:1', payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 74383463d1cdd..4cb6e4248b717 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -104,6 +104,7 @@ import { selectHistoryPageFromCursor, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, + THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; @@ -420,7 +421,6 @@ export interface ProjectionStoreV2Shape { throughEntryId?: string, conversationOnly?: boolean, workMode?: "deferred" | "work", - inlineWorkItemLimit?: number, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -617,7 +617,6 @@ export interface ProjectionStoreV2Shape { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; readonly deferWork?: boolean | undefined; - readonly inlineWorkItemLimit?: number | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -2869,7 +2868,7 @@ export const layer: Layer.Layer = SELECT run_id, segment, attempt_id, MIN(ordinal) AS first_ordinal, MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count FROM work_segments WHERE can_defer = 1 - GROUP BY run_id, segment, attempt_id HAVING COUNT(*) > ${window.inlineWorkItemLimit ?? THREAD_HISTORY_PAGE_POLICY.maxItems} + GROUP BY run_id, segment, attempt_id HAVING COUNT(*) > ${THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT} ), window_items AS NOT MATERIALIZED ( SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 @@ -3652,7 +3651,6 @@ export const layer: Layer.Layer = rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, deferWork: window.deferWork, - inlineWorkItemLimit: window.inlineWorkItemLimit, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -5454,7 +5452,6 @@ export const layer: Layer.Layer = throughEntryId, conversationOnly, workMode, - inlineWorkItemLimit, ) => sql .withTransaction( @@ -5473,10 +5470,7 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); - const historyItems = - workMode === "deferred" - ? deferWorkHistoryRows(indexed, inlineWorkItemLimit) - : indexed; + const historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; const selection = yield* Effect.try({ try: () => { const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); @@ -5665,7 +5659,6 @@ export const layer: Layer.Layer = rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, deferWork: options.deferWork, - inlineWorkItemLimit: options.inlineWorkItemLimit, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 30669b3edd637..615ea352f25af 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1278,7 +1278,7 @@ const make = Effect.gen(function* () { }), dispatch, getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly, workMode, inlineWorkItemLimit) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return ensureProjectionTranscript(threadId).pipe( Effect.andThen( orchestrator.getThreadHistoryPage( @@ -1287,7 +1287,6 @@ const make = Effect.gen(function* () { throughEntryId, conversationOnly, workMode, - inlineWorkItemLimit, ), ), ); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 4e6385c8cd481..61ff54d0c794f 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -134,13 +134,11 @@ export const layer = HttpApiBuilder.group( typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], deferWork = false, - inlineWorkItemLimit?: number, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, deferWork, - inlineWorkItemLimit, userTurnLimit: anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns @@ -232,7 +230,6 @@ export const layer = HttpApiBuilder.group( undefined, undefined, args.query.deferWork === "1", - args.query.inlineWorkItemLimit === "150" ? 150 : 75, ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, @@ -274,7 +271,6 @@ export const layer = HttpApiBuilder.group( : args.query.deferWork === "1" ? "deferred" : undefined, - args.query.inlineWorkItemLimit === "150" ? 150 : 75, ) .pipe( Effect.map((page) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index c78fdb3a44653..f4e54000222fc 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -158,21 +158,15 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { - it("defers completed work above the client limit and keeps conversation and persistent rows", () => { - const work = Array.from({ length: 76 }, (_, index) => { + it("defers completed work above 150 items and keeps conversation and persistent rows", () => { + const work = Array.from({ length: 151 }, (_, index) => { const row = makeRow(index); return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; }); - expect(deferWorkHistoryRows(work.slice(0, 75))).toHaveLength(75); - expect(deferWorkHistoryRows(work, 150)).toHaveLength(76); - const longWork = Array.from({ length: 151 }, (_, index) => ({ - ...makeRow(index), - item: { ...makeRow(index).item, runId: RUN, runStatus: "completed" }, - })); - expect(deferWorkHistoryRows(longWork.slice(0, 150), 150)).toHaveLength(150); - const deferredLongWork = deferWorkHistoryRows(longWork, 150)[0]!.item.deferredWork!; - expect(deferredLongWork.itemCount).toBe(151); - expect(decodeThreadHistoryCursor(deferredLongWork.cursor).p).toBe(151); + expect(deferWorkHistoryRows(work.slice(0, 150))).toHaveLength(150); + const deferredWork = deferWorkHistoryRows(work)[0]!.item.deferredWork!; + expect(deferredWork.itemCount).toBe(151); + expect(decodeThreadHistoryCursor(deferredWork.cursor).p).toBe(151); const superseded = work.map((row) => ({ ...row, item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, @@ -184,7 +178,7 @@ describe("threadHistoryPaging", () => { item: { ...row.item, runStatus: "running", attemptId: "active", attemptStatus: "running" }, })); const attempts = deferWorkHistoryRows([...superseded, ...active]); - expect(attempts[0]?.item.deferredWork?.itemCount).toBe(76); + expect(attempts[0]?.item.deferredWork?.itemCount).toBe(151); expect(attempts.slice(1)).toEqual(active); const compaction = { ...work[0]!, @@ -210,17 +204,17 @@ describe("threadHistoryPaging", () => { ...work.slice(30), ]); expect(deferred.map((row) => row.sourceItemId)).toEqual(["item-0", "compaction", "render"]); - expect(deferred[0]?.item.deferredWork?.itemCount).toBe(76); + expect(deferred[0]?.item.deferredWork?.itemCount).toBe(151); for (const runStatus of ["running", "failed", "interrupted", "cancelled"]) { expect( deferWorkHistoryRows(work.map((row) => ({ ...row, item: { ...row.item, runStatus } }))), - ).toHaveLength(76); + ).toHaveLength(151); } const interrupted = { ...compaction, item: { ...compaction.item, type: "run_interrupt_result" as const }, }; - expect(deferWorkHistoryRows([...work, interrupted])).toHaveLength(77); + expect(deferWorkHistoryRows([...work, interrupted])).toHaveLength(152); const failed = { ...compaction, item: { @@ -231,7 +225,7 @@ describe("threadHistoryPaging", () => { parentItemId: null, }, }; - expect(deferWorkHistoryRows([...work, failed])).toHaveLength(77); + expect(deferWorkHistoryRows([...work, failed])).toHaveLength(152); }); it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index f0b36ba1d63de..7f1ea4db95d6f 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -19,6 +19,8 @@ export const THREAD_HISTORY_PAGE_POLICY = { maxEncodedBytes: 1_048_576, } as const; +export const THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT = 150; + export const OLDER_THREAD_USER_TURN_LIMIT = 20; export function isConversationHistoryItem(item: Pick): boolean { @@ -196,10 +198,7 @@ function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { ); } -export function deferWorkHistoryRows( - items: ReadonlyArray, - inlineWorkItemLimit: number = THREAD_HISTORY_PAGE_POLICY.maxItems, -): Row[] { +export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { const blocked = new Set( items.flatMap(({ item }) => item.runId != null && @@ -230,7 +229,7 @@ export function deferWorkHistoryRows( const omitted = new Set(); const anchors = new Map(); for (const group of groups.values()) { - if (group.length <= inlineWorkItemLimit) continue; + if (group.length <= THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT) continue; const first = group[0]!; const last = group.at(-1)!; for (const row of group.slice(1)) omitted.add(row); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8c8ae439b0d68..e1d6303b23506 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -775,7 +775,6 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; - readonly inlineWorkItemLimit?: 75 | 150; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; readonly acceptCompactIds?: boolean; @@ -864,7 +863,6 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, deferWork: input.acceptDeferredWork === true, - inlineWorkItemLimit: input.inlineWorkItemLimit, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 7bab1629453d3..743457e22185e 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -8,7 +8,6 @@ import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/ import * as RpcHttp from "../rpc/http.ts"; import * as BoundedThreadSnapshotHttp from "./boundedThreadSnapshotHttp.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; -import { ClientPresentation } from "../platform/capabilities.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: "environment-bounded" as never, @@ -131,10 +130,10 @@ const idTableBody = () => { describe("boundedThreadSnapshotLoader", () => { it.effect.each([ - ["an older server that ignores the opt-in", () => boundedBody(false), "web", "150"], - ["a server that sends compact turnItems", () => boundedBody(true), "mobile", "75"], - ["a server that also sends an ID table", idTableBody, "desktop", "150"], - ] as const)("requests compact turnItems and loads %s", ([, body, surface, workLimit]) => { + ["an older server that ignores the opt-in", () => boundedBody(false)], + ["a server that sends compact turnItems", () => boundedBody(true)], + ["a server that also sends an ID table", idTableBody], + ] as const)("requests compact turnItems and loads %s", ([, body]) => { const urls: string[] = []; const fetchFn = ((input: RequestInfo | URL) => { urls.push(String(input)); @@ -146,7 +145,6 @@ describe("boundedThreadSnapshotLoader", () => { const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); expect(new URL(urls[0]!).searchParams.get("compactIds")).toBe("1"); - expect(new URL(urls[0]!).searchParams.get("inlineWorkItemLimit")).toBe(workLimit); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ @@ -164,7 +162,6 @@ describe("boundedThreadSnapshotLoader", () => { Effect.provide( Layer.provide(BoundedThreadSnapshotHttp.layer, RpcHttp.layerRemoteHttpClient(fetchFn)), ), - Effect.provideService(ClientPresentation, { metadata: { surface } }), ); }); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 19cf17e3d3f66..e3e9b5bb57fec 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -14,7 +14,6 @@ import { withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; -import { clientInlineWorkItemLimit } from "./threadHistoryHttp.ts"; const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; @@ -29,7 +28,6 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( >; readonly timeoutMs?: number; }) { - const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const endpoint = { params: { threadId: input.threadId }, query: { @@ -37,7 +35,6 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( dedupeBodies: "1", compactIds: "1", deferWork: "1", - inlineWorkItemLimit: inlineWorkItemLimit === 150 ? ("150" as const) : ("75" as const), }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index ea90d5f16b575..f0ca836248aff 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -5,7 +5,6 @@ import * as Option from "effect/Option"; import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; -import * as ClientCapabilities from "../platform/capabilities.ts"; import { executeAuthenticatedEnvironmentHttpRequest, withOrchestrationProtocolHeader, @@ -13,19 +12,6 @@ import { const DEFAULT_THREAD_HISTORY_TIMEOUT_MS = 6_000; -export const clientInlineWorkItemLimit = Effect.serviceOption( - ClientCapabilities.ClientPresentation, -).pipe( - Effect.map((presentation) => - Option.exists( - presentation, - ({ metadata }) => metadata.surface === "web" || metadata.surface === "desktop", - ) - ? (150 as const) - : (75 as const), - ), -); - export const fetchEnvironmentThreadHistoryPage = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadHistoryPage", )(function* (input: { @@ -38,13 +24,11 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; }) { - const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const endpoint = { params: { threadId: input.threadId }, query: { cursor: input.cursor, compactIds: "1", - inlineWorkItemLimit: inlineWorkItemLimit === 150 ? ("150" as const) : ("75" as const), ...(input.throughEntryId === undefined ? { deferWork: "1" } : {}), ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 551ca0c8db390..3cb4192f0ab6c 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -38,10 +38,7 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; -import { - clientInlineWorkItemLimit, - fetchEnvironmentThreadHistoryPage, -} from "./threadHistoryHttp.ts"; +import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts"; import { applyHistoryPageMeta, clearActiveHistoryLoading, @@ -196,7 +193,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make resumeCache?: ThreadResumeCache, ) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const inlineWorkItemLimit = yield* clientInlineWorkItemLimit; const cache = yield* Persistence.EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const historyController = yield* Effect.serviceOption( @@ -1017,7 +1013,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ? { acceptBoundedSnapshot: true as const, acceptDeferredWork: true as const, - inlineWorkItemLimit, acceptCompactTurnItems: true as const, acceptCompactIds: true as const, } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index be293e7b7ef91..30c33deebd55c 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -658,7 +658,6 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ deferWork: Schema.optionalKey(Schema.String), - inlineWorkItemLimit: Schema.optionalKey(Schema.Literals(["75", "150"])), compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), @@ -669,7 +668,6 @@ const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ throughEntryId: Schema.optional(TrimmedNonEmptyString), view: Schema.optional(Schema.Literals(["conversation", "activity", "work"])), deferWork: Schema.optionalKey(Schema.String), - inlineWorkItemLimit: Schema.optionalKey(Schema.Literals(["75", "150"])), compactIds: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 9212c645787e6..56d910b752deb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3689,7 +3689,6 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), acceptDeferredWork: Schema.optionalKey(Schema.Boolean), - inlineWorkItemLimit: Schema.optionalKey(Schema.Literals([75, 150])), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), From 779a5e7f6b9efaa1e0480caa0e8361c393ed495a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 05:30:39 -0500 Subject: [PATCH 19/19] perf(history): scope marker lookups to retained runs --- .../orchestration-v2/ProjectionStore.test.ts | 28 +++++++++++++++---- .../src/orchestration-v2/ProjectionStore.ts | 4 +++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1ac85067234d4..2af87ecf4feb7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1984,11 +1984,29 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { payload_json = json_set(payload_json, '$.ordinal', 1000) WHERE thread_id = ${threadId} AND ordinal = 999`; const promptId = TurnItemId.make("turn-item:bounded-sql-history:1"); - const deferred = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: sqlPageLimit, - userTurnLimit: 10, - deferWork: true, - }); + const { statements: deferredStatements, tracer: deferredTracer } = traceSqlStatements(); + const deferred = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe(Effect.withTracer(deferredTracer)); + const deferredQuery = deferredStatements.find((statement) => + statement.includes("blocked_runs AS MATERIALIZED"), + ); + assert.isDefined(deferredQuery); + const deferredPlan = yield* sql.unsafe<{ + readonly id: number; + readonly parent: number; + readonly detail: string; + }>(`EXPLAIN QUERY PLAN ${deferredQuery}`); + const blockedRunsPlan = deferredPlan.find((row) => row.detail === "MATERIALIZE blocked_runs"); + assert.isDefined(blockedRunsPlan); + const blockedLookup = deferredPlan.find( + (row) => row.parent === blockedRunsPlan!.id && row.detail.startsWith("SEARCH "), + ); + assert.include(blockedLookup?.detail ?? "", "run_id=?"); assert.lengthOf(deferred.projection.visibleTurnItems, 3); assert.strictEqual(deferred.projection.visibleTurnItems[0]?.sourceItemId, promptId); const anchor = deferred.projection.visibleTurnItems[1]!; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4cb6e4248b717..2a21bfae4bd32 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2849,6 +2849,10 @@ export const layer: Layer.Layer = SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND run_id IS NOT NULL AND ${window.deferWork ? 1 : 0} = 1 + AND run_id IN ( + SELECT run_id FROM eligible + WHERE ordinal >= ${boundary.ordinal} AND run_id IS NOT NULL + ) AND (type IN ('run_interrupt_request', 'run_interrupt_result') OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) ), work_segments AS (