diff --git a/.github/upstream-candidates.json b/.github/upstream-candidates.json index 37b92db6583..5b814d0c601 100644 --- a/.github/upstream-candidates.json +++ b/.github/upstream-candidates.json @@ -4,14 +4,18 @@ { "upstreamPr": 4018, "sourceSha": "de8fd65934768173819b93adcd6b92af3e8c7fc3", - "status": "active", - "purpose": "Bound server thread history and lazily page older web activity" + "status": "superseded", + "purpose": "Bound server thread history and lazily page older web activity", + "supersededBy": "upstream #5493 (6b73b3def): user-anchored turn-window pagination", + "note": "Client usage removed in the 6b73b3def sync merge. The server getThreadActivities RPC is retained only for deployed mobile clients; remove it once fleet mobile builds predate it no longer." }, { "upstreamPr": 3510, "sourceSha": "034f4936d7a1435887bb62ac3f2db61f08928cbf", - "status": "active", - "purpose": "Page mobile history and bound stale subscription catch-up" + "status": "partially-superseded", + "purpose": "Page mobile history and bound stale subscription catch-up", + "supersededBy": "upstream #5493 (6b73b3def): mobile paging replaced by loadEarlier turn windows", + "note": "The mobile scroll-up paging half was removed in the 6b73b3def sync merge. The server bounded-replay half in OrchestrationEngine remains active." }, { "upstreamPr": 4176, diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 6573c9e1187..ad5ef13b62d 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -17,7 +17,10 @@ import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; +// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump +// makes pre-pagination clients discard the record instead of decoding a +// partial thread as complete (rollback safety). +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d5dc7d1988d..7d5b408e04c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -62,11 +62,10 @@ export interface ThreadDetailScreenProps { readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; /** A send made now would be held in the steering queue, not open a turn. */ readonly sendEntersQueue: boolean; - readonly hasMoreOlderActivities: boolean; - readonly loadingOlderActivities: boolean; - readonly onLoadOlderActivities: () => void; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; @@ -391,9 +390,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} - hasMoreOlder={props.hasMoreOlderActivities} - loadingOlder={props.loadingOlderActivities} - onLoadOlder={props.onLoadOlderActivities} + loadEarlier={props.loadEarlier ?? null} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 98e3fb03283..e8d6b24b781 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -165,10 +165,11 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; - /** Older history beyond the live activity window can be lazy-loaded on scroll-up. */ - readonly hasMoreOlder?: boolean; - readonly loadingOlder?: boolean; - readonly onLoadOlder?: () => void; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { + readonly loading: boolean; + readonly onLoadEarlier: () => void; + } | null; } function MessageAttachmentImage(props: { @@ -1636,15 +1637,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - // Reaching the top (oldest) lazy-loads older history. The hook keys an - // in-flight guard by thread, so repeated fires during scroll coalesce. - const { hasMoreOlder, loadingOlder, onLoadOlder } = props; - const onStartReachedOlderHistory = useCallback(() => { - if (hasMoreOlder && !loadingOlder) { - onLoadOlder?.(); - } - }, [hasMoreOlder, loadingOlder, onLoadOlder]); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1980,22 +1972,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} - onStartReached={onStartReachedOlderHistory} - onStartReachedThreshold={0.5} onScrollBeginDrag={handleScrollBeginDrag} scrollEventThrottle={16} // Under automatic insets the spacer is UIKit's job, but the // older-history spinner still belongs at the top of the content. ListHeaderComponent={ - usesNativeAutomaticInsets ? ( - loadingOlder ? ( - - ) : null - ) : ( - - {loadingOlder ? : null} - - ) + <> + {usesNativeAutomaticInsets ? null : } + {props.loadEarlier != null ? ( + + + {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"} + + + ) : null} + } contentContainerStyle={{ paddingTop: 12, @@ -2041,25 +2036,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} - {props.feed.length === 0 && hasMoreOlder ? ( + {props.feed.length === 0 && props.loadEarlier != null ? ( // The window can derive zero visible entries while older history - // exists — without scrollable content `onStartReached` can never - // fire, so give the user an explicit affordance instead of the + // exists — give the user an explicit affordance instead of the // empty-state placeholder. - {loadingOlder ? ( + {props.loadEarlier.loading ? ( ) : ( - onLoadOlder?.()}> - Load older history + + Load earlier turns )} ) : null} {props.feed.length === 0 && - !hasMoreOlder && + props.loadEarlier == null && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d57583c368e..bdd81cabe6a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -8,6 +8,10 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -190,13 +194,25 @@ function ThreadRouteContent( useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + // "Load earlier turns" header state for windowed (paginated) thread loads. + const loadEarlierTurns = useMemo(() => { + if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) { + return null; + } + return { + loading: + selectedThreadDetailState.page._tag === "Some" && + selectedThreadDetailState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id); + }, + }; + }, [selectedThread, selectedThreadDetailState]); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - // Derive pending requests from the FULL loaded set (older pages + live - // window) so a prompt the user scrolled back to load still surfaces. - const requests = useSelectedThreadRequests(composer.mergedActivities); + const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const navigation = useNavigation(); const params = props.route.params; @@ -788,11 +804,9 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + loadEarlier={loadEarlierTurns} sendEntersQueue={composer.sendEntersQueue} composerQueueItems={composer.composerQueueItems} - hasMoreOlderActivities={composer.hasMoreOlderActivities} - loadingOlderActivities={composer.loadingOlderActivities} - onLoadOlderActivities={composer.onLoadOlderActivities} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 517e4403ece..da4821e83d3 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -67,7 +67,7 @@ describe("mobile surface existence (anti stack-drop)", () => { // The feed and the chip list both read the promoted detail, so one piece // of state moves the message and one revert puts it back. expect(composerState).toContain("promoteSteeredQueuedMessages(selectedThreadDetail"); - expect(composerState).toMatch(/buildThreadFeed\(\{ \.\.\.steeredDetail/); + expect(composerState).toMatch(/buildThreadFeed\(steeredDetail\)/); expect(composerState).toMatch(/timelineIds = new Set\(steeredDetail\?\.messages/); // Failure puts it back rather than leaving a bubble the agent never got. expect(composerState).toMatch( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 634718ae3f3..4f36cbb49fb 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -13,10 +13,6 @@ import { } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; -import { - useOlderThreadActivities, - type OlderActivitiesCursor, -} from "@t3tools/client-runtime/state/older-thread-activities"; import { sendEntersSteeringQueue } from "@t3tools/shared/chatList"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -178,43 +174,6 @@ export function useThreadComposerState() { const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { label: "remove queued message", }); - const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; - const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; - const loadOlderActivitiesPage = useCallback( - async (cursor: OlderActivitiesCursor) => { - if (selectedEnvironmentIdForActivities === null || selectedThreadIdForActivities === null) { - return null; - } - const result = await loadThreadActivities({ - environmentId: selectedEnvironmentIdForActivities, - input: { threadId: selectedThreadIdForActivities, ...cursor }, - }); - if (result._tag !== "Success") { - // Surface real failures (a spinner that quietly gives up reads as - // missing history); keep `hasMore` so scrolling back retries. - if (!isAtomCommandInterrupted(result)) { - setPendingConnectionError("Could not load older thread history."); - } - return null; - } - return result.value; - }, - [selectedEnvironmentIdForActivities, selectedThreadIdForActivities, loadThreadActivities], - ); - const { - mergedActivities, - hasMoreOlder: hasMoreOlderActivities, - loadingOlder: loadingOlderActivities, - loadOlder: onLoadOlderActivities, - } = useOlderThreadActivities({ - threadKey: selectedThreadShell - ? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}` - : null, - liveActivities: selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES, - hasMoreLiveActivities: selectedThreadDetail?.hasMoreActivities ?? false, - loadPage: loadOlderActivitiesPage, - }); - // "Send now" promotes a queued message into the conversation before the // server confirms the dispatch; the chip goes with it. See // promoteSteeredQueuedMessages. @@ -232,8 +191,8 @@ export function useThreadComposerState() { if (!steeredDetail) { return []; } - return buildThreadFeed({ ...steeredDetail, activities: mergedActivities }); - }, [steeredDetail, mergedActivities]); + return buildThreadFeed(steeredDetail); + }, [steeredDetail]); const composerQueueItems = useMemo(() => { type QueueItem = { @@ -571,13 +530,6 @@ export function useThreadComposerState() { runtimeMode, interactionMode, sendEntersQueue, - // Lazy-loaded older pages + the live window — the full loaded activity set. - // Request derivations must run over this (not the windowed live set alone) - // so prompts pulled in by scroll-up still surface, matching web. - mergedActivities, - hasMoreOlderActivities, - loadingOlderActivities, - onLoadOlderActivities, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 7e0ca2fb92b..5327b3a5cf1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -75,6 +75,10 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -385,6 +389,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd, { allowBare: true }).pipe( Effect.andThen(git.fetchRemote(input)), ), + remoteExists: (input) => + ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + Effect.andThen(git.remoteExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, { allowBare: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f0aa2872951..fabdf91d3a5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1562,6 +1562,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(settledRows, [ { state: "completed", completedAt: "2026-01-01T00:01:00.000Z" }, ]); + + const threadRows = yield* sql<{ readonly latestTurnId: string | null }>` + SELECT latest_turn_id AS "latestTurnId" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ latestTurnId: turnId }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 38776a9ebda..c698cf20dba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -931,6 +931,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti event.payload.session.activeTurnId ?? existingRow.value.latestTurnId; yield* projectionThreadRepository.upsert({ ...existingRow.value, + // activeTurnId describes current work; a terminal session must not erase history. latestTurnId: nextLatestTurnId, updatedAt: event.occurredAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 03edc5c6330..b3b2d5ac192 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -20,6 +20,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -2410,3 +2411,407 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) => { + // A thread shaped like real fan-out usage: user turns interleaved with + // subagent turns (no user pending message), plus a turnless straggler user + // message and a turnless activity anchored between turns. + // + // row turn pending msg anchor (requested_at) + // 1 turn-1 user-msg-1 T00 + // 2 turn-2 (subagent) T01 + // 3 turn-3 (subagent) T02 + // 4 turn-4 user-msg-4 T03 + // 5 turn-5 user-msg-5 T04 + // + // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) + // and a turnless activity at T03.6 — both belong to the page containing T03+. + const seedFanOutThread = Effect.fnUntraced(function* () { + const sql = yield* SqlClient.SqlClient; + + // Tests in this block share one in-memory database; reset before seeding. + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-w', 'Windowed', '/tmp/project-w', '[]', + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + latest_turn_id, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at + ) + VALUES ('thread-w', 'project-w', 'Windowed thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) + `; + + const turns: ReadonlyArray<{ + turn: string; + pendingMessage: string | null; + at: string; + }> = [ + { turn: "turn-1", pendingMessage: "user-msg-1", at: "2026-03-01T00:00:00.000Z" }, + { turn: "turn-2", pendingMessage: null, at: "2026-03-01T00:01:00.000Z" }, + { turn: "turn-3", pendingMessage: null, at: "2026-03-01T00:02:00.000Z" }, + { turn: "turn-4", pendingMessage: "user-msg-4", at: "2026-03-01T00:03:00.000Z" }, + { turn: "turn-5", pendingMessage: "user-msg-5", at: "2026-03-01T00:04:00.000Z" }, + ]; + for (const { turn, pendingMessage, at } of turns) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, completed_at, + checkpoint_files_json + ) + VALUES ('thread-w', ${turn}, ${pendingMessage}, 'completed', ${at}, ${at}, ${at}, '[]') + `; + if (pendingMessage !== null) { + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${pendingMessage}, 'thread-w', NULL, 'user', ${"prompt for " + turn}, 0, ${at}, ${at}) + `; + } + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${turn + "-reply"}, 'thread-w', ${turn}, 'assistant', ${"reply from " + turn}, 0, ${at}, ${at}) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES (${turn + "-activity"}, 'thread-w', ${turn}, 'tool', 'tool.completed', + 'ran tool', '{"ok":true}', ${at}) + `; + } + + // Straggler user message sent while turn-4 ran: turn_id NULL and not any + // turn's pending_message_id. + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('user-msg-straggler', 'thread-w', NULL, 'user', 'while you are at it', + 0, '2026-03-01T00:03:30.000Z', '2026-03-01T00:03:30.000Z') + `; + // Turnless activity in the same time range. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES ('turnless-activity', 'thread-w', NULL, 'info', 'context-window.updated', + 'usage', '{"usedTokens":1}', '2026-03-01T00:03:36.000Z') + `; + + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 42, '2026-03-01T00:00:10.000Z') + `; + } + }); + + const threadW = ThreadId.make("thread-w"); + const messageIds = (snapshot: { thread: { messages: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.messages.map((message) => message.id).toSorted(); + const activityIds = (snapshot: { thread: { activities: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.activities.map((activity) => activity.id).toSorted(); + + it.effect("returns the full thread with no page metadata when no window is requested", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page, undefined); + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.snapshotSequence, 42); + } + }), + ); + + it.effect("windows to the last N user-anchored turns with subagent turns riding along", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 2 walks back: turn-5 (user), turn-4 (user) -> window is + // rows 4..5. Subagent turns 2-3 are older than the 2nd user turn and + // stay out; the straggler message and turnless activity (T03.5/T03.6, + // after turn-4's anchor) ride along. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), [ + "turn-4-reply", + "turn-5-reply", + "user-msg-4", + "user-msg-5", + "user-msg-straggler", + ]); + assert.deepEqual(activityIds(snapshot.value), [ + "turn-4-activity", + "turn-5-activity", + "turnless-activity", + ]); + assert.equal(snapshot.value.page?.hasMore, true); + assert.notEqual(snapshot.value.page?.beforeCursor, null); + assert.equal(snapshot.value.page?.snapshotSequence, 42); + } + }), + ); + + it.effect("subagent turns between user turns ride along inside the window", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 3 reaches user turn-1, dragging subagent turns 2-3 along: + // the full thread, so no further pages. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 3 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("cursors survive a projection rewrite that reassigns turn row ids", () => + Effect.gen(function* () { + // The revert projector (and any projection rebuild) deletes and + // re-upserts projection_turns, assigning fresh autoincrement row ids. + // The keyset cursor is derived from event content, so a page cursor + // minted before the rewrite must keep working after it. + yield* seedFanOutThread(); + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + if (cursor === null || cursor === undefined) return; + + // Simulate the rewrite: delete and re-insert every turn row with the + // same content, which reassigns all row ids. + const turnRows = yield* sql` + SELECT thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + FROM projection_turns WHERE thread_id = 'thread-w' ORDER BY row_id + `; + yield* sql`DELETE FROM projection_turns WHERE thread_id = 'thread-w'`; + for (const row of turnRows) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + ) + VALUES (${row.thread_id as string}, ${row.turn_id as string}, + ${row.pending_message_id as string | null}, ${row.state as string}, + ${row.requested_at as string}, ${row.started_at as string}, + ${row.completed_at as string}, ${row.checkpoint_files_json as string}) + `; + } + + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + // Identical older slice to what the pre-rewrite cursor would return. + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + } + }), + ); + + it.effect("beforeCursor returns the disjoint adjacent older slice", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + // Older page: user turn-1 plus subagent turns 2-3 riding along. Disjoint + // from the first page: no turn-4/5 rows, no straggler. + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.deepEqual(activityIds(olderPage.value), [ + "turn-1-activity", + "turn-2-activity", + "turn-3-activity", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + assert.equal(olderPage.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("a cursor for a different thread degrades to the first page", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + + const foreign = encodeThreadDetailPageCursor({ + threadId: ThreadId.make("thread-other"), + beforeAnchorAt: "2026-03-01T00:01:00.000Z", + beforeTurnId: "turn-2", + }); + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: foreign, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), messageIds(firstPage.value)); + } + }), + ); + + it.effect("a malformed cursor degrades to the first page instead of failing", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: "not-a-cursor", + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page?.hasMore, true); + assert.equal(snapshot.value.thread.messages.length, 5); + } + }), + ); + + it.effect("windows never split below the raw-turn ceiling boundary contiguously", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // Page repeatedly with turnLimit 1 and assert the union of all pages is + // exactly the full thread with no duplicates (disjointness + coverage). + const seenMessages: string[] = []; + const seenActivities: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + ...(cursor !== undefined ? { beforeCursor: cursor } : {}), + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag !== "Some") return; + seenMessages.push(...snapshot.value.thread.messages.map((message) => message.id)); + seenActivities.push(...snapshot.value.thread.activities.map((activity) => activity.id)); + const next = snapshot.value.page?.beforeCursor; + if (next === null || next === undefined) break; + cursor = next; + } + assert.equal(new Set(seenMessages).size, seenMessages.length); + assert.equal(new Set(seenActivities).size, seenActivities.length); + assert.equal(seenMessages.length, 9); + assert.equal(seenActivities.length, 6); + }), + ); + + it.effect("a thread with no turns returns its content unwindowed on the first page", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-e', 'Empty', '/tmp/project-e', '[]', + '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at + ) + VALUES ('thread-e', 'project-e', 'Turnless thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 0, 0, 0, '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('pre-turn-msg', 'thread-e', NULL, 'user', 'first prompt', 0, + '2026-03-02T00:00:01.000Z', '2026-03-02T00:00:01.000Z') + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 7, '2026-03-02T00:00:01.000Z') + `; + } + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(ThreadId.make("thread-e"), { + turnLimit: 5, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), ["pre-turn-msg"]); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fda437cae5c..9058ca22e43 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -56,6 +56,10 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionQueuedMessage } from "../../persistence/Services/ProjectionQueuedMessages.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "../threadDetailCursor.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -148,11 +152,42 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); - +// Windowed reads order turns by the stable keyset (anchor, turn key), where +// anchor is requested_at and turn key is +// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the +// revert projector's row-id rewrite and full projection rebuilds. +const ThreadTurnWindowLookupInput = Schema.Struct({ + threadId: ThreadId, + // Exclusive keyset upper bound. Sentinels "~"/"" mean unbounded ("~" sorts + // after every ISO timestamp). + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, + userTurnLimit: Schema.Number, + maxRawTurns: Schema.Number, +}); +const ProjectionTurnWindowRowSchema = Schema.Struct({ + // The turn's timeline anchor, used to bound rows that have no turn linkage + // (user messages and turnless activities) to the same page window. + anchorAt: Schema.String, + turnKey: Schema.String, +}); +const ThreadTurnRangeLookupInput = Schema.Struct({ + threadId: ThreadId, + // Turn-linked rows are bounded by the keyset range [min, before) over + // (anchor, turn key); turnless rows by the matching [minAnchorAt, + // beforeAnchorAt) time range. Unbounded ends use sentinels: "" for the + // lower bound, "~" (sorts after ISO dates) for the upper bound. + minAnchorAt: Schema.String, + minTurnKey: Schema.String, + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, +}); /** * Maximum number of most-recent activities loaded into a thread-detail snapshot. * Bounds peak memory when opening a long-lived thread; older activities are - * fetched on demand (lazy-load, planned) and live ones stream in via events. + * fetched on demand and live ones stream in via events. Retained for the + * getThreadActivities RPC, which deployed mobile clients still call; current + * clients use the windowed snapshot loader instead. */ const THREAD_DETAIL_ACTIVITY_WINDOW = 500; @@ -173,6 +208,7 @@ const ThreadActivitiesBeforeActivityInput = Schema.Struct({ beforeActivityId: EventId, limit: NonNegativeInt, }); + const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -1336,6 +1372,198 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Resolves a page of recent turns for a windowed thread detail read. Walks + // back from the exclusive (beforeAnchorAt, beforeTurnKey) keyset boundary + // (sentinels "~"/"" mean unbounded, i.e. the first page) until it has seen + // `userTurnLimit` user-anchored turns — turns whose pending message is a + // user message; subagent/fan-out turns between them ride along — or hits the + // `maxRawTurns` ceiling that bounds pathological fan-out. The `candidates` + // CTE applies the keyset bound and LIMIT before the window functions run; + // its ORDER BY uses raw columns so the migration-037 + // (thread_id, requested_at, turn_id) index serves both range and order with + // no temp B-tree — the scan is genuinely bounded by the LIMIT. (Raw + // turn_id DESC places NULLs exactly where COALESCE-to-'' would, below every + // real id.) The caller derives the continuation cursor from the oldest + // returned row. + // Highest thread-DETAIL event sequence for this thread that the projection + // has applied (bounded by the global snapshot sequence read in the same + // transaction). This is the thread-scoped watermark a windowed page carries + // so clients can defer merging until their live subscription has caught up; + // the global sequence is not waitable per-thread. The event_type filter + // must match ws.ts's isThreadDetailEvent exactly: the subscription only + // delivers these types, so a watermark counting any other event could + // never be reached by the client and would park the page forever. Served + // by the event store's (aggregate_kind, stream_id, sequence) index. + const getThreadEventWatermarkRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, maxSequence: Schema.Number }), + Result: Schema.Struct({ threadSequence: Schema.NullOr(Schema.Number) }), + execute: ({ threadId, maxSequence }) => + sql` + SELECT MAX(sequence) AS "threadSequence" + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${threadId} + AND sequence <= ${maxSequence} + AND event_type IN ( + 'thread.message-sent', + 'thread.proposed-plan-upserted', + 'thread.activity-appended', + 'thread.turn-diff-completed', + 'thread.reverted', + 'thread.session-set' + ) + `, + }); + + const listTurnWindowRows = SqlSchema.findAll({ + Request: ThreadTurnWindowLookupInput, + Result: ProjectionTurnWindowRowSchema, + execute: ({ threadId, beforeAnchorAt, beforeTurnKey, userTurnLimit, maxRawTurns }) => + sql` + WITH candidates AS ( + SELECT + turns.requested_at AS anchor_at, + COALESCE(turns.turn_id, '') AS turn_key, + turns.pending_message_id + FROM projection_turns AS turns + WHERE turns.thread_id = ${threadId} + AND ( + turns.requested_at < ${beforeAnchorAt} + OR ( + turns.requested_at = ${beforeAnchorAt} + AND COALESCE(turns.turn_id, '') < ${beforeTurnKey} + ) + ) + ORDER BY turns.requested_at DESC, turns.turn_id DESC + LIMIT ${maxRawTurns} + ), + walked AS ( + SELECT + candidates.anchor_at, + candidates.turn_key, + CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END AS is_user_turn, + SUM(CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END) OVER ( + ORDER BY candidates.anchor_at DESC, candidates.turn_key DESC + ) AS user_turns_seen + FROM candidates + LEFT JOIN projection_thread_messages AS messages + ON messages.message_id = candidates.pending_message_id + ) + SELECT + anchor_at AS "anchorAt", + turn_key AS "turnKey" + FROM walked + WHERE user_turns_seen < ${userTurnLimit} + OR (user_turns_seen = ${userTurnLimit} AND is_user_turn = 1) + ORDER BY anchor_at ASC, turn_key ASC + `, + }); + + // Windowed variants of the two heavy collections. Turn-linked rows are + // bounded by the page's (anchor, turn key) keyset range over + // projection_turns; rows with no turn linkage (user messages always, and + // turnless activities like pre-turn context-window updates) are bounded by + // the matching turn-anchor time range so they land on the same page as the + // turns around them. Proposed plans and checkpoints stay unwindowed: they + // are metadata-scale. + const listThreadMessageRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + source_json AS "source", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2543,7 +2771,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + // Contiguous turn range bounding a windowed detail read; undefined loads the + // full thread. Resolved from a window request inside the snapshot + // transaction (see getThreadDetailSnapshot). + interface ThreadDetailBounds { + readonly minAnchorAt: string; + readonly minTurnKey: string; + readonly beforeAnchorAt: string; + readonly beforeTurnKey: string; + } + + const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => Effect.gen(function* () { const [ threadRow, @@ -2564,7 +2802,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadMessageRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadMessageRowsByThread({ threadId }) + : listThreadMessageRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", @@ -2596,7 +2837,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadActivityRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", @@ -2678,14 +2922,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } : null, proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - // The query fetches WINDOW+1 ascending rows; if it returned the extra - // one, older activities exist beyond the window — drop that oldest row - // and flag it so clients can lazy-load older history. - activities: (activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW + // Legacy unbounded snapshots fetch WINDOW+1 ascending rows; if the + // extra one came back, older activities exist beyond the window — drop + // that oldest row and flag it so deployed clients can lazy-load older + // history via the compat activities-page RPC. Turn-windowed snapshots + // are already bounded by their turn range and must not be truncated: + // rows dropped here would be unreachable, because loading earlier + // turns fetches earlier windows, never the middle of this one. + activities: (bounds === undefined && activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW ? activityRows.slice(activityRows.length - THREAD_DETAIL_ACTIVITY_WINDOW) : activityRows ).map(mapThreadActivityRow), - hasMoreActivities: activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW, + hasMoreActivities: + bounds === undefined && activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2714,23 +2963,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + getThreadDetailByIdBounded(threadId, undefined); + + // Bounds pathological fan-out: one user turn that spawned hundreds of + // subagent turns still pages in bounded chunks, at the cost of splitting the + // fan-out group across pages (the cursor continues the same group). Also + // structurally bounds the window scan via the candidates CTE's LIMIT. + const THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE = 150; + // Sentinels for unbounded keyset ends; "~" sorts after any ISO timestamp. + const ANCHOR_UNBOUNDED = "~"; + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( threadId, + window, ) => // Read the thread detail and the snapshot sequence within a single // transaction so the sequence is consistent with the returned state; a // projector update landing between two separate reads could otherwise return // a sequence ahead of the thread detail, causing the client to resume from - // too far and drop events. + // too far and drop events. Window resolution runs inside the same + // transaction so the page boundary is consistent with the returned rows. sql .withTransaction( Effect.gen(function* () { - const thread = yield* getThreadDetailById(threadId); + if (window?.turnLimit === undefined) { + const thread = yield* getThreadDetailById(threadId); + if (Option.isNone(thread)) { + return Option.none(); + } + const { snapshotSequence } = yield* getSnapshotSequence(); + return Option.some({ snapshotSequence, thread: thread.value }); + } + + // A malformed or foreign-thread cursor falls back to the first page + // rather than failing: the client's stale cursor after a revert or + // reconnect should degrade to "reload recent history", not error. + const decodedCursor = + window.beforeCursor === undefined + ? null + : decodeThreadDetailPageCursor(window.beforeCursor); + const cursor = decodedCursor?.threadId === threadId ? decodedCursor : null; + + const windowRows = yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + userTurnLimit: window.turnLimit, + maxRawTurns: THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:decodeRows", + ), + ), + ); + + const oldest = windowRows[0]; + // An empty window (no turns before the cursor, or a thread with no + // turns at all) still returns thread metadata with empty collections + // for turn-linked rows; turnless rows are bounded to the same empty + // range. The first page of a turnless thread stays unwindowed so + // pre-turn content (e.g. a just-created thread) is not hidden. + const bounds: ThreadDetailBounds | undefined = + oldest === undefined && cursor === null + ? undefined + : { + minAnchorAt: oldest?.anchorAt ?? "", + minTurnKey: oldest?.turnKey ?? "", + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + }; + // Empty window behind a cursor: nothing older remains. + const emptyBounds = + oldest === undefined && cursor !== null + ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } + : undefined; + + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); if (Option.isNone(thread)) { return Option.none(); } + + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; + const { snapshotSequence } = yield* getSnapshotSequence(); - return Option.some({ snapshotSequence, thread: thread.value }); + const watermarkRow = yield* getThreadEventWatermarkRow({ + threadId, + maxSequence: snapshotSequence, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:decodeRow", + ), + ), + ); + const threadSequence = Option.match(watermarkRow, { + onNone: () => 0, + onSome: (row) => row.threadSequence ?? 0, + }); + return Option.some({ + snapshotSequence, + thread: thread.value, + page: { + beforeCursor: + hasMore && oldest !== undefined + ? encodeThreadDetailPageCursor({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnId: oldest.turnKey, + }) + : null, + hasMore, + snapshotSequence, + threadSequence, + }, + }); }), ) .pipe( diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 26bb68d787c..b195ee78474 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -20,6 +20,7 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, ThreadId, @@ -194,9 +195,16 @@ export interface ProjectionSnapshotQueryShape { * sequence in one consistent transaction, so the returned `snapshotSequence` * exactly matches the state reflected in `thread` (no interleaving projector * update between the two reads). + * + * When `window` is provided, the thread's messages, activities, proposed + * plans, and checkpoints are bounded to a page of recent turns and the + * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). + * Without a window the full thread is returned with no `page` field — + * pagination is strictly opt-in. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, + window?: OrchestrationThreadDetailWindow, ) => Effect.Effect, ProjectionRepositoryError>; /** diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 0ce742dadbe..ead58a1b967 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -92,7 +92,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( // the only path that heals dropped ACP updates). yield* grokTranscriptResync.resyncThread(args.params.threadId); const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(args.params.threadId) + .getThreadDetailSnapshot( + args.params.threadId, + args.payload.turnLimit === undefined + ? undefined + : { + turnLimit: args.payload.turnLimit, + ...(args.payload.beforeCursor !== undefined + ? { beforeCursor: args.payload.beforeCursor } + : {}), + }, + ) .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), diff --git a/apps/server/src/orchestration/threadDetailCursor.test.ts b/apps/server/src/orchestration/threadDetailCursor.test.ts new file mode 100644 index 00000000000..434d83e86b1 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.test.ts @@ -0,0 +1,44 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "./threadDetailCursor.ts"; + +describe("threadDetailCursor", () => { + it("round-trips a cursor", () => { + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "2026-08-01T00:00:00.000Z", + beforeTurnId: "turn-9", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("round-trips empty boundary values", () => { + // The anchor is COALESCE(requested_at, started_at, '') and the turn key + // is COALESCE(turn_id, ''), so a server-minted cursor can legitimately + // carry empty strings; rejecting them would degrade a valid cursor to a + // first-page request that repeats recent history (review finding). + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "", + beforeTurnId: "", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("rejects malformed input", () => { + expect(decodeThreadDetailPageCursor("not-base64-json")).toBeNull(); + expect(decodeThreadDetailPageCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); + expect( + decodeThreadDetailPageCursor(Buffer.from(JSON.stringify({ t: "" })).toString("base64url")), + ).toBeNull(); + expect( + decodeThreadDetailPageCursor( + Buffer.from(JSON.stringify({ t: "thread-1", a: 5, i: "x" })).toString("base64url"), + ), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/threadDetailCursor.ts b/apps/server/src/orchestration/threadDetailCursor.ts new file mode 100644 index 00000000000..a7dcf231ee6 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.ts @@ -0,0 +1,62 @@ +import type { ThreadId } from "@t3tools/contracts"; + +/** + * Opaque, exclusive cursor for windowed thread detail reads. Encodes the thread + * id and the keyset boundary of an already-delivered page: the boundary turn's + * anchor timestamp (`COALESCE(requested_at, started_at, '')`) and turn id. + * Passing it back requests the adjacent disjoint slice of strictly older turns + * under `(anchor, turn_id)` ordering. + * + * The boundary is deliberately NOT a `projection_turns.row_id`: row ids are + * rewritten by the revert projector (delete + re-upsert) and by projection + * rebuilds, which would silently invalidate every persisted cursor with no + * event emitted. The (anchor, turnId) pair is derived from event content, so + * cursors survive both and no client-side refresh machinery is needed. The + * anchor doubles as the time bound for rows with no turn linkage (straggler + * user messages, turnless activities). The thread id is embedded so a cursor + * can never be replayed against a different thread. Clients must treat the + * string as opaque. + */ +export interface ThreadDetailPageCursor { + readonly threadId: ThreadId; + readonly beforeAnchorAt: string; + /** Boundary turn id; "" for the rare turn row with a null turn_id. */ + readonly beforeTurnId: string; +} + +export function encodeThreadDetailPageCursor(cursor: ThreadDetailPageCursor): string { + return Buffer.from( + JSON.stringify({ t: cursor.threadId, a: cursor.beforeAnchorAt, i: cursor.beforeTurnId }), + ).toString("base64url"); +} + +/** + * Returns null for anything that is not a well-formed cursor. Callers degrade + * a malformed or foreign-thread cursor to a first-page request. + */ +export function decodeThreadDetailPageCursor(encoded: string): ThreadDetailPageCursor | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object") { + return null; + } + const record = parsed as Record; + if (typeof record.t !== "string" || record.t.length === 0) { + return null; + } + // Empty strings are valid boundary values, not malformed input: the anchor + // is COALESCE(requested_at, started_at, ''), so a boundary turn with no + // timestamps encodes a: "" (and sorts before every real anchor, correctly + // ending the walk); the turn key is "" for a null turn_id. + if (typeof record.a !== "string") { + return null; + } + if (typeof record.i !== "string") { + return null; + } + return { threadId: record.t as ThreadId, beforeAnchorAt: record.a, beforeTurnId: record.i }; +} diff --git a/apps/server/src/persistence/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index a0a3ca41c3c..c02a67a2ca1 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -7,9 +7,10 @@ import { migrationManifest } from "./Migrations.ts"; describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); - assert.deepStrictEqual(migrationManifest.slice(-2), [ + assert.deepStrictEqual(migrationManifest.slice(-3), [ [35, "ProjectionThreadTitleRegeneration"], [36, "ProjectionThreadsPinned"], + [37, "ProjectionTurnsKeysetIndex"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 04c547c0488..1f12cb89361 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -52,6 +52,7 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; +import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; /** * Migration loader with all migrations defined inline. @@ -100,6 +101,7 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], + [37, "ProjectionTurnsKeysetIndex", Migration0037], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts new file mode 100644 index 00000000000..6b1ee7c0304 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Composite index for windowed thread detail reads. Pagination orders turns by + * the stable keyset (requested_at, turn_id); the pre-existing + * (thread_id, requested_at) index cannot serve the tiebreak order, forcing a + * temp B-tree over all of a thread's turns before the page LIMIT applies. + * With this index the candidates scan is genuinely bounded by the page size. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_keyset + ON projection_turns(thread_id, requested_at, turn_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index 934eaaca79f..51fe300b9b5 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -82,9 +82,10 @@ layer("b18 desktop migration namespace repair", (it) => { readonly migration_id: number; readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; - assert.deepStrictEqual(upstreamMigrations.slice(-2), [ + assert.deepStrictEqual(upstreamMigrations.slice(-3), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index 8fda094dd32..ac8df0c60af 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -38,9 +38,10 @@ layer("fork migration namespace for a repaired database", (it) => { const backup = yield* sql` SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-2), [ + assert.deepStrictEqual(upstream.slice(-3), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index d92a4ac0ec1..4236c286b02 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -71,9 +71,10 @@ layer("smart migration namespace repair", (it) => { const upstream = yield* sql` SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-2), [ + assert.deepStrictEqual(upstream.slice(-3), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index 42b8f4d63e8..799d0e5d3a0 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -28,11 +28,12 @@ layer("t3vm migration namespace repair", (it) => { const upstream = yield* sql` SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-4), [ + assert.deepStrictEqual(upstream.slice(-5), [ { migration_id: 33, name: "ProjectionThreadsSettled" }, { migration_id: 34, name: "ProjectionThreadsSnoozed" }, { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e69d51c1747..06910dc2019 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1490,6 +1490,67 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // Exact shape the CLI emits when Stop lands mid-tool-call: is_error + // is true and the only error is internal diagnostic telemetry. + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + stop_reason: "tool_use", + terminal_reason: "aborted_tools", + session_id: "sdk-session-abort-tools", + uuid: "result-abort-tools", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "interrupted"); + assert.equal(turnCompleted.payload.errorMessage, undefined); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("interruptTurn stops every live task before interrupting the turn", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2061,6 +2122,24 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "roster", }, + { + type: "system", + subtype: "vcs_state_changed", + kind: "push", + cwd: "/tmp/worktree", + session_id: "session", + uuid: "vcs", + }, + { + type: "system", + subtype: "code_change_published", + provider: "github", + url: "https://github.com/pingdotgg/t3code/pull/1", + repo: "pingdotgg/t3code", + identifier: "1", + session_id: "session", + uuid: "ccp", + }, { type: "system", subtype: "task_updated", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e0c950062a5..011b18c3e79 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -350,7 +350,29 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } +/** + * First user-facing error from a non-success result. "[ede_diagnostic] ..." + * entries are CLI-internal telemetry (the CLI hides them from its own UI too), + * so they must never become the error banner. + */ +function resultUserFacingError(result: SDKResultMessage): string | undefined { + if (result.subtype === "success" || !Array.isArray(result.errors)) { + return undefined; + } + return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); +} + function isInterruptedResult(result: SDKResultMessage): boolean { + // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields + // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and + // is_error: true), interrupting mid-stream yields "aborted_streaming". + if ( + result.terminal_reason === "aborted_tools" || + result.terminal_reason === "aborted_streaming" + ) { + return true; + } + const errors = resultErrorsText(result); if (errors.includes("interrupt")) { return true; @@ -2922,7 +2944,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const errorMessage = resultUserFacingError(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -3037,9 +3059,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // error rows in client work logs. `background_tasks_changed` is a roster // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. - if ((message.subtype as string) === "background_tasks_changed") { - return; + // request is the reconciliation source. `vcs_state_changed` + // ({kind: commit|push|rebase}) and `code_change_published` + // ({provider, url, repo}) are informational CLI notices; the work log + // already shows the underlying git/gh tool calls. + switch (message.subtype as string) { + case "background_tasks_changed": + case "vcs_state_changed": + case "code_change_published": + return; } switch (message.subtype) { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 509a44453aa..cc3f6c3f489 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7436,6 +7436,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-exists"); + return true; + }), + ); const fetchRemote = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -7490,6 +7497,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + remoteExists, fetchRemote, resolveRemoteTrackingCommit, createWorktree, @@ -7593,6 +7601,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fallbackRemoteName: "origin", }); assert.deepEqual(bootstrapGitOperations, [ + "remote-exists", "fetch", "resolve-remote-commit", "create-worktree", @@ -7630,6 +7639,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "falls back to the local base branch when startFromOrigin is set but no origin remote exists", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + // The fork's bootstrap waits for the worktree to appear in the + // projection before starting the provider; satisfy it the same way + // the neighbouring bootstrap tests do. + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath: "/tmp/bootstrap-worktree", + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("checks out the base branch directly when bootstrap reuses it", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 32af3048435..5a28b290ac1 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -170,6 +170,11 @@ export interface GitFetchRemoteInput { remoteName: string; } +export interface GitRemoteExistsInput { + cwd: string; + remoteName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -245,6 +250,7 @@ export class GitVcsDriver extends Context.Service< readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a89cd3e8dfe..5a9082e8096 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1313,11 +1313,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)); - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.exitCode === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), @@ -3208,6 +3211,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8091e194658..6fec3836ae5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1122,7 +1122,17 @@ const makeWsRpcLayer = ( ? deriveLocalBranchNameFromRemoteRef(prepareWorktree.baseBranch) : undefined; worktreeBaseRefName = undefined; - } else if (prepareWorktree.startFromOrigin) { + } else if ( + prepareWorktree.startFromOrigin === true && + // "Start from origin" is a stored default; repos without an + // origin remote fall back to the local base branch instead of + // failing the whole bootstrap on `git fetch origin`. Checked + // lazily so the reuse path never touches the remote. + (yield* gitWorkflow.remoteExists({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + })) + ) { yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", @@ -1223,6 +1233,7 @@ const makeWsRpcLayer = ( settings, shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, + threadSnapshotPagination: true, }; }); @@ -1635,7 +1646,14 @@ const makeWsRpcLayer = ( } const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(input.threadId) + .getThreadDetailSnapshot( + input.threadId, + // Windowing the fallback snapshot is opt-in per subscription: + // clients that don't send turnLimit (including all + // pre-pagination clients) get the full thread, since they + // have no way to load older pages. + input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit }, + ) .pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ab198db86d2..51767505763 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -434,6 +435,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 443e56674a7..b09c6b6958a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -55,6 +55,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 773a3811fdf..4876df16504 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -46,6 +46,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onWorkspaceTargetChange: (target: WorkspaceTarget) => void; effectiveEnvModeOverride?: EnvMode; @@ -319,6 +320,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onWorkspaceTargetChange, effectiveEnvModeOverride, @@ -416,7 +418,7 @@ export const BranchToolbar = memo(function BranchToolbar({ data-compact={labelsOverflow ? "" : undefined} className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1" > - {isMobile ? ( + {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null} ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4883779bc42..68d94415281 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,10 +70,6 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - useOlderThreadActivities, - type OlderActivitiesCursor, -} from "@t3tools/client-runtime/state/older-thread-activities"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; @@ -241,7 +237,11 @@ import { } from "../state/server"; import { orchestrationEnvironment } from "../state/orchestration"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -267,6 +267,8 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, type WorkspaceTarget, } from "./BranchToolbar.logic"; import { @@ -291,6 +293,7 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, + buildLoadingThreadFromShell, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -1279,8 +1282,34 @@ function ChatViewContent(props: ChatViewProps) { ); // Always resolve the pre-allocated route ref so draft routes can promote to a // live server thread without remounting (draft hero landing). + const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); + const serverThreadShell = routeServerThreadShell; const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); - const serverThreadShell = useThreadShell(routeThreadRef); + const loadingServerThread = useMemo( + () => + threadDetailLoading && routeServerThreadShell + ? buildLoadingThreadFromShell(routeServerThreadShell) + : null, + [routeServerThreadShell, threadDetailLoading], + ); + const activeServerThread = serverThread ?? loadingServerThread; + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, + ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the @@ -1536,19 +1565,19 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. On server routes, also require the shell // so we don't treat a transient detail load as active without shell metadata. const isServerThread = - serverThread !== null && + activeServerThread !== null && (routeKind !== "server" || shouldTreatServerThreadAsActive({ hasServerThreadShell: serverThreadShell !== null, hasServerThreadDetail: true, })); const activeThread: Thread | undefined = isServerThread - ? (serverThread ?? undefined) + ? (activeServerThread ?? undefined) : localDraftThread; const threadError = isServerThread ? resolveServerThreadError({ localError: localServerError, - serverError: serverThread?.session?.lastError, + serverError: activeServerThread?.session?.lastError, dismissedServerError: dismissedServerErrorsByThreadKey[routeThreadKey], }) : localDraftError; @@ -1896,6 +1925,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -2172,45 +2209,7 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); - // ── Older-history lazy-load ──────────────────────────────────────────────── - // The detail snapshot windows activities to the most recent page (the server - // sets `hasMoreActivities` when older ones exist); older pages are fetched on - // demand (infinite scroll-up) and prepended by the shared engine. Messages - // aren't windowed server-side, so this just back-fills the older tool - // activity. - const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { - reportFailure: false, - }); - const activeThreadEnvironmentIdForActivities = activeThread?.environmentId ?? null; - const activeThreadIdForActivities = activeThread?.id ?? null; - const loadOlderActivitiesPage = useCallback( - async (cursor: OlderActivitiesCursor) => { - if (activeThreadEnvironmentIdForActivities === null || activeThreadIdForActivities === null) { - return null; - } - const result = await loadThreadActivities({ - environmentId: activeThreadEnvironmentIdForActivities, - input: { threadId: activeThreadIdForActivities, ...cursor }, - }); - // Failures stay silent on web (the "Load older history" affordance itself - // is the retry surface); returning null keeps `hasMore` for the retry. - return result._tag === "Success" ? result.value : null; - }, - [activeThreadEnvironmentIdForActivities, activeThreadIdForActivities, loadThreadActivities], - ); - const { - mergedActivities: threadActivities, - hasMoreOlder: hasMoreOlderActivities, - loadingOlder: loadingOlderActivities, - progressVersion: olderHistoryCursorVersion, - loadOlder: loadOlderActivities, - } = useOlderThreadActivities({ - threadKey: activeThread ? `${activeThread.environmentId}\u0000${activeThread.id}` : null, - liveActivities: activeThread?.activities ?? EMPTY_ACTIVITIES, - hasMoreLiveActivities: activeThread?.hasMoreActivities ?? false, - loadPage: loadOlderActivitiesPage, - }); - + const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null @@ -2730,7 +2729,11 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -6585,10 +6588,6 @@ function ChatViewContent(props: ChatViewProps) { activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} - hasMoreOlder={hasMoreOlderActivities} - loadingOlder={loadingOlderActivities} - olderHistoryCursorVersion={olderHistoryCursorVersion} - onLoadOlder={loadOlderActivities} latestTurn={activeLatestTurn} runningTurnId={ activeThread.session?.status === "running" @@ -6615,8 +6614,9 @@ function ChatViewContent(props: ChatViewProps) { maintainScrollAtEnd={maintainTimelineAtEnd} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} - hideEmptyPlaceholder={isDraftHeroState} + hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} + loadEarlier={loadEarlierTurns} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6804,6 +6804,7 @@ function ChatViewContent(props: ChatViewProps) { ()); + const performSnooze = useCallback( + async ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) { + return { status: "skipped" } as const; + } + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + return isAtomCommandInterrupted(result) + ? ({ status: "interrupted" } as const) + : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); + } + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + return { status: "success" } as const; + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + }, + [planForwardNavigation, snoozeThread], + ); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, @@ -2399,52 +2433,35 @@ export default function SidebarV2() { opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) return; - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - } finally { - snoozingThreadKeysRef.current.delete(threadKey); + return; } + if (outcome.status !== "success") return; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); })(); }, - [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat], + [attemptUnsnooze, performSnooze, timestampFormat], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); @@ -2464,16 +2481,16 @@ export default function SidebarV2() { // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); - const canSnoozeSelection = snoozableThreads.every( + const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow }), ); - const titleRegenerationThreads = snoozableThreads.filter( + const titleRegenerationThreads = selectedThreads.filter( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true, @@ -2518,12 +2535,55 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { - attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { - coSnoozingKeys, - }); - } clearSelection(); + const outcomes = await Promise.all( + selectedThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); + return { outcome, threadRef }; + }), + ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: + firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } } return; } @@ -2619,8 +2679,10 @@ export default function SidebarV2() { confirmThreadDelete, deleteThread, markThreadUnread, + performSnooze, removeFromSelection, serverConfigs, + attemptUnsnooze, updateThreadMetadata, timestampFormat, ], diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index cd03feb8262..6336be38361 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -758,36 +758,32 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Tool call failed"'); }); - it("offers a 'Load older history' control when older activity remains", async () => { + it("renders no load-earlier control when no older turns remain", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( - , + , ); - expect(markup).toContain("Load older history"); + expect(markup).not.toContain("Load earlier turns"); }); - it("shows a loading indicator while older history is being fetched", async () => { + it("renders the load-earlier header when older turns exist", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( {} }} />, ); - expect(markup).toContain("Loading older history"); - }); + expect(markup).toContain("Load earlier turns"); - it("renders no older-history control when none remains", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , + const loadingMarkup = renderToStaticMarkup( + {} }} + />, ); - expect(markup).not.toContain("older history"); + expect(loadingMarkup).toContain("Loading earlier turns"); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c53522fcd96..78eadfef519 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -76,7 +76,6 @@ import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, - resolveOlderHistoryAutoLoad, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -159,6 +158,33 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; + +// Header row shown when older turns exist beyond the loaded window. Plain +// button, no spinner animation; the label change is the loading indicator. +function TimelineLoadEarlierHeader({ + loading, + onLoadEarlier, + fade, +}: { + loading: boolean; + onLoadEarlier: () => void; + fade: boolean; +}) { + return ( +
+
+ +
+
+ ); +} const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; @@ -198,12 +224,8 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; - /** Older history beyond the live activity window can be lazy-loaded. */ - hasMoreOlder?: boolean; - loadingOlder?: boolean; - /** Increments after the older-history cursor advances or is reset. */ - olderHistoryCursorVersion?: number; - onLoadOlder?: () => void; + /** Non-null when older turns exist beyond the loaded window. */ + loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } // --------------------------------------------------------------------------- @@ -242,10 +264,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, - hasMoreOlder = false, - loadingOlder = false, - olderHistoryCursorVersion = 0, - onLoadOlder, + loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -357,19 +376,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); - const olderHistoryAutoLoadArmedRef = useRef(true); - const olderHistoryObservedProgressVersionRef = useRef(olderHistoryCursorVersion); - const requestOlderHistory = useCallback(() => { - // Disarm before both automatic and explicit requests. If a request fails, - // prop changes while the viewport remains at the start must not trigger an - // immediate retry loop; the header button still permits a deliberate retry. - olderHistoryAutoLoadArmedRef.current = false; - onLoadOlder?.(); - }, [onLoadOlder]); - useEffect(() => { - olderHistoryAutoLoadArmedRef.current = true; - olderHistoryObservedProgressVersionRef.current = olderHistoryCursorVersion; - }, [routeThreadKey, olderHistoryCursorVersion]); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -401,21 +407,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } - // Reaching the top lazy-loads older history; maintainVisibleContentPosition - // (set on the list) keeps the viewport anchored when rows prepend. - const olderHistoryDecision = resolveOlderHistoryAutoLoad({ - armed: olderHistoryAutoLoadArmedRef.current, - hasMore: hasMoreOlder, - isAtStart: state?.isAtStart ?? false, - loading: loadingOlder, - observedProgressVersion: olderHistoryObservedProgressVersionRef.current, - progressVersion: olderHistoryCursorVersion, - }); - olderHistoryAutoLoadArmedRef.current = olderHistoryDecision.armed; - olderHistoryObservedProgressVersionRef.current = olderHistoryDecision.observedProgressVersion; - if (olderHistoryDecision.shouldLoad) { - requestOlderHistory(); - } if (!state || minimapItems.length === 0) { return; } @@ -438,16 +429,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [ - listRef, - minimapItems, - minimapStripMap, - onIsAtEndChange, - hasMoreOlder, - loadingOlder, - olderHistoryCursorVersion, - requestOlderHistory, - ]); + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -479,28 +461,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [timelineViewportElement, rows.length]); - const listHeader = useMemo(() => { - if (loadingOlder) { - return ( -
- Loading older history… -
- ); - } - if (hasMoreOlder) { - return ( - - ); - } - return topFadeEnabled ? TIMELINE_LIST_FADE_HEADER : TIMELINE_LIST_HEADER; - }, [loadingOlder, hasMoreOlder, requestOlderHistory, topFadeEnabled]); - const sharedState = useMemo( () => ({ timestampFormat, @@ -565,21 +525,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (hideEmptyPlaceholder) { return null; } - // Only short-circuit to the empty state when there is genuinely nothing to - // fetch: the window can derive zero VISIBLE rows (e.g. only tool-neutral work - // entries) while older history still exists — the list must render then so - // its "Load older history" header stays reachable. - if (hasMoreOlder || loadingOlder) { - // Keep the list mounted so its older-history control remains reachable. - } else { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } + return ( +
+

+ Send a message to start the conversation. +

+
+ ); } return ( @@ -623,7 +575,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", )} - ListHeaderComponent={listHeader} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } ListFooterComponent={TIMELINE_LIST_FOOTER} />
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -206,7 +206,9 @@ export function SidebarUpdatePill() { align="start" className={ state?.channel === "nightly" && state.releaseNotes.length > 0 - ? "max-w-none text-balance" + ? // pointer-events-auto overrides the positioner's pointer-events-none so the + // release notes stay open (and scrollable) when the cursor moves into them. + "pointer-events-auto max-w-none text-balance" : undefined } side="top" diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index c5f5f22bfda..d99f6d6136a 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -54,6 +54,10 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); // renderer heap. Older v1/v2 entries fail to decode and are treated as cold. // v2 stored the snapshot sequence alongside the thread so a warm cache can // resume via `afterSequence` instead of re-downloading the full thread body. +// v3 adds windowed (paginated) snapshots carrying `page` metadata. The bump +// exists for rollback safety: a pre-pagination client would decode a windowed +// v2 record, silently drop the unknown `page` field, and treat the partial +// thread as complete forever. Older entries fail to decode → cold cache. const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(3), environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e0179865455..07d16da92f5 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -337,6 +337,7 @@ describe("environment entity projections", () => { data: Option.some(detail), status: "live", error: Option.none(), + page: Option.none(), }), ); @@ -365,6 +366,7 @@ describe("environment entity projections", () => { }), status: "live", error: Option.none(), + page: Option.none(), }), ); diff --git a/packages/client-runtime/src/state/olderThreadActivities.test.ts b/packages/client-runtime/src/state/olderThreadActivities.test.ts deleted file mode 100644 index 756a12cbfbc..00000000000 Binary files a/packages/client-runtime/src/state/olderThreadActivities.test.ts and /dev/null differ diff --git a/packages/client-runtime/src/state/olderThreadActivities.ts b/packages/client-runtime/src/state/olderThreadActivities.ts deleted file mode 100644 index 269d05159c2..00000000000 --- a/packages/client-runtime/src/state/olderThreadActivities.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; - -import type { OrchestrationThreadActivity } from "@t3tools/contracts"; - -import { liveWindowOldestActivityId, oldestActivityByChronology } from "./threadReducer.ts"; - -const EMPTY_ACTIVITIES: ReadonlyArray = []; - -/** - * Pagination cursor for a thread's older activities. Sequenced rows page by - * `beforeSequence`; legacy/unsequenced rows (the common case — `sequence` is - * absent on most real rows) page by the `(createdAt, activityId)` keyset. - */ -export type OlderActivitiesCursor = - | { readonly beforeSequence: number } - | { - readonly beforeCreatedAt: OrchestrationThreadActivity["createdAt"]; - readonly beforeActivityId: OrchestrationThreadActivity["id"]; - }; - -export interface OlderActivitiesPage { - readonly activities: ReadonlyArray; - readonly hasMore: boolean; -} - -export interface UseOlderThreadActivitiesOptions { - /** - * Identity of the thread the live window belongs to (e.g. - * `${environmentId}\0${threadId}`); null when no thread is selected. - * Changing it resets the lazy-loaded pages. - */ - readonly threadKey: string | null; - /** The server-windowed live activity set from the thread detail. */ - readonly liveActivities: ReadonlyArray; - /** The server's `hasMoreActivities` flag from the detail snapshot. */ - readonly hasMoreLiveActivities: boolean; - /** - * Fetch the page immediately older than the cursor. Resolve `null` to skip - * the page silently (a failure the caller already surfaced, or an - * interrupted command) — `hasMore` is left true so the user can retry. - * MUST be referentially stable (useCallback) for the load callback to be. - */ - readonly loadPage: (cursor: OlderActivitiesCursor) => Promise; -} - -export interface UseOlderThreadActivitiesResult { - /** Lazy-loaded older pages + the live window, oldest first. */ - readonly mergedActivities: ReadonlyArray; - /** Whether older history exists beyond everything loaded. */ - readonly hasMoreOlder: boolean; - readonly loadingOlder: boolean; - /** Increments whenever paging advances or the live window is reset. */ - readonly progressVersion: number; - /** Dispatch a load of the next older page (no-op while one is in flight). */ - readonly loadOlder: () => void; -} - -// ── Pure decision kernel (exported for unit tests) ────────────────────────── - -export interface LiveWindowShape { - readonly key: string | null; - /** Chronological-oldest activity id (an identity sentinel, not a lookup key). */ - readonly oldest: string | null; - readonly count: number; -} - -/** - * Whether the live window was RESHAPED rather than purely appended-to: a - * different thread, a re-snapshot (reconnect) that changes the window's - * chronological-oldest row, or a checkpoint revert that shrinks it. A pure - * append (same thread, same oldest, count not smaller) is NOT a reshape. - */ -export function didLiveWindowReshape(previous: LiveWindowShape, next: LiveWindowShape): boolean { - return ( - next.key !== previous.key || next.oldest !== previous.oldest || next.count < previous.count - ); -} - -/** - * The cursor for the page immediately older than `oldest`: sequenced rows page - * by `beforeSequence`; unsequenced rows (the common case) by the - * `(createdAt, activityId)` keyset. - */ -export function olderActivitiesCursorFor( - oldest: OrchestrationThreadActivity, -): OlderActivitiesCursor { - return oldest.sequence !== undefined - ? { beforeSequence: oldest.sequence } - : { beforeCreatedAt: oldest.createdAt, beforeActivityId: oldest.id }; -} - -/** - * The row the NEXT load should cursor from: the explicit cursor row already - * paged past when one exists (so an all-overlap page keeps advancing), else - * the chronologically-oldest loaded row — never index 0, which the reducer - * can fill with a newer row (unsequenced rows sort to the end). - */ -export function nextOlderActivitiesCursorRow( - pagedPast: OrchestrationThreadActivity | null, - merged: ReadonlyArray, -): OrchestrationThreadActivity | null { - return pagedPast ?? oldestActivityByChronology(merged); -} - -/** - * The page rows not already present in the loaded set (older pages + live - * window) — boundary overlap and mid-flight appends must never produce - * duplicate ids in the merged timeline. - */ -export function freshOlderActivities( - page: OlderActivitiesPage, - merged: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(merged.map((activity) => activity.id)); - return page.activities.filter((activity) => !seen.has(activity.id)); -} - -/** - * The older-history lazy-load engine, shared by every client (web ChatView, - * the mobile composer, the TUI ChatView). The thread-detail snapshot windows - * activities to the most recent page; older pages are fetched on demand and - * prepended. - * - * One implementation holds all the hardening the per-client copies kept - * drifting on: - * - reset on live-window RESHAPE, not just thread switch: a reconnect - * re-snapshot changes the window's chronological-oldest row and a checkpoint - * revert shrinks it, but a plain append does neither (the reducer re-sorts - * unsequenced rows, so index 0 is not a stable boundary — the sentinel is - * {@link liveWindowOldestActivityId}); - * - a generation guard so a load resolving after a reset can't repopulate the - * cleared state; - * - a synchronous in-flight key so scroll-triggered duplicate dispatches - * coalesce before the loading state commits; - * - an explicit advancing cursor (the oldest row paged PAST), so an - * all-overlap page keeps paging instead of dead-ending while the server - * still reports more — the server cursor is strict, so it strictly - * decreases and paging cannot loop; - * - dedup against the LATEST merged set via a ref, so a live append or a - * prior prepend settling mid-flight can't produce duplicate ids; - * - `hasMore` stays true on a failed/skipped page (the history still exists; - * scrolling back retries). - */ -export function useOlderThreadActivities( - options: UseOlderThreadActivitiesOptions, -): UseOlderThreadActivitiesResult { - const { threadKey, liveActivities, hasMoreLiveActivities, loadPage } = options; - - const [olderActivities, setOlderActivities] = useState< - ReadonlyArray - >([]); - const [olderLoaded, setOlderLoaded] = useState(false); - const [olderHasMore, setOlderHasMore] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [progressVersion, setProgressVersion] = useState(0); - - // Order-independent oldest boundary: `liveActivities[0]` shifts when the - // reducer re-sorts unsequenced rows on the first live append, which would - // otherwise make a plain append look like a window reshape. - const liveOldestActivityId = useMemo( - () => liveWindowOldestActivityId(liveActivities), - [liveActivities], - ); - const liveActivityCount = liveActivities.length; - - // Bumps on every reset so a late in-flight load can't repopulate the - // freshly-cleared state (the thread key alone doesn't change on a - // same-thread window reshape). - const generationRef = useRef(0); - // The thread key of an in-flight load — coalesces the duplicate dispatches a - // fast scroll fires before the loading state updates. - const inFlightKeyRef = useRef(null); - // The oldest row we've paged past; advances even when a page dedupes to - // nothing. Reset on reshape. - const cursorRef = useRef(null); - const windowRef = useRef({ - key: threadKey, - oldest: liveOldestActivityId, - count: liveActivityCount, - }); - - // useLayoutEffect (not useEffect) so the cleared state commits before paint: - // otherwise a thread switch renders one frame with the previous thread's - // lazy-loaded pages still merged in, flashing stale rows. - useLayoutEffect(() => { - const previous = windowRef.current; - windowRef.current = { - key: threadKey, - oldest: liveOldestActivityId, - count: liveActivityCount, - }; - if (!didLiveWindowReshape(previous, windowRef.current)) { - return; - } - generationRef.current += 1; - inFlightKeyRef.current = null; - cursorRef.current = null; - setOlderActivities([]); - setOlderLoaded(false); - setOlderHasMore(false); - setLoadingOlder(false); - setProgressVersion((current) => current + 1); - }, [threadKey, liveOldestActivityId, liveActivityCount]); - - const mergedActivities = useMemo( - () => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities), - [olderActivities, liveActivities], - ); - // Latest merged set, read inside the async load handler so dedup runs - // against current state, not the snapshot captured at dispatch time. - const mergedActivitiesRef = useRef(mergedActivities); - mergedActivitiesRef.current = mergedActivities; - - // Before any page is loaded the server flag is authoritative; afterwards - // the latest page's `hasMore` is. - const hasMoreOlder = olderLoaded ? olderHasMore : threadKey !== null && hasMoreLiveActivities; - - const loadOlder = useCallback(() => { - if (threadKey === null || !hasMoreOlder) { - return; - } - const oldest = nextOlderActivitiesCursorRow(cursorRef.current, mergedActivitiesRef.current); - if (!oldest) { - return; - } - if (inFlightKeyRef.current === threadKey) { - return; // a load for this thread is already in flight - } - const cursor = olderActivitiesCursorFor(oldest); - const generation = generationRef.current; - inFlightKeyRef.current = threadKey; - setLoadingOlder(true); - void loadPage(cursor) - .then((page) => { - // The window/thread was reset while this was in flight — drop the page - // so it can't repopulate state cleared by the reset. - if (generationRef.current !== generation) { - return; - } - if (page === null) { - // Failed or interrupted (already surfaced by the caller). Keep - // `hasMore` — the history still exists and retrying is valid. - return; - } - // Advance the cursor even when every row dedupes away — the server - // cursor is strict, so it strictly decreases and paging can't loop. - const pageOldest = page.activities[0]; - if (pageOldest) { - cursorRef.current = pageOldest; - setProgressVersion((current) => current + 1); - } - const fresh = freshOlderActivities(page, mergedActivitiesRef.current); - if (fresh.length > 0) { - setOlderActivities((previous) => [...fresh, ...previous]); - } - setOlderLoaded(true); - setOlderHasMore(page.hasMore); - }) - .finally(() => { - if (generationRef.current === generation) { - inFlightKeyRef.current = null; - setLoadingOlder(false); - } - }); - }, [threadKey, hasMoreOlder, loadPage]); - - return { - mergedActivities: threadKey === null ? EMPTY_ACTIVITIES : mergedActivities, - hasMoreOlder, - loadingOlder, - progressVersion, - loadOlder, - }; -} diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index f628fe9b659..e5b28f7ba89 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -22,6 +22,16 @@ import { SNAPSHOT_HTTP_TIMEOUT_MS } from "./snapshotHttpPolicy.ts"; * WebSocket subscription's first frame. The response is gzip-compressible by * the transport and keeps the (potentially multi-KB) snapshot off the socket. */ +/** + * Optional turn window for a snapshot fetch. Only send a window to servers + * that advertise `threadSnapshotPagination`; older servers reject unknown + * query parameters. + */ +export interface ThreadSnapshotWindow { + readonly turnLimit: number; + readonly beforeCursor?: string; +} + export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", )(function* (input: { @@ -29,6 +39,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( readonly threadId: ThreadId; readonly signer: Option.Option; readonly timeoutMs?: number; + readonly window?: ThreadSnapshotWindow; }) { const requestUrl = environmentEndpointUrl( input.prepared.httpBaseUrl, @@ -48,6 +59,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ params: { threadId: input.threadId }, + payload: { + ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), + ...(input.window?.beforeCursor !== undefined + ? { beforeCursor: input.window.beforeCursor } + : {}), + }, headers, }), ), @@ -68,6 +85,7 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + window?: ThreadSnapshotWindow, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -85,8 +103,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer< // connections work without one). const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); return ThreadSnapshotLoader.of({ - load: (prepared: PreparedConnection, threadId: ThreadId) => - fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(window !== undefined ? { window } : {}), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e925..8ba9696ec57 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -3,14 +3,38 @@ import * as Option from "effect/Option"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +/** + * Pagination state for a windowed thread. Present only when the loaded thread + * is a window (the server returned `page` metadata); absent means the thread is + * fully loaded — either the server predates pagination or the window reached + * the top. + */ +export interface EnvironmentThreadPageState { + /** Opaque exclusive cursor for the next older slice; null when fully loaded. */ + readonly beforeCursor: string | null; + readonly hasMore: boolean; + /** True while an older page fetch is in flight. */ + readonly loadingOlder: boolean; +} + export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly page: Option.Option; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), + page: Option.none(), }; + +/** Whether the thread has older turns that can be loaded with more pages. */ +export function threadHasOlderTurns(state: EnvironmentThreadState): boolean { + return Option.match(state.page, { + onNone: () => false, + onSome: (page) => page.hasMore, + }); +} diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts new file mode 100644 index 00000000000..ca178d27251 --- /dev/null +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -0,0 +1,547 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; +import { + INITIAL_THREAD_USER_TURN_LIMIT, + makeEnvironmentThreadState, + requestOlderThreadTurns, + ThreadSnapshotLoader, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; + +function message(id: string, turnId: string, createdAt: string): OrchestrationMessage { + return { + id: id as OrchestrationMessage["id"], + role: "assistant", + text: `text of ${id}`, + turnId: TurnId.make(turnId), + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +const OLDER_MESSAGE = message("message-old", "turn-1", "2026-04-01T00:00:00.000Z"); +const RECENT_MESSAGE = message("message-recent", "turn-2", "2026-04-01T01:00:00.000Z"); + +// Reverts retain turns via checkpoints with checkpointTurnCount <= the revert's +// turnCount, so both fixture turns carry one: reverting to turnCount 1 keeps +// turn-1 (the older page's turn) and discards turn-2 (the loaded window's). +function checkpoint(turnId: string, turnCount: number): OrchestrationThread["checkpoints"][number] { + return { + turnId: TurnId.make(turnId), + checkpointTurnCount: turnCount, + checkpointRef: + `checkpoint-${turnCount}` as OrchestrationThread["checkpoints"][number]["checkpointRef"], + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-04-01T01:00:00.000Z", + }; +} + +const BASE_THREAD: OrchestrationThread = { + // Fork-required fields absent from upstream's fixture: the fork added them to + // OrchestrationThread, so upstream's new test cannot construct one without them. + queuedMessages: [], + pendingTurnStart: null, + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Windowed thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [RECENT_MESSAGE], + proposedPlans: [], + activities: [], + checkpoints: [checkpoint("turn-2", 2)], + session: null, +}; + +const WINDOWED_SNAPSHOT: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: BASE_THREAD, + page: { beforeCursor: "cursor-1", hasMore: true, snapshotSequence: 10 }, +}; + +const OLDER_PAGE: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: { + ...BASE_THREAD, + messages: [OLDER_MESSAGE], + checkpoints: [checkpoint("turn-1", 1)], + }, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 10 }, +}; + +type LoaderResponse = Option.Option; + +const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: { + readonly paginationCapability?: boolean; + readonly initialResponse?: LoaderResponse; + /** Cached snapshot returned by the cache store (simulates a warm cache). */ + readonly cached?: OrchestrationThreadDetailSnapshot; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const loaderWindows = yield* Ref.make>([]); + const lastSubscribeInput = yield* Ref.make | undefined>(undefined); + const savedThreads = yield* Ref.make>([]); + // Older-page responses resolve through deferreds so tests can interleave + // live events with an in-flight page fetch. + const pendingPageResponses = yield* Queue.unbounded>(); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: Record) => + Stream.unwrap(Ref.set(lastSubscribeInput, input).pipe(Effect.as(Stream.fromQueue(inputs)))), + } as unknown as WsRpcProtocolClient; + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.succeed({ + threadSnapshotPagination: options?.paginationCapability !== false, + } as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(session), + ); + const prepared = yield* SubscriptionRef.make>( + Option.some(PREPARED), + ); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: (_prepared, _threadId, window) => + Ref.update(loaderWindows, (current) => [...current, window]).pipe( + Effect.andThen( + window?.beforeCursor === undefined + ? Effect.succeed( + options?.initialResponse ?? Option.none(), + ) + : Deferred.make().pipe( + Effect.tap((deferred) => Queue.offer(pendingPageResponses, deferred)), + Effect.flatMap(Deferred.await), + ), + ), + ), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => + Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => Queue.offer(observed, state)), + Effect.forkScoped, + ); + + const awaitState = (predicate: (state: EnvironmentThreadState) => boolean) => + Queue.take(observed).pipe(Effect.repeat({ until: predicate })); + const resolveNextPage = (response: LoaderResponse) => + Queue.take(pendingPageResponses).pipe( + Effect.flatMap((deferred) => Deferred.succeed(deferred, response)), + ); + + return { + inputs, + observed, + awaitState, + resolveNextPage, + loaderWindows, + lastSubscribeInput, + savedThreads, + threadState, + }; +}); + +const hasMessage = (state: EnvironmentThreadState, id: string): boolean => + Option.match(state.data, { + onNone: () => false, + onSome: (thread) => thread.messages.some((entry) => entry.id === id), + }); + +const titleEvent = (title: string, sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-title-${sequence}`), + sequence, + occurredAt: "2026-04-01T01:30:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:30:00.000Z", + }, + }, +}); + +// Reverting to turnCount 1 retains only turns whose checkpoint count is <= 1: +// turn-1 survives, turn-2 (the loaded window's newest turn) is discarded. +const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-revert-${sequence}`), + sequence, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.reverted", + payload: { + threadId: THREAD_ID, + turnCount: 1, + }, + }, +}); + +describe("thread pagination state", () => { + it.effect("windows the initial load when the server advertises pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: "cursor-1", + hasMore: true, + loadingOlder: false, + }); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + }), + ); + + it.effect("does not send a window to servers without the capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + paginationCapability: false, + initialResponse: Option.some({ snapshotSequence: 10, thread: BASE_THREAD }), + }); + const state = yield* harness.awaitState((value) => Option.isSome(value.data)); + expect(Option.isNone(state.page)).toBe(true); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]).toBeUndefined(); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + }), + ); + + it.effect("merges an older page below the loaded window and clears the cursor", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + const thread = Option.getOrThrow(state.data); + // Older rows land before the loaded window's rows. + expect(thread.messages.map((entry) => entry.id)).toEqual(["message-old", "message-recent"]); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: null, + hasMore: false, + loadingOlder: false, + }); + }), + ); + + it.effect("discards an in-flight older page when a revert rewrites history", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Revert lands while the page fetch is in flight and removes turn-2. + yield* Queue.offer(harness.inputs, revertEvent(11)); + yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + // The stale page was dropped: no resurrected rows, cursor unchanged. + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("discards an in-flight older page when a fresh snapshot replaces the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshot: { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Replaced thread" }, + page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, + }, + }); + yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Replaced thread", + }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + // The replacement snapshot's cursor wins over the discarded page's. + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-2"); + }), + ); + + it.effect("discards an older page read from a projection behind the loaded state", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some({ ...OLDER_PAGE, snapshotSequence: 5 })); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("a merged history page never advances the live-event dedupe sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // The page was captured at a newer projection sequence (12) than the + // loaded state (10); merging it must not swallow events 11-12. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 12, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 12 }, + }), + ); + yield* harness.awaitState((value) => hasMessage(value, "message-old")); + + // Event at sequence 11 must still apply after the merge: the revert + // discards turn-2, so the loaded window's row disappears while the + // merged older turn-1 row survives. If the merge had advanced the + // dedupe sequence to the page's 12, this event would be swallowed. + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState( + (value) => !hasMessage(value, "message-recent") && hasMessage(value, "message-old"), + ); + expect(hasMessage(state, "message-old")).toBe(true); + }), + ); + + it.effect("parks a page read ahead of the live state until events catch up", () => + Effect.gen(function* () { + // A page whose thread watermark is ahead of the loaded state may + // contain streaming content the subscription has not delivered yet + // (e.g. an out-of-window subagent turn mid-stream); merging it + // immediately and then replaying those deltas would duplicate text. + // The page parks until the live state reaches the watermark. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Page watermark 11 > loaded sequence 10: must park, not merge. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 11, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 11, threadSequence: 11 }, + }), + ); + + // A live event at sequence 11 arrives; only then does the page merge. + yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11)); + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + expect(hasMessage(state, "message-recent")).toBe(true); + expect(Option.getOrThrow(state.page).loadingOlder).toBe(false); + }), + ); + + it.effect("a revert keeps the page cursor and triggers no refresh fetch", () => + Effect.gen(function* () { + // Cursors are an (anchor, turnId) keyset derived from event content, so + // they survive the revert projector's row rewrite: the machine keeps + // the stored cursor and performs no snapshot re-fetch. The revert + // reducer's turn filtering alone handles loaded history. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + const windows = yield* Ref.get(harness.loaderWindows); + // Only the initial load hit the loader — no post-revert refresh fetch. + expect(windows.length).toBe(1); + }), + ); + + it.effect("drops a windowed cache when the server lacks the pagination capability", () => + Effect.gen(function* () { + // Resuming a windowed cache via afterSequence against a pre-pagination + // server would render only the window forever with no way to load the + // rest: the machine must discard the cache and take a full snapshot. + const fullSnapshot: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Full reload" }, + }; + const harness = yield* makeHarness({ + paginationCapability: false, + cached: WINDOWED_SNAPSHOT, + initialResponse: Option.some(fullSnapshot), + }); + + const state = yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Full reload", + }), + ); + expect(Option.isNone(state.page)).toBe(true); + // The subscription resumed from the fresh full snapshot, not the + // discarded windowed cache's watermark, and sent no window fields. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + expect(subscribeInput?.afterSequence).toBe(20); + }), + ); + + it.effect("keeps a windowed cache when the server supports pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: WINDOWED_SNAPSHOT }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + // Wait for the subscription (recorded when the WS method is invoked) + // before asserting its input. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput).pipe( + Effect.repeat({ until: (input) => input !== undefined }), + ); + expect(subscribeInput?.afterSequence).toBe(10); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 2f6883d56f7..7e846958ea7 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -137,7 +137,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly cached?: OrchestrationThread; readonly httpSnapshot?: Option.Option; readonly completionMarker?: boolean; - readonly eventBatchSize?: number; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -227,9 +226,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); - const threadState = yield* makeEnvironmentThreadState(THREAD_ID, { - eventBatchSize: options?.eventBatchSize ?? 1, - }).pipe( + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ThreadSnapshotLoader, snapshotLoader), @@ -397,84 +394,6 @@ describe("EnvironmentThreads", () => { }), ); - it.effect("applies a live burst in order with one state publication", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - cached: BASE_THREAD, - completionMarker: true, - eventBatchSize: 64, - }); - yield* awaitThreadState( - harness.observed, - (value) => value.status === "synchronizing" && Option.isSome(value.data), - ); - const publicationsBeforeBurst = yield* Ref.get(harness.statePublicationCount); - - const finalSequence = CACHED_SNAPSHOT_SEQUENCE + 63; - for (let sequence = CACHED_SNAPSHOT_SEQUENCE + 1; sequence <= finalSequence; sequence += 1) { - yield* Queue.offer( - harness.inputs, - titleUpdated( - sequence === finalSequence - ? "Final title" - : sequence === CACHED_SNAPSHOT_SEQUENCE + 1 - ? "First title" - : "Interim title", - sequence, - ), - ); - } - yield* Queue.offer(harness.inputs, synchronized()); - - const state = yield* awaitThreadState( - harness.observed, - (value) => - value.status === "live" && - Option.isSome(value.data) && - value.data.value.title === "Final title", - ); - - expect(Option.getOrThrow(state.data).title).toBe("Final title"); - expect(yield* Ref.get(harness.statePublicationCount)).toBe(publicationsBeforeBurst + 1); - }), - ); - - it.effect("persists a settled snapshot before a batched turn starts", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - cached: ACTIVE_THREAD, - eventBatchSize: 2, - }); - - yield* Queue.offer( - harness.inputs, - sessionUpdated("ready", CACHED_SNAPSHOT_SEQUENCE + 1, null), - ); - yield* Queue.offer( - harness.inputs, - sessionUpdated("running", CACHED_SNAPSHOT_SEQUENCE + 2, TurnId.make("turn-2")), - ); - yield* Queue.offer(harness.inputs, synchronized()); - - const state = yield* awaitThreadState( - harness.observed, - (value) => - value.status === "live" && - Option.isSome(value.data) && - value.data.value.session?.status === "running" && - value.data.value.session.activeTurnId === TurnId.make("turn-2"), - ); - - expect(Option.getOrThrow(state.data).session?.status).toBe("running"); - yield* TestClock.adjust("500 millis"); - yield* Effect.yieldNow; - - const saved = (yield* Ref.get(harness.savedThreads)).at(-1); - expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); - expect(saved?.thread.session?.status).toBe("ready"); - }), - ); - it.effect("reduces live events and persists the latest thread", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b2bded785a5..9d52b443cf9 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -3,16 +3,18 @@ import { type EnvironmentId as EnvironmentIdType, type OrchestrationGetSnapshotError, type OrchestrationThread, + type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -23,130 +25,99 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; -const THREAD_EVENT_BATCH_WINDOW = Duration.millis(16); -const THREAD_EVENT_BATCH_MAX_SIZE = 64; - -interface ThreadStreamBatchReduction { - readonly state: EnvironmentThreadState; - readonly lastSequence: number; - readonly awaitingCompletion: boolean; - readonly threadDeleted: boolean; - readonly reloadRequired: boolean; - readonly persistableSnapshot: OrchestrationThreadDetailSnapshot | null; +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { + return Option.isSome(data) ? "cached" : "empty"; } -export interface EnvironmentThreadStateOptions { - readonly eventBatchSize?: number; +/** + * Turn window sizes for paginated thread loads: the initial page covers the + * last 10 user-anchored turns (subagent/fan-out turns ride along), each + * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest + * observed threads stays around 100K gzipped while median threads load fully. + */ +export const INITIAL_THREAD_USER_TURN_LIMIT = 10; +export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; + +function pageStateFromSnapshot( + page: OrchestrationThreadDetailPage | undefined, +): Option.Option { + return page === undefined + ? Option.none() + : Option.some({ + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + loadingOlder: false, + }); } -function reduceThreadStreamItems( - currentState: EnvironmentThreadState, - currentSequence: number, - currentAwaitingCompletion: boolean, - items: ReadonlyArray, -): ThreadStreamBatchReduction { - let state = currentState; - let lastSequence = currentSequence; - let awaitingCompletion = currentAwaitingCompletion; - let thread = Option.getOrNull(currentState.data); - let threadDeleted = false; - let reloadRequired = false; - let persistableSnapshot: OrchestrationThreadDetailSnapshot | null = null; - - for (const item of items) { - if (item.kind === "synchronized") { - awaitingCompletion = false; - if (thread !== null && state.status !== "deleted") { - state = { - data: state.data, - status: "live", - error: Option.none(), - }; - } - continue; - } +interface ThreadOlderTurnRequestRegistry { + /** + * Registers the live state machine for a thread. Returns the deregistration + * cleanup; registration lives exactly as long as the machine's scope, and a + * successor machine for the same thread simply replaces the entry. + */ + readonly register: (key: string, handler: () => void) => () => void; + readonly request: (key: string) => boolean; +} - if (item.kind === "snapshot") { - lastSequence = item.snapshot.snapshotSequence; - thread = item.snapshot.thread; - threadDeleted = false; - persistableSnapshot = shouldPersistThread(thread) ? item.snapshot : null; - state = { - data: Option.some(thread), - status: awaitingCompletion ? "synchronizing" : "live", - error: Option.none(), +function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { + const handlers = new Map void>(); + return { + register: (key, handler) => { + handlers.set(key, handler); + return () => { + if (handlers.get(key) === handler) { + handlers.delete(key); + } }; - continue; - } - - if (item.event.sequence <= lastSequence) { - continue; - } - lastSequence = item.event.sequence; - - if (thread === null) { - if (item.event.type === "thread.deleted") { - awaitingCompletion = false; - threadDeleted = true; - persistableSnapshot = null; - state = { - data: Option.none(), - status: "deleted", - error: Option.none(), - }; + }, + request: (key) => { + const handler = handlers.get(key); + if (handler === undefined) { + return false; } - continue; - } - - const result = applyThreadDetailEvent(thread, item.event); - if (result.kind === "updated") { - thread = result.thread; - if (shouldPersistThread(thread)) { - persistableSnapshot = { snapshotSequence: lastSequence, thread }; - } - state = { - data: Option.some(thread), - status: awaitingCompletion ? "synchronizing" : "live", - error: Option.none(), - }; - } else if (result.kind === "deleted") { - awaitingCompletion = false; - thread = null; - threadDeleted = true; - persistableSnapshot = null; - state = { - data: Option.none(), - status: "deleted", - error: Option.none(), - }; - } else if (result.kind === "reload-required") { - reloadRequired = true; - } - } - - return { - state, - lastSequence, - awaitingCompletion, - threadDeleted, - reloadRequired, - persistableSnapshot, + handler(); + return true; + }, }; } -function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { - return Option.isSome(data) ? "cached" : "empty"; +const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); + +/** + * Channel from UI actions to the live per-thread state machines. The machines + * resolve it from the Effect environment (overridable in tests); the default + * instance is shared with the sync `requestOlderThreadTurns` entry point so + * the apps get working wiring without providing anything. + */ +export class ThreadOlderTurnRequests extends Context.Reference( + "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", + { defaultValue: () => defaultOlderTurnRequestRegistry }, +) {} + +/** + * Asks the live state machine for `threadId` to fetch the next older page. + * Returns false when no machine is live or no fetch was started (no cursor, + * already loading); callers render from `EnvironmentThreadState.page` and can + * treat false as "nothing to do". + */ +export function requestOlderThreadTurns( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, +): boolean { + return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId })); } function formatThreadError(cause: Cause.Cause): string { @@ -190,14 +161,12 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, - options?: EnvironmentThreadStateOptions, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const eventBatchSize = options?.eventBatchSize ?? THREAD_EVENT_BATCH_MAX_SIZE; const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( @@ -215,6 +184,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + // A cached windowed snapshot restores its page cursor so "load earlier" + // works while rendering from cache; a cached full snapshot has no page. + page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. @@ -222,7 +194,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); + // One HTTP fallback per state machine: a missing snapshot must not produce a + // fresh 404 on every 250ms socket retry (fork behaviour, covered by + // threads-sync.test.ts). const httpSnapshotLoadAttempted = yield* Ref.make(false); + // Bumped whenever loaded history may have been rewritten out from under an + // in-flight older-page fetch (snapshot replacement, revert, deletion). A + // page response captured under an older epoch is discarded, not merged. + const historyEpoch = yield* Ref.make(0); + // Serializes stream-item application against older-page staleness checks + + // merges. Without it, a revert or snapshot processed between loadOlderTurns' + // epoch check and its merge could still slip resurrected history in. + const applyLock = yield* Semaphore.make(1); + // Whether the connected server accepts windowed reads; set per subscription + // from the session config. Gates loadOlderTurns so a reconnect to a + // pre-pagination server never sends unsupported window parameters. + const paginationSupported = yield* Ref.make(false); + // An older page whose thread watermark is ahead of the live state, parked + // until the subscription catches up (see mergeOlderPage's caller). At most + // one can exist because loadOlderTurns no-ops while loadingOlder is true. + const pendingOlderPage = yield* Ref.make<{ + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly epoch: number; + } | null>(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -267,6 +261,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); + // The capability belongs to the session that advertised it. During a + // reconnect, a new prepared connection can exist before the new session's + // config arrives; leaving the old value would let loadOlderTurns send + // window parameters to a server that may not accept them (review + // finding). makeSubscribeInput re-sets it from the next session's config. + yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), @@ -284,85 +284,286 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); - const removeCachedThread = cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not remove the cached thread.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( + thread: OrchestrationThread, + // "keep" preserves the current page state (live events touch only loaded + // recent turns); a snapshot or merged page passes its own page state. + page: Option.Option | "keep", + ) { + const waiting = yield* Ref.get(awaitingCompletion); + yield* SubscriptionRef.update(state, (current) => ({ + data: Option.some(thread), + status: waiting ? ("synchronizing" as const) : ("live" as const), + error: Option.none(), + page: page === "keep" ? current.page : page, + })); + // Active threads can update many times per second and retain large tool + // payloads. The server remains the source of truth while a turn is active; + // persist once it settles so cache encoding stays off the streaming path. + if (shouldPersistThread(thread)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); + yield* Queue.offer(persistence, { + snapshotSequence, + thread, + // Persist the window boundary with the window's content so a cache + // restore can keep paging from where the loaded history ends. + ...Option.match(currentPage, { + onNone: () => ({}), + onSome: (value) => + ({ + page: { + beforeCursor: value.beforeCursor, + hasMore: value.hasMore, + snapshotSequence, + }, + }) as const, }), - ), - ), - ); + }); + } + }); // A terminal `thread-deleted` subscription failure never reaches the item // stream, so that path publishes the deleted state itself instead of going - // through the batch reducer. + // through applyItem. Callers outside applyItemLocked must hold applyLock. const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), + page: Option.none(), }); - yield* removeCachedThread; + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); }); - // Re-read the thread from the server, replacing whatever we hold. Used when an - // event cannot be reconciled against the cached transcript ("reload-required"), - // and by the manual reload action. Failures leave the current state in place — - // the caller is already in a degraded path and a live subscription may recover. - const reloadFromServer = Effect.fn("EnvironmentThreadState.reloadFromServer")(function* () { - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared)) { + // Body of applyItem, running under applyLock. + const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( + item: OrchestrationThreadStreamItem, + ) { + if (item.kind === "synchronized") { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => + Option.isSome(current.data) && current.status !== "deleted" + ? { ...current, status: "live" as const, error: Option.none() } + : current, + ); return; } - const fresh = yield* snapshotLoader - .load(prepared.value, threadId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isNone(fresh)) { + + if (item.kind === "snapshot") { + // A fresh snapshot replaces all loaded history, including older + // pages: a turn reverted while disconnected would otherwise survive + // in the preserved history with no event left to remove it. The + // epoch bump discards any older-page fetch racing this snapshot. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } - yield* SubscriptionRef.set(lastSequence, fresh.value.snapshotSequence); - yield* SubscriptionRef.set(state, { - data: Option.some(fresh.value.thread), - status: (yield* Ref.get(awaitingCompletion)) ? "synchronizing" : "live", - error: Option.none(), - }); - if (shouldPersistThread(fresh.value.thread)) { - yield* Queue.offer(persistence, fresh.value); + + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + return; + } + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(); + } + return; } + if (item.event.type === "thread.reverted") { + // A revert rewrites loaded history (whole turns disappear), so an + // older-page fetch in flight may straddle the removed range; the epoch + // bump discards it. The stored page cursor stays valid: cursors are an + // (anchor, turnId) keyset derived from event content, which survives + // the revert projector's row rewrite, so no refresh is needed — the + // revert reducer's turn filtering fully handles loaded history. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + } + const result = applyThreadDetailEvent(current.data.value, item.event); + if (result.kind === "updated") { + yield* setThread(result.thread, "keep"); + } else if (result.kind === "deleted") { + yield* setDeleted(); + } + // The event may have advanced the live state past a parked page's + // watermark; merge it as soon as that happens. + yield* tryMergePendingOlderPage(); }); - const applyItems = Effect.fn("EnvironmentThreadState.applyItems")(function* ( - items: ReadonlyArray, + // Merges a parked older page once the live state has caught up to the + // page's thread watermark, or discards it if history was rewritten + // (epoch advanced) while it waited. Must run under applyLock. + const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( + function* () { + const pending = yield* Ref.get(pendingOlderPage); + if (pending === null) { + return; + } + const epochNow = yield* Ref.get(historyEpoch); + if (epochNow !== pending.epoch) { + yield* Ref.set(pendingOlderPage, null); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + const watermark = pending.snapshot.page?.threadSequence; + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + if (watermark !== undefined && watermark > loadedSequence) { + return; + } + yield* Ref.set(pendingOlderPage, null); + yield* mergeOlderPage(pending.snapshot); + }, + ); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, ) { - const currentState = yield* SubscriptionRef.get(state); - const reduction = reduceThreadStreamItems( - currentState, - yield* SubscriptionRef.get(lastSequence), - yield* Ref.get(awaitingCompletion), - items, - ); + yield* applyLock.withPermits(1)(applyItemLocked(item)); + }); - yield* SubscriptionRef.set(lastSequence, reduction.lastSequence); - yield* Ref.set(awaitingCompletion, reduction.awaitingCompletion); - if (reduction.state !== currentState) { - yield* SubscriptionRef.set(state, reduction.state); + // Merges an older disjoint page below the currently loaded window. All four + // windowed collections prepend; identity dedupe guards the (server-bug or + // cursor-misuse) case of overlapping pages so a row never renders twice. + const mergeOlderPage = Effect.fn("EnvironmentThreadState.mergeOlderPage")(function* ( + snapshot: OrchestrationThreadDetailSnapshot, + ) { + // The merge is built inside the update callback so it composes with + // whatever thread value is current at commit time. The applyLock already + // serializes this against event application; the atomic build is defense + // in depth against future callers outside the lock. + let merged: OrchestrationThread | null = null; + yield* SubscriptionRef.update(state, (value) => { + if (Option.isNone(value.data)) { + return value; + } + const loaded = value.data.value; + const older = snapshot.thread; + const mergeById = ( + olderRows: ReadonlyArray, + loadedRows: ReadonlyArray, + ): ReadonlyArray => { + const seen = new Set(loadedRows.map((row) => row.id)); + return [...olderRows.filter((row) => !seen.has(row.id)), ...loadedRows]; + }; + const seenCheckpoints = new Set(loaded.checkpoints.map((row) => row.turnId)); + merged = { + // Thread metadata stays the loaded (newer) snapshot's; only the + // windowed collections gain rows from the older page. + ...loaded, + messages: mergeById(older.messages, loaded.messages), + activities: mergeById(older.activities, loaded.activities), + proposedPlans: mergeById(older.proposedPlans, loaded.proposedPlans), + checkpoints: [ + ...older.checkpoints.filter((row) => !seenCheckpoints.has(row.turnId)), + ...loaded.checkpoints, + ], + }; + return { + ...value, + data: Option.some(merged), + page: pageStateFromSnapshot(snapshot.page), + }; + }); + // Persist the widened window under the *loaded* watermark: the merged + // content is only known consistent with the state it merged into, not + // with the page's own (possibly newer) sequence. + if (merged !== null && shouldPersistThread(merged)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + yield* Queue.offer(persistence, { + snapshotSequence, + thread: merged, + ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }), + }); } + }); - if (reduction.threadDeleted) { - yield* removeCachedThread; + const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () { + // Gated on the connected server's capability: a reconnect to a + // pre-pagination server must never receive window parameters. + if (!(yield* Ref.get(paginationSupported))) { return; } - - if (reduction.persistableSnapshot !== null) { - yield* Queue.offer(persistence, reduction.persistableSnapshot); + const current = yield* SubscriptionRef.get(state); + const page = Option.getOrNull(current.page); + if (page === null || page.loadingOlder || !page.hasMore || page.beforeCursor === null) { + return; } - if (reduction.reloadRequired) { - yield* reloadFromServer(); + const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared)); + if (prepared === null) { + return; } + const epochAtStart = yield* Ref.get(historyEpoch); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: true })), + })); + const window: ThreadSnapshotWindow = { + turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT, + beforeCursor: page.beforeCursor, + }; + const response = yield* snapshotLoader.load(prepared, threadId, window); + // Staleness check and merge run under the same lock as stream-item + // application, so a revert/snapshot cannot land between them (TOCTOU + // review finding) — anything that rewrites history bumps the epoch + // before this permit is acquired. + yield* applyLock.withPermits(1)( + Effect.gen(function* () { + const epochNow = yield* Ref.get(historyEpoch); + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + // A page carrying a sequence older than the loaded state was read + // from a projection behind what we render; merging it could + // resurrect turns a newer snapshot or revert already removed. + const stale = + epochNow !== epochAtStart || + Option.match(response, { + onNone: () => false, + onSome: (snapshot) => snapshot.snapshotSequence < loadedSequence, + }); + if (Option.isNone(response) || stale) { + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + // A page read AHEAD of the live state may include content (e.g. + // streaming deltas of an out-of-window turn) the subscription has + // not delivered yet; merging now and then replaying those events + // would duplicate them. Park the page until the live state reaches + // the page's thread-scoped watermark; loadingOlder stays true so + // the UI shows progress and no second fetch starts. Pages from + // pre-watermark servers (threadSequence absent) merge immediately, + // preserving the old behavior. + const watermark = response.value.page?.threadSequence; + if (watermark !== undefined && watermark > loadedSequence) { + yield* Ref.set(pendingOlderPage, { + snapshot: response.value, + epoch: epochNow, + }); + return; + } + yield* mergeOlderPage(response.value); + }), + ); }); yield* SubscriptionRef.changes(supervisor.state).pipe( @@ -390,14 +591,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { - const supportsCompletionMarker = yield* session.initialConfig.pipe( - Effect.map((config) => config.threadResumeCompletionMarker === true), - Effect.orElseSucceed(() => false), + const config = yield* session.initialConfig.pipe( + Effect.orElseSucceed( + () => + ({}) as { + threadResumeCompletionMarker?: boolean; + threadSnapshotPagination?: boolean; + }, + ), ); + const supportsCompletionMarker = config.threadResumeCompletionMarker === true; + // Windowed loads are gated on the server capability: pre-pagination + // servers reject unknown query params, and a windowed WS fallback to + // such a server would silently hide history. + const supportsPagination = config.threadSnapshotPagination === true; + yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; let current = yield* SubscriptionRef.get(state); + // A windowed cache resuming against a server without pagination is a + // trap: afterSequence resume keeps only the window, and the missing + // older turns can never be loaded (the server has no cursor reads). + // Drop the window marker and treat the data as needing a full reload. + if (!supportsPagination && Option.isSome(current.page)) { + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + data: Option.none(), + status: value.status === "deleted" ? value.status : ("empty" as const), + page: Option.none(), + })); + yield* SubscriptionRef.set(lastSequence, 0); + current = yield* SubscriptionRef.get(state); + } if (Option.isNone(current.data) && current.status !== "deleted") { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( @@ -413,20 +640,20 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ), ); - // The socket subscription may retry an expected domain failure (for - // example, while a newly-created thread is still being projected). - // Do not repeat the HTTP fallback on each socket retry: a missing - // snapshot otherwise produces a new 404 every 250ms. const alreadyAttemptedHttpSnapshotLoad = yield* Ref.getAndSet( httpSnapshotLoadAttempted, true, ); - if (!alreadyAttemptedHttpSnapshotLoad) { - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); - if (Option.isSome(httpSnapshot)) { - yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); - current = yield* SubscriptionRef.get(state); - } + const httpSnapshot = alreadyAttemptedHttpSnapshotLoad + ? Option.none() + : yield* snapshotLoader.load( + prepared, + threadId, + supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined, + ); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + current = yield* SubscriptionRef.get(state); } } @@ -444,23 +671,47 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + // The WS fallback snapshot (sent when afterSequence is missing or + // the gap is too large) should be windowed the same as the HTTP + // path; without this a resume failure re-downloads the full thread. + ...(supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : {}), }; }), { // A permanently unavailable thread must not keep resubscribing: the // server can never satisfy it, and the 250ms retry would hammer the // socket until the state's idle TTL expires. + // setDeleted under applyLock: every other history rewrite (snapshot + // apply, older-page merge, delta-driven deletion) is serialized by it, + // and this path must not race a mergeOlderPage commit. onExpectedFailure: (cause) => - terminalSnapshotReason(cause) === "thread-deleted" ? setDeleted() : setStreamError(cause), + terminalSnapshotReason(cause) === "thread-deleted" + ? applyLock.withPermits(1)(setDeleted()) + : setStreamError(cause), retryExpectedFailureAfter: "250 millis", isExpectedFailureTerminal: (cause) => terminalSnapshotReason(cause) !== undefined, resubscribe: foregroundResubscriptions, }, - ).pipe( - Stream.groupedWithin(eventBatchSize, THREAD_EVENT_BATCH_WINDOW), - Stream.runForEach(applyItems), - ), + ).pipe(Stream.runForEach(applyItem)), + ); + + // Expose loadOlderTurns to UI actions through the request registry. + // Requests funnel through a sliding queue drained serially, so mashing + // "load earlier" coalesces (loadOlderTurns itself no-ops while a fetch is + // in flight). + const olderTurnRequestRegistry = yield* ThreadOlderTurnRequests; + const olderTurnRequests = yield* Queue.sliding(1); + yield* Stream.fromQueue(olderTurnRequests).pipe( + Stream.runForEach(() => loadOlderTurns()), + Effect.forkScoped, + ); + const deregister = olderTurnRequestRegistry.register( + threadKey({ environmentId, threadId }), + () => { + Queue.offerUnsafe(olderTurnRequests, undefined); + }, ); + yield* Effect.addFinalizer(() => Effect.sync(deregister)); yield* Effect.addFinalizer(() => Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( @@ -468,7 +719,23 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(current.data, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? persist({ + snapshotSequence, + thread, + ...Option.match(current.page, { + onNone: () => ({}), + onSome: (page) => + ({ + page: { + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + snapshotSequence, + }, + }) as const, + }), + }) + : Effect.void, }), ), ), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index f95fb82808a..c4fc47d60ec 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -460,6 +460,16 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +// Query-string window for windowed thread snapshots (GET payloads must encode +// to strings). Both fields optional: omitting them keeps the full-snapshot +// behavior, so pagination stays opt-in per request. +const EnvironmentOrchestrationThreadSnapshotQuery = { + turnLimit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + ), + beforeCursor: Schema.optional(TrimmedNonEmptyString), +}; + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -479,6 +489,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + payload: EnvironmentOrchestrationThreadSnapshotQuery, success: OrchestrationThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 242cb7fafc2..ef2b9794505 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -13,6 +13,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -587,12 +588,62 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * When provided, the fallback snapshot frame (sent when `afterSequence` is + * missing or the catch-up gap is too large) is windowed to the last + * `turnLimit` user-anchored turns and carries `page` metadata. Absent means + * the fallback snapshot is the full thread, preserving pre-pagination client + * behavior. Live events are unaffected either way. + */ + turnLimit: Schema.optionalKey(PositiveInt), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; +/** + * Bounds a thread detail read to a window of recent turns. `turnLimit` counts + * turns with a user pending message (subagent/fan-out turns between them ride + * along), so the window always contains the last N user prompts. `beforeCursor` + * requests the disjoint page of older turns strictly before a previously + * returned cursor. Requests without a window get the full thread; pagination is + * strictly opt-in so older clients keep today's behavior on both HTTP and the + * WebSocket fallback snapshot. + */ +export const OrchestrationThreadDetailWindow = Schema.Struct({ + turnLimit: Schema.optionalKey(PositiveInt), + beforeCursor: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type; + +/** + * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and + * exclusive: passing it back returns the adjacent disjoint slice of older + * turns. `null` means the thread is fully loaded below this page. The + * `snapshotSequence` mirrors the top-level snapshot sequence so history pages + * can be sequence-checked against live state before merging. + */ +export const OrchestrationThreadDetailPage = Schema.Struct({ + beforeCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMore: Schema.Boolean, + snapshotSequence: NonNegativeInt, + /** + * Highest event sequence applied to THIS thread at page read time. The + * global `snapshotSequence` advances with every thread's events, so a + * client cannot wait for it via its per-thread subscription; this + * thread-scoped watermark is reachable. A client merging an older page + * must first have applied live events up to it — otherwise a streaming + * turn outside the loaded window could have deltas replayed on top of + * page content that already includes them, duplicating text. + */ + threadSequence: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type; + export const OrchestrationThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, thread: OrchestrationThread, + // Present only on windowed responses. Absent on full snapshots (and from + // pre-pagination servers), which clients treat as fully loaded. + page: Schema.optional(OrchestrationThreadDetailPage), }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index fac3474c5ba..5031a279d7f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -434,6 +434,12 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Whether thread detail reads accept a turn window (`turnLimit`/ + * `beforeCursor`) and return `page` metadata. Clients must not send window + * fields to servers that don't advertise this. + */ + threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type;