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 36fe66a02649..346ee5f873d6 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 0038bc9bf4c1..37fe962ec862 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 e27f32ef8442..50f606a8bf73 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 7f70a2ec25ee..f4dcc84caf0b 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 ed064325bfe1..411da1c4c9ac 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 9a80f8d28417..807a26a9c395 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 a5059eada443..9b0cfbeb6a5a 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 3c4baab80ef0..414844028ec5 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 9d3e583b832e..578b1e01f5b8 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 38ad3b5c7de1..12e1d0109ae4 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 579639ca83ab..fd85232f3c27 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 e5696e6f5cd1..3afd4eb79574 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 169acd8630d0..f27a238f8a58 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 0648d1832f07..584c300b3f39 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 fbfa67e61bf8..130049577ea7 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 51760756c108..733dbf14116a 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 c7006fd0909d..42be7e347485 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 e0680c17c46f..f6ba3e9f0c1a 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 ffe9eb14dfa2..fbba7d1bd9dd 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 d61213c243ea..1799f3bd2482 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 ad0cd9ec2a3b..f3a85c0bc1ad 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 c9ae1843ff27..09b594a0acda 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 a9714172e44b..9947d1f077d6 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 4dd4e7cef521..91d05ff85967 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 24840ee28420..fd3a2f2b45a1 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 3c1c153621e2..526d739e2f1c 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 693af5329fe1..e5ff90b9d69b 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 330e292181d4..c196a3c9f7d0 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 f4e830397226..6a055850a43d 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 0bfb6cdf081e..1eee3e6f9abf 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 000000000000..6470da5defa0 --- /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 346ee5f873d6..7116f72dff47 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 50f606a8bf73..563de061c5c1 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 f4dcc84caf0b..84928bdec791 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 411da1c4c9ac..510c97cf1ff9 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 9b0cfbeb6a5a..a5059eada443 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 414844028ec5..3c4baab80ef0 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 578b1e01f5b8..61ff54d0c794 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 12e1d0109ae4..52b1f40e197b 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 fd85232f3c27..aecdbb146012 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 3afd4eb79574..2829824b8f99 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 f27a238f8a58..169acd8630d0 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 130049577ea7..2962955b19b2 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 1799f3bd2482..d61213c243ea 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 f3a85c0bc1ad..b220d7f741bf 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 09b594a0acda..1c8818fd54db 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 9947d1f077d6..f0ca836248af 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 91d05ff85967..59e1ea3652e5 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 fd3a2f2b45a1..8d1150d45cf4 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 526d739e2f1c..778927396bb1 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 e5ff90b9d69b..97afa271dc70 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 1eee3e6f9abf..0bfb6cdf081e 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 6470da5defa0..000000000000 --- 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 b01f61bed86f..4e9c0d6917be 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 5ff221a6e005..48feb955d0a3 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 0e287fbc478e..865bcc7c8cb1 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 a797b3807df6..fdd9461348af 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 6995def43e84..36488aaccbb1 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 36488aaccbb1..fca65791c24f 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 2b031370f882..58424bd4d8d5 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 158c3865cdee..4bc0272726fe 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 50b27d8561a6..02b77f9d7d64 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 02b77f9d7d64..c9c722f668b6 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 f1789f47d396..de927527a783 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 cc4b386f17e8..f1f7c43160d0 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 7931021f75b8..99788c0c1bca 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 58424bd4d8d5..3f13ec347f08 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 e5c06be1709e..78626faee997 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 4bc0272726fe..5eeeb1c0af04 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 fd10c96aa1b2..c4024df79438 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 eb61b244075a..6e95bc8658c7 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 69b1410ecc31..5545e4e74e1d 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 efec9b73205e..7d7e0af89613 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 fca65791c24f..885f099a992f 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 615ea352f25a..30669b3edd63 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 61ff54d0c794..4e6385c8cd48 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 52b1f40e197b..9b72faf585e5 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 dae3e44087d4..4898a5571043 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 8e0f75f225c4..293774f7c64f 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 743457e22185..7bab1629453d 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 4d128477e727..19cf17e3d3f6 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 f0ca836248af..ea90d5f16b57 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 de927527a783..2416c4a25b5b 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 30c33deebd55..be293e7b7ef9 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 56d910b752de..9212c645787e 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 0961ccc23806..eba206536a58 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 78626faee997..ea3e8385a6eb 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 7d7e0af89613..06612f46a33d 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 885f099a992f..c364651c1754 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 9b72faf585e5..0c628524b660 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 4898a5571043..ced4e1045f43 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 f1f7c43160d0..f52a412dc5fa 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 99788c0c1bca..ac29afd62b44 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 5eeeb1c0af04..2bb06b4ed510 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 c9c722f668b6..11e39914b260 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 2416c4a25b5b..0980907ceacd 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 3f13ec347f08..1e02f7908915 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 c364651c1754..2a59cb127fa5 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 0c628524b660..d5efe521632a 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 ced4e1045f43..01b60acb28f1 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 2bb06b4ed510..4f64e870e3b9 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 733dbf14116a..4458f1af9346 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 8d1150d45cf4..6fd678b66b12 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 11e39914b260..6128babc475a 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 0980907ceacd..973ce94b89c9 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 06612f46a33d..900e8adad33d 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 2a59cb127fa5..74383463d1cd 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 6128babc475a..89d7f5b43fa5 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 973ce94b89c9..551ca0c8db39 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 89d7f5b43fa5..c8c212db41ca 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 a8f0e7351998..46490f6b35cf 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 d5efe521632a..c78fdb3a4465 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 01b60acb28f1..f0b36ba1d63d 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 4684a126013f..91acb881637c 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 900e8adad33d..1ac85067234d 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 74383463d1cd..4cb6e4248b71 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 30669b3edd63..615ea352f25a 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 4e6385c8cd48..61ff54d0c794 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 c78fdb3a4465..f4e54000222f 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 f0b36ba1d63d..7f1ea4db95d6 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 8c8ae439b0d6..e1d6303b2350 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 7bab1629453d..743457e22185 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 19cf17e3d3f6..e3e9b5bb57fe 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 ea90d5f16b57..f0ca836248af 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 551ca0c8db39..3cb4192f0ab6 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 be293e7b7ef9..30c33deebd55 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 9212c645787e..56d910b752de 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 1ac85067234d..2af87ecf4feb 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 4cb6e4248b71..2a21bfae4bd3 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 (