diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 7b82f02d006eb..88d34c4192058 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -99,7 +99,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { } function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { - const fileCount = checkpoint.files.length; + const fileCount = checkpoint.deferredFileCount ?? checkpoint.files.length; if (checkpoint.status !== "ready") { return `Diff ${checkpoint.status}`; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6889515e75be2..9ea27d7689a61 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -44,6 +44,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { Atom, AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; +import { applyOrchestrationV2ProjectionEvent } from "@t3tools/client-runtime/state/orchestration-v2-projection"; import type { Thread, TurnDiffSummary } from "../types"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; @@ -2268,23 +2269,89 @@ describe("waitForRevertedMessage", () => { await expect(waiting).rejects.toThrow("The provider could not roll back."); }); - it("ignores a failure recorded for an earlier rollback", async () => { - vi.useFakeTimers(); + it("rewinds a paged user prompt while ignoring a failure recorded for an earlier rollback", async () => { const { state, projection } = projectionAtom(); - const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {}, 50); - const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind."); - appAtomRegistry.set(state, { - data: Option.some({ - ...projection, - thread: { - ...projection.thread, - rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." }, + const run = { + id: RunId.make("run-2"), + threadId: projection.thread.id, + ordinal: 2, + providerInstanceId: projection.thread.providerInstanceId, + modelSelection: projection.thread.modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed" as const, + requestedAt: projection.updatedAt, + startedAt: projection.updatedAt, + completedAt: projection.updatedAt, + checkpointId: null, + contextHandoffId: null, + }; + const item = { + id: TurnItemId.make("item-message-2"), + threadId: projection.thread.id, + runId: run.id, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "completed" as const, + title: null, + startedAt: projection.updatedAt, + completedAt: projection.updatedAt, + updatedAt: projection.updatedAt, + createdBy: "user" as const, + creationSource: "web" as const, + type: "user_message" as const, + messageId, + inputIntent: "turn_start" as const, + text: "second", + attachments: [], + }; + const pagedProjection = { + ...projection, + messages: [], + runs: [run], + visibleTurnItems: [ + { + position: 0, + visibility: "local" as const, + sourceThreadId: item.threadId, + sourceItemId: item.id, + item, }, - }), + ], + thread: { + ...projection.thread, + rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." }, + }, + }; + appAtomRegistry.set(state, { data: Option.some(pagedProjection) }); + let accept = () => {}; + const accepted = new Promise((resolve) => { + accept = resolve; + }); + let settled = false; + const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => { + accept(); + }).then(() => { + settled = true; + }); + await Promise.race([accepted, waiting]); + expect(settled).toBe(false); + const rewound = applyOrchestrationV2ProjectionEvent(pagedProjection, { + id: EventId.make("run-rolled-back"), + type: "run.updated", + threadId: run.threadId, + runId: run.id, + occurredAt: projection.updatedAt, + payload: { ...run, status: "rolled_back" }, }); - await vi.advanceTimersByTimeAsync(50); - await settled; - vi.useRealTimers(); + appAtomRegistry.set(state, { data: Option.fromNullishOr(rewound) }); + await expect(waiting).resolves.toBeUndefined(); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b2c2d8540fd17..3f5d77b259d63 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1083,10 +1083,21 @@ export async function waitForRevertedMessage( const threadAtom = environmentThreadDetails.stateAtom(threadRef); const readProjection = () => Option.getOrNull(appAtomRegistry.get(threadAtom).data); const initial = readProjection(); - if (!initial?.messages.some((message) => message.id === messageId)) { + // Paged prompts live in turn items without a duplicate projection message. + const messageRunId = + initial?.messages.find((message) => message.id === messageId)?.runId ?? + initial?.visibleTurnItems.find( + ({ visibility, sourceThreadId, item }) => + visibility === "local" && + sourceThreadId === initial.thread.id && + item.threadId === initial.thread.id && + item.type === "user_message" && + item.messageId === messageId && + initial.runs.some((run) => run.id === item.runId && run.threadId === initial.thread.id), + )?.item.runId; + if (messageRunId == null) { throw new Error("The message to rewind is no longer available."); } - const messageRunId = initial.messages.find((message) => message.id === messageId)?.runId; return new Promise((resolve, reject) => { let settled = false; let accepted = false; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 063e9ecd086ee..4fd8e179d2e59 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -361,7 +361,8 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; -import { threadEnvironment } from "../state/threads"; +import { environmentThreadDetails, threadEnvironment } from "../state/threads"; +import { waitForAtomValue } from "../state/waitForAtomValue"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; @@ -1172,6 +1173,45 @@ export default function ChatView(props: ChatViewProps) { }, }; })() as MessagesTimelineHistoryControls | undefined; + const getPromptHistory = useStableCallback(() => { + if (routeThreadDetailRef === null) return { messages: [], blocked: true }; + const requests = appAtomRegistry.get( + environmentThreadDetails.pendingRequestsAtom(routeThreadDetailRef), + ); + return { + messages: appAtomRegistry + .get(environmentThreadDetails.visibleTurnItemsAtom(routeThreadDetailRef)) + .flatMap(({ item }) => + item.type === "user_message" && !item.messageId.startsWith("async-answer:") + ? [{ id: item.messageId, role: "user", text: item.text }] + : [], + ), + blocked: requests === null || requests.approvals.length > 0 || requests.userInputs.length > 0, + }; + }); + const loadOlderPromptHistory = useStableCallback(() => { + if (routeThreadDetailRef === null) return null; + const historyAtom = environmentThreadDetails.historyAtom(routeThreadDetailRef); + const history = appAtomRegistry.get(historyAtom); + if (!history.hasMoreHistory || history.loading || history.historyCursor === null) return null; + return (async () => { + const result = await loadEarlierThreadHistory({ + environmentId: routeThreadDetailRef.environmentId, + input: { threadId: routeThreadDetailRef.threadId }, + }); + if (!AsyncResult.isSuccess(result) || result.value._tag !== "loaded") { + throw new Error("Could not load earlier prompt history."); + } + // The command can finish before the atom subscription delivers its page. + const updated = await waitForAtomValue({ + registry: appAtomRegistry, + atom: historyAtom, + predicate: (current) => !current.loading && current.historyCursor !== history.historyCursor, + timeoutMs: 6_000, + }); + if (!updated) throw new Error("Earlier prompt history did not arrive."); + })(); + }); const committedServerMessageIds = deriveCommittedServerUserMessageIds(serverVisibleTurnItems); // Queued messages have no turn item until their run starts, so the // turn-item-derived set alone can never retire their optimistic rows — @@ -5712,7 +5752,7 @@ export default function ChatView(props: ChatViewProps) { createdAt: DateTime.formatIso(sourceMessage.createdAt), updatedAt: DateTime.formatIso(sourceMessage.updatedAt), } - : undefined; + : timelineMessages.find((message) => message.id === messageId); if (!message || message.role !== "user") return; if (!supportsConversationRollback) { @@ -8178,6 +8218,8 @@ export default function ChatView(props: ChatViewProps) { activeThread={activeThread} activeThreadShell={activeThreadShell} promptHistoryMessages={timelineMessages} + getPromptHistory={isServerThread ? getPromptHistory : undefined} + onLoadOlderPromptHistory={loadOlderPromptHistory} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} forceExpandedOnMobile={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ebc30a4a1e708..d31c8a1accfbf 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1078,7 +1078,10 @@ import { } from "../../types"; import { buildComposerPromptHistoryEntries, + loadEarlierComposerPromptHistory, stepComposerPromptHistory, + type ComposerPromptHistoryMessage, + type ComposerPromptHistoryStep, type ComposerPromptHistoryPosition, } from "./composerPromptHistory"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; @@ -1508,6 +1511,13 @@ export interface ChatComposerProps { activeThread: Thread | undefined; activeThreadShell: ThreadShell | null; promptHistoryMessages: ReadonlyArray; + getPromptHistory?: + | (() => { + readonly messages: ReadonlyArray; + readonly blocked: boolean; + }) + | undefined; + onLoadOlderPromptHistory?: (() => Promise | null) | undefined; isServerThread: boolean; isLocalDraftThread: boolean; forceExpandedOnMobile: boolean; @@ -1904,6 +1914,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, promptHistoryMessages, + getPromptHistory, + onLoadOlderPromptHistory, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, @@ -2407,6 +2419,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) targetKey: string; position: ComposerPromptHistoryPosition; } | null>(null); + const pendingPromptRecallRef = useRef(null); + const cancelPendingPromptRecall = useStableCallback(() => { + pendingPromptRecallRef.current?.abort(); + pendingPromptRecallRef.current = null; + }); + useLayoutEffect(() => { + if (promptHistoryPositionRef.current?.targetKey !== composerDraftTargetKey) { + promptHistoryPositionRef.current = null; + } + return cancelPendingPromptRecall; + }, [composerDraftTargetKey, cancelPendingPromptRecall]); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( null, ); @@ -3506,7 +3529,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); setIsComposerScrollCollapsed(false); }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); /** * Payloads for chips the prompt no longer references. History undo restores the @@ -3530,6 +3552,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { + cancelPendingPromptRecall(); expandComposerForEditorChange(); // Typing is when the menu is about to need skills, so it re-checks a // snapshot that has aged past its TTL. @@ -4003,6 +4026,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); setComposerSubmissionError(submission.validationMessage); if (!submission.didDispatch) return; + cancelPendingPromptRecall(); onTypingGuardSend(); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); @@ -4103,47 +4127,117 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); }; + const isPromptHistoryBlocked = useStableCallback(() => { + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + return ( + isComposerApprovalState || + incomingPendingApproval !== null || + incomingPendingUserInputs.length > 0 || + (draft?.images.length ?? 0) > 0 || + (draft?.files.length ?? 0) > 0 || + (draft?.terminalContexts.length ?? 0) > 0 || + (draft?.previewAnnotations.length ?? 0) > 0 || + (draft?.reviewComments.length ?? 0) > 0 || + (draft?.threadContexts.length ?? 0) > 0 || + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || + pendingDraftWork.has(attachmentTargetKey) + ); + }); + const readPromptHistory = useStableCallback((fresh = false) => { + const history = fresh ? getPromptHistory?.() : undefined; + const recalled = promptHistoryPositionRef.current; + return { + entries: buildComposerPromptHistoryEntries( + history?.messages ?? promptHistoryMessagesRef.current, + ), + position: recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null, + currentPrompt: + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? + promptRef.current, + blocked: history?.blocked === true || isPromptHistoryBlocked(), + }; + }); + const applyPromptHistoryStep = useStableCallback((step: ComposerPromptHistoryStep) => { + promptHistoryPositionRef.current = step.position + ? { targetKey: promptHistoryTargetKey, position: step.position } + : null; + replacePromptFromHistory(step.prompt); + }); + useLayoutEffect(() => { + if (pendingPromptRecallRef.current && isPromptHistoryBlocked()) cancelPendingPromptRecall(); + }); + + const onPromptHistorySelectionChange = useStableCallback(() => { + cancelPendingPromptRecall(); + expandComposerForEditorChange(); + }); + const navigatePromptHistory = ( direction: "backward" | "forward", event: KeyboardEvent, ): boolean => { - if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - if (isComposerApprovalState || pendingUserInputs.length > 0) return false; if ( - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerPreviewAnnotations.length > 0 || - composerReviewComments.length > 0 + event.shiftKey || + event.altKey || + event.metaKey || + event.ctrlKey || + event.isComposing || + event.keyCode === 229 ) { + cancelPendingPromptRecall(); return false; } - const recalled = promptHistoryPositionRef.current; - const position = recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null; - if (position === null && promptRef.current.length > 0) { + if (isPromptHistoryBlocked()) { + cancelPendingPromptRecall(); + return false; + } + const current = readPromptHistory(); + if (current.currentPrompt.length > 0 && current.position?.recalled !== current.currentPrompt) { + cancelPendingPromptRecall(); return false; } const editor = composerEditorRef.current; if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + cancelPendingPromptRecall(); return false; } - const step = stepComposerPromptHistory({ - direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), - position, - currentPrompt: promptRef.current, + const step = stepComposerPromptHistory({ ...current, direction }); + if (step) { + cancelPendingPromptRecall(); + applyPromptHistoryStep(step); + return true; + } + if (direction !== "backward") { + cancelPendingPromptRecall(); + return false; + } + if (pendingPromptRecallRef.current) return true; + const page = onLoadOlderPromptHistory?.(); + if (!page) return false; + const controller = new AbortController(); + pendingPromptRecallRef.current = controller; + // Store changes include context arriving before React commits its new props. + const requestDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + const unsubscribe = useComposerDraftStore.subscribe((state) => { + if (state.getComposerDraft(composerDraftTarget) !== requestDraft) controller.abort(); + }); + void loadEarlierComposerPromptHistory({ + getCurrent: () => readPromptHistory(true), + loadEarlier: () => page, + signal: controller.signal, + }).then((loadedStep) => { + unsubscribe(); + if (pendingPromptRecallRef.current !== controller) return; + pendingPromptRecallRef.current = null; + if (loadedStep && !controller.signal.aborted && !readPromptHistory(true).blocked) { + applyPromptHistoryStep(loadedStep); + } }); - if (!step) return false; - promptHistoryPositionRef.current = step.position - ? { targetKey: promptHistoryTargetKey, position: step.position } - : null; - replacePromptFromHistory(step.prompt); return true; }; const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => { + if (key !== "ArrowUp") cancelPendingPromptRecall(); const submissionIntent = composerSubmissionIntentForKey({ event, keybindings, @@ -7097,7 +7191,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "flex items-center overflow-hidden whitespace-nowrap leading-8", )} onChange={onPromptChangeStable} - onVisibleSelectionChange={expandComposerForEditorChangeStable} + onVisibleSelectionChange={onPromptHistorySelectionChange} onCommandKeyDown={onComposerCommandKeyStable} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} diff --git a/apps/web/src/components/chat/composerPromptHistory.test.ts b/apps/web/src/components/chat/composerPromptHistory.test.ts index 0a677151cbc17..94710614718e1 100644 --- a/apps/web/src/components/chat/composerPromptHistory.test.ts +++ b/apps/web/src/components/chat/composerPromptHistory.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { buildPlanImplementationPrompt } from "../../proposedPlan"; import { buildComposerPromptHistoryEntries, + loadEarlierComposerPromptHistory, recallableComposerPrompt, stepComposerPromptHistory, type ComposerPromptHistoryPosition, @@ -188,3 +189,120 @@ describe("stepComposerPromptHistory", () => { expect(step?.prompt).toBe("first"); }); }); + +function deferredPage() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function pagedRecall() { + let current: Omit[0], "direction"> & { + blocked: boolean; + } = { + entries: entries.slice(-1), + position: { entryId: "m3", recalled: "third" }, + currentPrompt: "third", + blocked: false, + }; + const page = deferredPage(); + const cancellation = new AbortController(); + const loadEarlier = vi.fn(() => page.promise); + return { + page, + cancellation, + loadEarlier, + update: (next: Partial) => { + current = { ...current, ...next }; + }, + recall: () => + loadEarlierComposerPromptHistory({ + getCurrent: () => current, + loadEarlier, + signal: cancellation.signal, + }), + }; +} + +describe("loadEarlierComposerPromptHistory", () => { + it("takes one backward step using the messages available after the page resolves", async () => { + const history = pagedRecall(); + const pending = history.recall(); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + + history.update({ entries }); + history.page.resolve(); + expect(await pending).toEqual({ + position: { entryId: "m2", recalled: "second" }, + prompt: "second", + }); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + }); + + it("cancels an in-flight recall even if an edited draft returns to its original text", async () => { + const history = pagedRecall(); + const pending = history.recall(); + history.update({ currentPrompt: "third edited" }); + history.cancellation.abort(); + history.update({ entries, currentPrompt: "third" }); + history.page.resolve(); + expect(await pending).toBeNull(); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + }); + + it("does not recall when input context blocks history while the page is loading", async () => { + const history = pagedRecall(); + const pending = history.recall(); + history.update({ entries, blocked: true }); + history.page.resolve(); + expect(await pending).toBeNull(); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + }); + + it("stops after a page with no recallable prompt and lets the next keypress load another", async () => { + const history = pagedRecall(); + history.update({ entries: [], position: null, currentPrompt: "" }); + const pending = history.recall(); + history.update({ + entries: buildComposerPromptHistoryEntries([ + { id: "work", role: "assistant", text: "Still working" }, + { id: "attachment", role: "user", text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT }, + ]), + }); + history.page.resolve(); + expect(await pending).toBeNull(); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + + const nextPage = deferredPage(); + history.loadEarlier.mockReturnValueOnce(nextPage.promise); + const nextKeypress = history.recall(); + expect(history.loadEarlier).toHaveBeenCalledTimes(2); + history.update({ entries }); + nextPage.resolve(); + expect(await nextKeypress).toEqual({ + position: { entryId: "m3", recalled: "third" }, + prompt: "third", + }); + expect(history.loadEarlier).toHaveBeenCalledTimes(2); + }); + + it("drops a failed page and allows a later recall to retry", async () => { + const history = pagedRecall(); + const pending = history.recall(); + history.page.reject(new Error("History unavailable")); + expect(await pending).toBeNull(); + expect(history.loadEarlier).toHaveBeenCalledTimes(1); + + const retryPage = deferredPage(); + history.loadEarlier.mockReturnValueOnce(retryPage.promise); + const retry = history.recall(); + history.update({ entries }); + retryPage.resolve(); + expect((await retry)?.prompt).toBe("second"); + expect(history.loadEarlier).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts index bc7f2646a8881..e161de6026819 100644 --- a/apps/web/src/components/chat/composerPromptHistory.ts +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -163,3 +163,22 @@ export function stepComposerPromptHistory(input: { if (!entry) return { position: null, prompt: "" }; return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; } + +export async function loadEarlierComposerPromptHistory(input: { + readonly getCurrent: () => Omit[0], "direction"> & { + readonly blocked: boolean; + }; + readonly loadEarlier: () => Promise; + readonly signal: AbortSignal; +}): Promise { + if (input.signal.aborted) return null; + try { + await input.loadEarlier(); + } catch { + return null; + } + if (input.signal.aborted) return null; + const current = input.getCurrent(); + if (current.blocked) return null; + return stepComposerPromptHistory({ ...current, direction: "backward" }); +} diff --git a/packages/client-runtime/src/platform/orchestrationCache.test.ts b/packages/client-runtime/src/platform/orchestrationCache.test.ts index 64269bf81ca65..3ea3f0e868c87 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.test.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.test.ts @@ -39,6 +39,15 @@ const encodeStoredShellSnapshotJson = Schema.encodeSync(StoredShellSnapshotJson) const decodeStoredShellSnapshotJson = Schema.decodeUnknownSync(StoredShellSnapshotJson); const encodeStoredThreadSnapshotJson = Schema.encodeSync(StoredThreadSnapshotJson); const decodeStoredThreadSnapshotJson = Schema.decodeUnknownSync(StoredThreadSnapshotJson); +const decodeStoredThreadSnapshotEffect = Schema.decodeEffect(StoredThreadSnapshotJson); +const Version4ThreadSnapshotJson = Schema.fromJsonString( + StoredOrchestrationThreadSnapshot.mapFields((fields) => ({ + ...fields, + schemaVersion: Schema.Literal(4), + })), +); +const decodeVersion4ThreadSnapshot = Schema.decodeSync(Version4ThreadSnapshotJson); +const encodeVersion4ThreadSnapshot = Schema.encodeEffect(Version4ThreadSnapshotJson); class TestCacheDecodeError extends Schema.TaggedError()( "TestCacheDecodeError", @@ -160,21 +169,62 @@ describe("orchestration cache envelopes", () => { }), ); - it("rejects thread snapshots saved before queued-run items were kept, but not shells", () => { - const staleThread = encodeStoredThreadSnapshotJson({ - schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, - environmentId, - threadId: v2ThreadId, - snapshot: { snapshotSequence: 4, projection: v2Projection }, - }).replace(`"schemaVersion":${ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION}`, `"schemaVersion":3`); - expect(() => decodeStoredThreadSnapshotJson(staleThread)).toThrow(); - const shell = encodeStoredShellSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - snapshot: v2ShellSnapshot, - }); - expect(decodeStoredShellSnapshotSync(shell).schemaVersion).toBe(3); - }); + it.effect( + "round-trips deferred checkpoints and rejects thread caches across a version-4 rollback", + () => + Effect.gen(function* () { + const checkpoint = { + id: "deferred-checkpoint" as never, + threadId: v2ThreadId, + scopeId: "scope" as never, + runId: null, + nodeId: "node" as never, + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 1, + ref: "refs/t3/deferred" as never, + status: "ready" as const, + files: [], + deferredFileCount: 2, + capturedAt: v2Now, + }; + const encoded = encodeStoredThreadSnapshotJson({ + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, + environmentId, + threadId: v2ThreadId, + snapshot: { + snapshotSequence: 4, + projection: { ...v2Projection, checkpoints: [checkpoint] }, + }, + }); + expect(decodeStoredThreadSnapshotJson(encoded).snapshot.projection.checkpoints).toEqual([ + checkpoint, + ]); + + expect.soft(() => decodeVersion4ThreadSnapshot(encoded)).toThrow(); + const oldCache = yield* encodeVersion4ThreadSnapshot({ + schemaVersion: 4, + environmentId, + threadId: v2ThreadId, + snapshot: { snapshotSequence: 4, projection: v2Projection }, + }); + let discarded = false; + const restored = yield* decodeOrDiscardOrchestrationCache( + decodeStoredThreadSnapshotEffect(oldCache).pipe(Effect.asSome), + Effect.sync(() => { + discarded = true; + }), + ); + expect.soft(Option.isNone(restored)).toBe(true); + expect.soft(discarded).toBe(true); + const shell = encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: v2ShellSnapshot, + }); + expect(decodeStoredShellSnapshotSync(shell).schemaVersion).toBe(3); + }), + ); it.effect("discards V1-versioned cache envelopes after a decode failure", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/platform/orchestrationCache.ts b/packages/client-runtime/src/platform/orchestrationCache.ts index f0b95d3bb603b..15304074eeeed 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 3 as const; -export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 4 as const; +export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 5 as const; export const StoredOrchestrationShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index dc6579217f3cc..e0d5728e1fefb 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -123,7 +123,7 @@ describe("boundedThreadSnapshotLoader", () => { it.effect.each([ ["an older server that ignores the opt-in", false], ["a server that sends compact turnItems", true], - ] as const)("requests compact turnItems and loads %s", ([, compact]) => { + ] as const)("requests viewport and compact turnItems and loads %s", ([, compact]) => { const urls: string[] = []; const fetchFn = ((input: RequestInfo | URL) => { urls.push(String(input)); @@ -134,6 +134,7 @@ describe("boundedThreadSnapshotLoader", () => { const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(new URL(urls[0]!).searchParams.get("viewportWindow")).toBe("1"); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 5a903226a3197..01bc0988cb318 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -30,7 +30,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1" }, + query: { compactTurnItems: "1", viewportWindow: "1" }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index e81a12bc3d4f3..525a4dc0226e8 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -265,6 +265,28 @@ const LOADERS: ReadonlyArray<{ ]; describe("authenticated environment HTTP requests", () => { + it.effect.each([ + { view: undefined, support: "1" }, + { view: "conversation" as const, support: null }, + { view: "activity" as const, support: null }, + ])("requests history support only without a view ($view)", ({ view, support }) => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json(THREAD_HISTORY)); + yield* fetchEnvironmentThreadHistoryPage({ + ...harness.input, + threadId: THREAD.projection.thread.id, + cursor: "older-page", + view, + }).pipe(Effect.provide(harness.httpLayer)); + + expect(harness.calls).toHaveLength(1); + const query = new URL(harness.calls[0]!.url).searchParams; + expect(query.get("cursor")).toBe("older-page"); + expect(query.get("view")).toBe(view ?? null); + expect(query.get("includeSupport")).toBe(support); + }), + ); + it.effect.each(LOADERS)("rejects an invalid $name response", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json({})); diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e20053..9cc5f4724b34c 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -21,6 +21,7 @@ export interface ThreadCheckpointSummary { readonly additions: number; readonly deletions: number; }>; + readonly deferredFileCount?: number; readonly assistantMessageId: MessageId | null; readonly completedAt: string; } @@ -43,6 +44,9 @@ export function deriveThreadCheckpointSummaries( checkpointRef: checkpoint.ref, status: checkpoint.status, files: checkpoint.files, + ...(checkpoint.deferredFileCount === undefined + ? {} + : { deferredFileCount: checkpoint.deferredFileCount }), assistantMessageId, completedAt: DateTime.formatIso(checkpoint.capturedAt), }, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index 2d99d7dbf0e0b..8dee06b7fde62 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -28,7 +28,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( params: { threadId: input.threadId }, query: { cursor: input.cursor, - ...(input.view === undefined ? {} : { view: input.view }), + ...(input.view === undefined ? { includeSupport: "1" as const } : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, }; diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index c2c30506022fc..6e22f2da82cd6 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; +import { MessageId, RunId, type OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -14,7 +14,7 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); -function row(index: number): OrchestrationV2ProjectedTurnItem { +function row(index: number) { const id = `item-${index}` as never; return { position: index, @@ -41,10 +41,61 @@ function row(index: number): OrchestrationV2ProjectedTurnItem { completedAt: NOW, updatedAt: NOW, }, - }; + } satisfies OrchestrationV2ProjectedTurnItem; } describe("threadHistoryMerge", () => { + it("keeps a retained prompt above within-run gaps across successive older pages", () => { + const runId = RunId.make("long-run"); + const activity = (index: number) => ({ + ...row(index), + item: { ...row(index).item, runId }, + }); + const prompt: OrchestrationV2ProjectedTurnItem = { + ...activity(0), + item: { + ...activity(0).item, + type: "user_message", + messageId: MessageId.make("long-run-prompt"), + createdBy: "user", + creationSource: "web", + workflowScript: false, + inputIntent: "turn_start", + text: "Keep this prompt above the older activity", + attachments: [], + }, + }; + const retained = [prompt, activity(5), activity(6)]; + const initial = { + ...v2Projection, + turnItems: retained.map((entry) => entry.item), + visibleTurnItems: retained.map((entry, position) => ({ ...entry, position })), + }; + const first = mergeOlderHistoryIntoProjection(initial, [activity(3), activity(4)]); + expect + .soft(first.visibleTurnItems.map((entry) => entry.sourceItemId)) + .toEqual(["item-0", "item-3", "item-4", "item-5", "item-6"]); + const empty = mergeOlderHistoryIntoProjection(first, []); + expect(empty).toBe(first); + const second = mergeOlderHistoryIntoProjection(empty, [activity(1), activity(2), activity(3)]); + expect + .soft(second.visibleTurnItems.map((entry) => entry.sourceItemId)) + .toEqual(["item-0", "item-1", "item-2", "item-3", "item-4", "item-5", "item-6"]); + const complete = mergeOlderHistoryIntoProjection(second, [prompt, activity(1)]); + expect(complete.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-0", + "item-1", + "item-2", + "item-3", + "item-4", + "item-5", + "item-6", + ]); + expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(complete.visibleTurnItems[0]?.item).toBe(prompt.item); + expect(complete.turnItems).toHaveLength(7); + }); + it("fills activity around partial messages in canonical order and preserves live edits", () => { const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] }; const preview = [row(1), row(3)]; diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 24840ee28420a..df7d662d32698 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -1,8 +1,11 @@ import type { + OrchestrationV2Checkpoint, OrchestrationV2ProjectedTurnItem, + OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; +import * as DateTime from "effect/DateTime"; export type ThreadHistoryMeta = { readonly historyCursor: string | null; @@ -49,10 +52,79 @@ function renumberVisible( return rows.map((row, position) => (row.position === position ? row : { ...row, position })); } -export function mergeOlderHistoryIntoProjection( +function mergeMissingEntities( + current: ReadonlyArray, + older: ReadonlyArray, +): ReadonlyArray { + const ids = new Set(current.map((entity) => entity.id)); + const missing = older.filter((entity) => { + if (ids.has(entity.id)) return false; + ids.add(entity.id); + return true; + }); + return missing.length === 0 ? current : [...missing, ...current]; +} + +function hydrateCheckpointFiles( + current: OrchestrationV2Checkpoint, + older: OrchestrationV2Checkpoint | undefined, +): OrchestrationV2Checkpoint { + const { deferredFileCount, ...checkpoint } = current; + if ( + deferredFileCount === undefined || + older === undefined || + older.deferredFileCount !== undefined || + older.files.length !== deferredFileCount || + current.threadId !== older.threadId || + current.scopeId !== older.scopeId || + current.runId !== older.runId || + current.nodeId !== older.nodeId || + current.parentCheckpointId !== older.parentCheckpointId || + current.ordinalWithinScope !== older.ordinalWithinScope || + current.appRunOrdinal !== older.appRunOrdinal || + current.ref !== older.ref || + current.status !== older.status || + DateTime.toEpochMillis(current.capturedAt) !== DateTime.toEpochMillis(older.capturedAt) + ) { + return current; + } + return { ...checkpoint, files: older.files }; +} + +function mergeHistorySupport( projection: OrchestrationV2ThreadProjection, + support: OrchestrationV2ThreadHistoryPage["support"], +): OrchestrationV2ThreadProjection { + if (support === undefined) return projection; + const nodes = mergeMissingEntities(projection.nodes, support.nodes); + const runs = mergeMissingEntities(projection.runs, support.runs); + const attempts = mergeMissingEntities(projection.attempts, support.attempts); + const olderCheckpoints = new Map( + support.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint]), + ); + const hydrated = projection.checkpoints.map((checkpoint) => + hydrateCheckpointFiles(checkpoint, olderCheckpoints.get(checkpoint.id)), + ); + const checkpoints = mergeMissingEntities( + hydrated.every((checkpoint, index) => checkpoint === projection.checkpoints[index]) + ? projection.checkpoints + : hydrated, + support.checkpoints, + ); + return nodes === projection.nodes && + runs === projection.runs && + attempts === projection.attempts && + checkpoints === projection.checkpoints + ? projection + : { ...projection, nodes, runs, attempts, checkpoints }; +} + +export function mergeOlderHistoryIntoProjection( + current: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, + support?: OrchestrationV2ThreadHistoryPage["support"], ): OrchestrationV2ThreadProjection { + const projection = mergeHistorySupport(current, support); if (olderItems.length === 0) { return projection; } @@ -99,6 +171,18 @@ export function mergeOlderHistoryIntoProjection( ...page, ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), ]; + // Ordinals are local to the item thread. Keep inherited source order, including + // fork markers, while inserting activity below a retained prompt in that source. + const sourceOrder = new Map(); + for (const row of visible) { + if (!sourceOrder.has(row.item.threadId)) sourceOrder.set(row.item.threadId, sourceOrder.size); + } + visible.sort( + (left, right) => + sourceOrder.get(left.item.threadId)! - sourceOrder.get(right.item.threadId)! || + left.item.ordinal - right.item.ordinal || + String(left.item.id).localeCompare(String(right.item.id)), + ); if ( visible.length === projection.visibleTurnItems.length && visible.every((row, index) => row === projection.visibleTurnItems[index]) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 61c7f01c9b73c..0da417aa519d8 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + OrchestrationV2ThreadHistoryPage, EventId, MessageId, ORCHESTRATION_V2_WS_METHODS, @@ -20,6 +21,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; @@ -53,6 +55,7 @@ const TARGET = new PrimaryConnectionTarget({ wsBaseUrl: "wss://environment.example.test", }); const THREAD_ID = v2ThreadId; +const encodeHistoryPage = Schema.encodeSync(OrchestrationV2ThreadHistoryPage); const CACHED_SNAPSHOT_SEQUENCE = 7; const PREPARED: PreparedConnection = { environmentId: TARGET.environmentId, @@ -123,6 +126,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); const lastAcceptCompactTurnItems = yield* Ref.make(undefined); + const lastAcceptViewportWindow = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -141,6 +145,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; readonly acceptCompactTurnItems?: true; + readonly acceptViewportWindow?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -150,6 +155,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), + Effect.andThen(Ref.set(lastAcceptViewportWindow, input.acceptViewportWindow)), Effect.as(streamFrom(inputs)), ), ), @@ -266,6 +272,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastRequestCompletionMarker, lastAcceptBoundedSnapshot, lastAcceptCompactTurnItems, + lastAcceptViewportWindow, supervisorState, supervisorSession, savedThreads, @@ -750,6 +757,174 @@ describe("EnvironmentThreads", () => { }), ); + it.effect( + "hydrates support-only history without reverting live entities or checkpoint captures", + () => + Effect.gen(function* () { + const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const supportFor = (ordinal: number) => + ({ + runs: [ + { + id: `run-${ordinal}` as never, + threadId: THREAD_ID, + ordinal, + providerInstanceId: "codex" as never, + modelSelection: { instanceId: "codex" as never, model: "gpt-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`message-${ordinal}`), + rootNodeId: `node-${ordinal}` as never, + activeAttemptId: null, + status: ordinal === 1 ? ("completed" as const) : ("running" as const), + requestedAt: at, + startedAt: at, + completedAt: ordinal === 1 ? at : null, + checkpointId: `checkpoint-${ordinal}` as never, + contextHandoffId: null, + }, + ], + attempts: [ + { + id: `attempt-${ordinal}` as never, + runId: `run-${ordinal}` as never, + attemptOrdinal: 1, + rootNodeId: `node-${ordinal}` as never, + providerInstanceId: "codex" as never, + providerThreadId: "provider-thread" as never, + providerTurnId: null, + reason: "initial" as const, + status: ordinal === 1 ? ("completed" as const) : ("running" as const), + startedAt: at, + completedAt: ordinal === 1 ? at : null, + }, + ], + nodes: [ + { + id: `node-${ordinal}` as never, + threadId: THREAD_ID, + runId: `run-${ordinal}` as never, + parentNodeId: null, + rootNodeId: `node-${ordinal}` as never, + kind: "root_turn" as const, + status: ordinal === 1 ? ("completed" as const) : ("running" as const), + countsForRun: true, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: at, + completedAt: ordinal === 1 ? at : null, + }, + ], + checkpoints: [ + { + id: `checkpoint-${ordinal}` as never, + threadId: THREAD_ID, + scopeId: "scope" as never, + runId: `run-${ordinal}` as never, + nodeId: `node-${ordinal}` as never, + parentCheckpointId: null, + ordinalWithinScope: ordinal, + appRunOrdinal: ordinal, + ref: `refs/t3/${ordinal}` as never, + status: "ready" as const, + files: [ + { path: `file-${ordinal}.ts`, kind: "modified", additions: 1, deletions: 0 }, + ], + capturedAt: at, + }, + ], + }) satisfies NonNullable; + const older = supportFor(1); + const stale = supportFor(2); + const full = stale.checkpoints[0]!; + const listed = { ...full, files: [], deferredFileCount: 1 }; + const incompatible = [ + { ...listed, id: "checkpoint-status" as never, status: "stale" as const }, + { ...listed, id: "checkpoint-ref" as never, ref: "refs/t3/new-capture" as never }, + { + ...listed, + id: "checkpoint-capture" as never, + capturedAt: DateTime.add(at, { seconds: 1 }), + }, + ]; + const live = { + ...BASE_PROJECTION, + runs: stale.runs.map((run) => ({ + ...run, + status: "completed" as const, + completedAt: at, + })), + nodes: stale.nodes.map((node) => ({ + ...node, + status: "completed" as const, + completedAt: at, + })), + attempts: stale.attempts.map((attempt) => ({ + ...attempt, + status: "superseded" as const, + completedAt: at, + })), + checkpoints: [listed, ...incompatible], + }; + const page: OrchestrationV2ThreadHistoryPage = { + snapshotSequence: 14, + items: [], + nextCursor: null, + hasMoreHistory: false, + support: { + runs: [...older.runs, ...stale.runs], + nodes: [...older.nodes, ...stale.nodes], + attempts: [...older.attempts, ...stale.attempts], + checkpoints: [ + ...older.checkpoints, + full, + ...incompatible.map((checkpoint) => ({ ...full, id: checkpoint.id })), + ], + }, + }; + const requestedSupport: Array = []; + const harness = yield* makeHarness({ + historyHttpClient: HttpClient.make((request, url) => { + requestedSupport.push(url.searchParams.get("includeSupport")); + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json(encodeHistoryPage(page))), + ); + }), + }); + yield* Queue.offer(harness.inputs, { + ...snapshot(live, 15), + historyCursor: "support-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 47, + }); + yield* awaitThreadState( + harness.observed, + (state) => state.history.historyCursor === "support-cursor", + ); + expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + const merged = Option.getOrThrow(state.data); + expect.soft(requestedSupport).toEqual(["1"]); + expect.soft(merged.runs).toEqual([...older.runs, ...live.runs]); + expect.soft(merged.nodes).toEqual([...older.nodes, ...live.nodes]); + expect.soft(merged.attempts).toEqual([...older.attempts, ...live.attempts]); + expect.soft(merged.checkpoints).toEqual([...older.checkpoints, full, ...incompatible]); + expect + .soft( + merged.checkpoints.find((checkpoint) => checkpoint.id === full.id)?.deferredFileCount, + ) + .toBeUndefined(); + expect(merged.visibleTurnItems).toEqual(live.visibleTurnItems); + expect(state.history).toMatchObject({ + historyCursor: null, + hasMoreHistory: false, + loading: false, + }); + }), + ); + it.effect("keeps progressive history metadata from a bounded socket fallback", () => Effect.gen(function* () { const olderItem = { @@ -822,6 +997,7 @@ describe("EnvironmentThreads", () => { }); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBeUndefined(); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); + expect(yield* Ref.get(harness.lastAcceptViewportWindow)).toBe(true); yield* TestClock.adjust("500 millis"); yield* Effect.yieldNow; @@ -1348,6 +1524,7 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Paging unavailable", ); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined(); + expect(yield* Ref.get(harness.lastAcceptViewportWindow)).toBeUndefined(); } }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fc8b8e5008133..a77d2d7d922b6 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -695,7 +695,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make { _tag: "loaded" }, { ...latest, - data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)), + data: Option.some( + mergeOlderHistoryIntoProjection(latest.data.value, page.items, page.support), + ), status: complete && waiting ? "synchronizing" : latest.status, history: complete ? applyHistoryPageMeta(latest.history, page) @@ -856,7 +858,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot - ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + ? { + acceptBoundedSnapshot: true as const, + acceptCompactTurnItems: true as const, + acceptViewportWindow: true as const, + } : {}), }; }),