From 8b570d282b5f50709185fbd26f8e36872ddafdeb Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 17:38:40 +0000 Subject: [PATCH 001/200] fix(mobile): delivered messages no longer reappear at the bottom of reopened long threads (#31182) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../src/state/acknowledged-thread-messages.ts | 16 ++++++++++ .../src/state/threadOutboxDelivery.test.ts | 32 ++++++++++++++++++- .../src/state/use-thread-composer-state.ts | 15 +++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index d7935d1debcab..fda11130df39a 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,5 +1,6 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { appAtomRegistry } from "./atom-registry"; import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; import type { AcknowledgedThreadMessage, QueuedThreadMessage } from "./thread-outbox-model"; @@ -51,6 +52,21 @@ export function retainAcknowledgedThreadMessage(message: AcknowledgedThreadMessa ); } +// A bounded window later pages the message out and a remount restarts at sequence 0, so +// rows must leave as soon as their thread has caught up, not at the next write. +export function pruneCaughtUpAcknowledgedThreadMessages( + threadKey: string, + thread: Parameters[1], +) { + writeAcknowledgedThreadMessages((current) => + current.filter( + (entry) => + scopedThreadKey(entry.environmentId, entry.threadId) !== threadKey || + !threadHasCaughtUpTo(entry, thread), + ), + ); +} + export function forgetAcknowledgedThreadMessage(message: QueuedThreadMessage) { writeAcknowledgedThreadMessages((current) => current.filter((entry) => entry.messageId !== message.messageId), diff --git a/apps/mobile/src/state/threadOutboxDelivery.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts index 8fafbe62e7105..3f9797cc91f9c 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -1,4 +1,7 @@ -import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; +import { + acknowledgedThreadMessagesAtom, + pruneCaughtUpAcknowledgedThreadMessages, +} from "./acknowledged-thread-messages"; import { CommandId, EnvironmentId, @@ -144,6 +147,7 @@ vi.mock("./thread-outbox", async () => { }); import * as Option from "effect/Option"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { appAtomRegistry } from "./atom-registry"; import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; import { clearThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; @@ -364,6 +368,32 @@ describe("thread outbox drain delivery cleanup", () => { ]); }); + it("drops an acknowledged row once the open thread catches up, so a reload cannot resurrect it", async () => { + const message = queuedMessage({ messageId: "paged-out-later", text: "Delivered" }); + await harness.manager.enqueue(message); + await completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId), 7); + + pruneCaughtUpAcknowledgedThreadMessages( + scopedThreadKey(message.environmentId, message.threadId), + { + sequence: 6, + messages: [], + }, + ); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...message, sequence: 7 }, + ]); + + pruneCaughtUpAcknowledgedThreadMessages( + scopedThreadKey(message.environmentId, message.threadId), + { + sequence: 7, + messages: [], + }, + ); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([]); + }); + it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); await harness.manager.enqueue(message); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 336226cce8bff..75b3b0df01fde 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -15,7 +15,7 @@ import { deriveThreadRuntime, threadRuntimeHasInterruptibleRun, } from "@t3tools/client-runtime/state/thread-execution"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CommandId, @@ -50,7 +50,10 @@ import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed, createThreadFeedCache } from "../lib/threadActivity"; -import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; +import { + acknowledgedThreadMessagesAtom, + pruneCaughtUpAcknowledgedThreadMessages, +} from "./acknowledged-thread-messages"; import { appendPendingThreadMessages, selectPendingAcknowledgedMessages, @@ -237,6 +240,14 @@ export function useThreadComposerState() { ? EMPTY_THREAD_SEQUENCE_ATOM : environmentThreadDetails.sequenceAtom(selectedThreadDetailRef), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- prunes the app-wide acknowledged atom when the mounted thread catches up + useEffect(() => { + if (selectedThreadKey === null || acknowledgedMessages.length === 0) return; + pruneCaughtUpAcknowledgedThreadMessages(selectedThreadKey, { + sequence: selectedThreadSequence, + messages: selectedThreadMessages, + }); + }, [acknowledgedMessages, selectedThreadKey, selectedThreadSequence, selectedThreadMessages]); const checkpointSummaries = useAtomValue( selectedThreadDetailRef === null ? EMPTY_CHECKPOINT_SUMMARIES_ATOM From 725cfc9c3d9182075af602056746c319b5eb47e0 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:40:42 +0200 Subject: [PATCH 002/200] fix(threads): side chats inherit parent permissions by default (#31114) --- .../ProviderTurnStartService.test.ts | 18 ++++++++----- .../ProviderTurnStartService.ts | 8 +----- .../ThreadFork.execution.test.ts | 27 ++++++++++++++++++- .../ThreadForkService.test.ts | 21 +++++++++++++-- .../src/orchestration-v2/ThreadForkService.ts | 2 -- apps/web/src/components/chat/useSideChat.ts | 14 +++++++--- docs/user/thread-sidebar.md | 6 ++--- 7 files changed, 71 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index cdbd259440ab2..d1b5168258874 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -302,6 +302,7 @@ function makeLocalCommandHarness(input: { readonly contextRun: "current" | "failed" | "cancelled" | "delivered"; readonly nativeForkFrom?: "idle" | "busy"; readonly switchedProvider?: boolean; + readonly threadRuntimeMode?: OrchestrationV2ThreadProjection["thread"]["runtimeMode"]; }; }) { const settledRuns: Array = []; @@ -566,7 +567,7 @@ function makeLocalCommandHarness(input: { ...projection, thread: { ...projection.thread, - runtimeMode: "full-access", + runtimeMode: input.sideChat.threadRuntimeMode ?? "full-access", lineage: { parentThreadId, relationshipToParent: "fork", @@ -1260,16 +1261,22 @@ effectIt.effect("recovers an abandoned failed turn before continuing its native ); effectIt.effect.each([ - { contextRun: "current", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, - { contextRun: "failed", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, - { contextRun: "cancelled", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "current", runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "failed", runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "cancelled", runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT" }, { contextRun: "failed", switchedProvider: true, - runtimeMode: "approval-required", + runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT", }, { contextRun: "delivered", runtimeMode: "full-access", context: "" }, + { + contextRun: "current", + threadRuntimeMode: "approval-required", + runtimeMode: "approval-required", + context: "SIDE_CHAT_CONTEXT", + }, ] as const)( "sends side chat context inline once, for the first turn that reaches the provider: %j", ({ runtimeMode, context, ...sideChat }) => @@ -1281,7 +1288,6 @@ effectIt.effect.each([ yield* harness.start; - // The turn that delivers the context is clamped; a later one follows the thread's mode. expect(harness.open).toHaveBeenCalledWith( expect.objectContaining({ runtimePolicy: expect.objectContaining({ runtimeMode }) }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 1d6e34b4a8652..54b16d7aa2aef 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -699,15 +699,9 @@ export const layer: Layer.Layer< }); const { isCurrentAttemptInStatus } = runControls; - // A side chat shares its parent's worktree; the turn that gets its context asks before acting. const resolvedPolicy = yield* runtimePolicy .resolve({ - thread: - projection.thread.lineage.sideChat === true && - (sideChatHandoff !== undefined || - !projection.runs.some((previous) => previous.ordinal < run.ordinal)) - ? { ...projection.thread, runtimeMode: "approval-required" } - : projection.thread, + thread: projection.thread, modelSelection: run.modelSelection, }) .pipe(Effect.result); diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index d955755e92122..41a1120620f86 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -959,12 +959,37 @@ it.effect("a side chat of an idle parent still starts with the framing note", () query.includes("FROM orchestration_v2_projection_context_transfers"), ), ); + assert.equal( + (yield* orchestrator.getThreadProjection(sideChatId)).thread.runtimeMode, + "full-access", + ); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("side-chat-parent-mode-change"), + threadId: parentId, + runtimeMode: "approval-required", + }); + assert.equal( + (yield* orchestrator.getThreadProjection(sideChatId)).thread.runtimeMode, + "full-access", + ); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("side-chat-child-mode-change"), + threadId: sideChatId, + runtimeMode: "auto", + }); yield* askSideChat(sideChatId, "side-chat-idle-first-message", { ...forkCases[0]!.modelSelection, model: "side-chat-model", }); const started = yield* orchestrator.getThreadProjection(sideChatId); + assert.equal(started.thread.runtimeMode, "auto"); + assert.equal( + (yield* orchestrator.getThreadProjection(parentId)).thread.runtimeMode, + "approval-required", + ); assert.isNotNull(started.providerThreads[0]?.forkedFrom); // The side chat does not show the parent's run, so it is no baseline. assert.isFalse( @@ -1110,7 +1135,7 @@ it.effect("a side chat opened during the parent's first turn starts from a diges yield* fork("side-chat-busy-fork", parentId, sideChatId); const created = yield* orchestrator.getThreadProjection(sideChatId); assert.isTrue(created.thread.lineage.sideChat); - assert.equal(created.thread.runtimeMode, "approval-required"); + assert.equal(created.thread.runtimeMode, "full-access"); assert.isNull(created.thread.forkedFrom); assert.deepEqual(created.contextTransfers[0]?.sourcePoint, { threadId: parentId }); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index 5b35b5d195e86..f9f6ff25b2f0a 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -293,14 +293,31 @@ it.effect("rejects in-progress and rolled-back fork sources", () => }), ); -it.effect("marks only requested forks as supervised side chats", () => +it.effect("marks only requested forks as side chats and inherits parent permissions", () => Effect.gen(function* () { const sourceRun = makeSourceRun("completed"); const sideChat = yield* planFork(sourceRun, makeSourceProjection(sourceRun), { sideChat: true, }); assert.isTrue(sideChat.targetThread.lineage.sideChat); - assert.equal(sideChat.targetThread.runtimeMode, "approval-required"); + assert.equal(sideChat.targetThread.runtimeMode, "full-access"); + for (const runtimeMode of [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ] as const) { + const source = makeSourceProjection(sourceRun); + const inherited = yield* planFork( + sourceRun, + { + ...source, + thread: { ...source.thread, runtimeMode }, + }, + { sideChat: true }, + ); + assert.equal(inherited.targetThread.runtimeMode, runtimeMode); + } const sideChatSource = makeSourceProjection(sourceRun); const sideChatProjection = { diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 6790a2917ae6f..1dc6d1eaf4a54 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -113,8 +113,6 @@ export const layer: Layer.Layer = Layer.succeed( title: input.title ?? `${input.sourceProjection.thread.title} fork`, // The source's title job completes only on the source thread. titleRegeneration: null, - // Side chats share the parent's worktree, so they start supervised. - ...(input.sideChat ? { runtimeMode: "approval-required" as const } : {}), activeProviderThreadId: null, lineage: { parentThreadId: input.sourceProjection.thread.id, diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts index 7ac27f758265f..1587526528987 100644 --- a/apps/web/src/components/chat/useSideChat.ts +++ b/apps/web/src/components/chat/useSideChat.ts @@ -11,7 +11,7 @@ import { useRef } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useRightPanelStore } from "../../rightPanelStore"; -import { waitForThreadShell } from "../../state/entities"; +import { readThreadShell, waitForThreadShell } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; @@ -55,22 +55,28 @@ export function useSideChat(input: { environmentId: threadRef.environmentId, input: { threadId: targetThreadId, onlyIfUnused: true }, }); - if (!(await waitForThreadShell(scopeThreadRef(threadRef.environmentId, targetThreadId)))) { + const targetRef = scopeThreadRef(threadRef.environmentId, targetThreadId); + if (!(await waitForThreadShell(targetRef))) { input.onError("The side chat was created, but it did not reach this client."); discard(); return false; } + const sideChat = readThreadShell(targetRef); + if (sideChat === null) { + input.onError("The side chat is no longer available."); + discard(); + return false; + } const text = prompt?.trim(); // Send before showing the tab: closing a tab whose side chat has no run deletes it. if (text) { - // The server asks before edits on a side chat's first turn whatever mode is sent. const turn = await startThreadTurn({ environmentId: threadRef.environmentId, input: { threadId: targetThreadId, message: { messageId: newMessageId(), role: "user", text, attachments: [] }, titleSeed: deriveThreadTitleSeed({ text, attachments: [] }), - runtimeMode: "approval-required", + runtimeMode: sideChat.runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), }, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6a3145d17512f..6f848b6795772 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -300,9 +300,9 @@ You can also choose **Side chat** from the **+** menu in the right panel. The si chat opens as a tab there, with its own composer. Side chats do not show in the sidebar. Find one again under **Side chats** in thread details. -A side chat works in the same folder as the thread. Its first reply asks before it -edits files or runs commands. After that, it uses the permission mode you choose -in its composer. +A side chat works in the same folder as the thread and inherits its permissions +when created, including for its first reply. Change permissions for an individual +side chat in its composer. Later changes to the parent do not change existing side chats. To use what you learned, choose **Bring back**. The thread gets the side chat with your next message there. Bring back is available while the thread is idle. From 02cb2fc4653f8aadd2733ec505858aa3d5dc5e08 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 5 Nov 2026 11:59:45 -0600 Subject: [PATCH 003/200] fix(server): threads follow their worktree's branch changes (#31599) --- .../WorktreeBranchSync.test.ts | 111 ++++++++++++++++++ .../orchestration-v2/WorktreeBranchSync.ts | 84 +++++++++++++ .../src/orchestration-v2/runtimeLayer.ts | 4 + .../src/vcs/VcsStatusBroadcaster.test.ts | 20 +++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 26 +++- 5 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/orchestration-v2/WorktreeBranchSync.test.ts create mode 100644 apps/server/src/orchestration-v2/WorktreeBranchSync.ts diff --git a/apps/server/src/orchestration-v2/WorktreeBranchSync.test.ts b/apps/server/src/orchestration-v2/WorktreeBranchSync.test.ts new file mode 100644 index 0000000000000..bb95778860e5e --- /dev/null +++ b/apps/server/src/orchestration-v2/WorktreeBranchSync.test.ts @@ -0,0 +1,111 @@ +import { assert, it } from "@effect/vitest"; +import { + ThreadId, + type OrchestrationV2ThreadShell, + type OrchestrationV2ServerCommand, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Stream from "effect/Stream"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as WorktreeBranchSync from "./WorktreeBranchSync.ts"; + +it.effect.each([false, true])( + "reconciles shared worktree branches without changing root checkouts or detached HEADs, conflict=%s", + (conflict) => { + const reconciled = Deferred.makeUnsafe(); + const commands: OrchestrationV2ServerCommand[] = []; + const thread = (id: string, branch: string, worktreePath: string | null) => + ({ id: ThreadId.make(id), branch, worktreePath }) as OrchestrationV2ThreadShell; + const snapshot = { + schemaVersion: 2, + snapshotSequence: 0, + threads: [ + thread("generated", "t3/unify-converter-branches", "/worktree"), + thread("shared", "t3/bfc349ae", "/link/worktree"), + thread("current", "union", "/worktree"), + thread("root", "main", null), + thread("other", "feature", "/other"), + ], + archivedThreads: [thread("archived", "t3/bfc349ae", "/worktree")], + }; + return Effect.gen(function* () { + yield* Layer.build(WorktreeBranchSync.layer); + yield* Deferred.await(reconciled); + assert.deepEqual( + commands.map((command) => { + assert.equal(command.type, "thread.metadata.update"); + if (command.type !== "thread.metadata.update") throw new Error("Unexpected command"); + return [ + command.threadId, + command.branch, + command.expectedBranch, + command.expectedWorktreePath, + ]; + }), + [ + ["generated", "union", "t3/unify-converter-branches", "/worktree"], + ["shared", "union", "t3/bfc349ae", "/link/worktree"], + ["archived", "union", "t3/bfc349ae", "/worktree"], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + realPath: (path) => Effect.succeed(path === "/link/worktree" ? "/worktree" : path), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadWorktreePaths: () => + Effect.succeed( + [...snapshot.threads, ...snapshot.archivedThreads].flatMap((thread) => + thread.worktreePath === null + ? [] + : [{ threadId: thread.id, worktreePath: thread.worktreePath }], + ), + ), + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + streamBranchChanges: Stream.fromIterable([null, "t3/bfc349ae", "union"]).pipe( + Stream.map((refName) => ({ + cwd: "/worktree", + refName, + })), + Stream.ensuring(Deferred.succeed(reconciled, undefined)), + ), + }), + Layer.mock(GitWorkflowService.GitWorkflowService)({ + currentBranch: () => Effect.succeed("union"), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getShellSnapshot: () => Effect.succeed(snapshot), + dispatch: (command) => + Effect.gen(function* () { + commands.push(command); + if ( + command.type === "thread.metadata.update" && + command.threadId === ThreadId.make("generated") && + conflict + ) { + return yield* new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Thread moved before branch metadata could be applied.", + }); + } + return { sequence: commands.length, storedEvents: [] }; + }), + }), + ), + ), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/orchestration-v2/WorktreeBranchSync.ts b/apps/server/src/orchestration-v2/WorktreeBranchSync.ts new file mode 100644 index 0000000000000..d821e192dca9f --- /dev/null +++ b/apps/server/src/orchestration-v2/WorktreeBranchSync.ts @@ -0,0 +1,84 @@ +import { CommandId } from "@t3tools/contracts"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; + +const make = Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const git = yield* GitWorkflowService.GitWorkflowService; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const fs = yield* FileSystem.FileSystem; + return Effect.fn("WorktreeBranchSync.reconcile")(function* (input: { + readonly cwd: string; + readonly refName: string | null; + }) { + if (input.refName === null) return; + const paths = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + const matchingPaths = new Set(); + for (const worktreePath of new Set(paths.map((thread) => thread.worktreePath))) { + const realPath = + worktreePath === input.cwd + ? worktreePath + : yield* fs.realPath(worktreePath).pipe(Effect.orElseSucceed(() => null)); + if (realPath === input.cwd) matchingPaths.add(worktreePath); + } + const matchingThreads = new Map( + paths + .filter((thread) => matchingPaths.has(thread.worktreePath)) + .map((thread) => [thread.threadId, thread.worktreePath]), + ); + if (matchingThreads.size === 0) return; + const snapshot = yield* threads.getShellSnapshot({ threadIds: [...matchingThreads.keys()] }); + const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => + thread.worktreePath === matchingThreads.get(thread.id) && thread.branch !== input.refName, + ); + if (sharing.length === 0) return; + // Status events may queue behind a generated rename or another checkout. + if ((yield* git.currentBranch(input.cwd)) !== input.refName) return; + for (const thread of sharing) { + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(yield* randomUuidV4), + threadId: thread.id, + branch: input.refName, + expectedBranch: thread.branch, + expectedWorktreePath: thread.worktreePath, + }) + .pipe( + Effect.catchTags({ + OrchestratorDispatchError: (cause) => + Effect.logWarning("Worktree branch update was rejected", { + threadId: thread.id, + cause, + }), + }), + ); + } + }); +}); + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const reconcile = yield* make; + yield* forkParked( + Stream.runForEach(broadcaster.streamBranchChanges, (input) => + reconcile(input).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to reconcile worktree branch", { cwd: input.cwd, cause }), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 1b54627595372..ba1dadad1864c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,4 @@ +import * as WorktreeBranchSync from "./WorktreeBranchSync.ts"; import * as GuestShareService from "../guest/GuestShareService.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; @@ -368,6 +369,9 @@ export const layer = Layer.mergeAll( ); export const layerProduction = Layer.mergeAll( + WorktreeBranchSync.layer.pipe( + Layer.provide(Layer.merge(layerThreadManagementProvided, ProjectionStore.layer)), + ), layer.pipe(Layer.provide(layerProjectService)), layerProjectService, layerManagedProjectFoldersProvided, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 45b130f9a2b4b..60f39b592d6de 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -207,8 +207,8 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); }); - it.effect("publishes a terminal branch switch without reading remote status", () => { - const state = { + it.effect("publishes terminal branch switches and validates cached status reads", () => { + const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: remoteStatusWithPr, localStatusCalls: 0, @@ -220,6 +220,11 @@ describe("VcsStatusBroadcaster", () => { return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; yield* broadcaster.getStatus({ cwd: "/repo" }); + const branchEvents = yield* broadcaster.streamBranchChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); const subscribed = yield* Deferred.make(); const events = yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( Stream.tap((event) => @@ -243,6 +248,13 @@ describe("VcsStatusBroadcaster", () => { { _tag: "localUpdated", local: state.currentLocalStatus }, ]); assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); + state.currentLocalStatus = { ...state.currentLocalStatus, hasWorkingTreeChanges: true }; + yield* broadcaster.refreshLocalStatus("/repo"); + state.currentLocalStatus = { ...baseLocalStatus, refName: "union" }; + state.currentRemoteStatus = null; + const queried = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(queried.refName, "union"); + assert.isNull(queried.pr); const snapshot = yield* broadcaster .streamStatus({ cwd: "/repo", includeRemote: false }) .pipe(Stream.take(1), Stream.runCollect); @@ -251,6 +263,10 @@ describe("VcsStatusBroadcaster", () => { local: state.currentLocalStatus, remote: null, }); + assert.deepStrictEqual(yield* Fiber.join(branchEvents), [ + { cwd: "/repo", refName: "main" }, + { cwd: "/repo", refName: "union" }, + ]); }).pipe(Effect.provide(layerTestFor(state))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 7fee5379e4a3b..3921077ebbb42 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -205,6 +205,10 @@ function remoteRefreshFailureDelay( export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, { + readonly streamBranchChanges: Stream.Stream<{ + readonly cwd: string; + readonly refName: string | null; + }>; readonly getStatus: ( input: VcsStatusInput, ) => Effect.Effect; @@ -245,6 +249,10 @@ export const make = Effect.gen(function* () { PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), ); + const branchChangesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded<{ readonly cwd: string; readonly refName: string | null }>(), + (pubsub) => PubSub.shutdown(pubsub), + ); const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -266,14 +274,21 @@ export const make = Effect.gen(function* () { const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local }; - const [localChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { + const [localChanged, branchChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const remote = previous.remote?.branch === local.refName ? previous.remote : null; return [ - [previous.local?.fingerprint !== nextLocal.fingerprint, previous.remote !== remote], + [ + previous.local?.fingerprint !== nextLocal.fingerprint, + previous.local?.value.refName !== local.refName, + previous.remote !== remote, + ], new Map(cache).set(cwd, { local: nextLocal, remote }), ] as const; }); + if (branchChanged && local.isRepo) { + yield* PubSub.publish(branchChangesPubSub, { cwd, refName: local.refName }); + } if (options?.publish && remoteCleared) { yield* PubSub.publish(changesPubSub, { cwd, @@ -322,7 +337,9 @@ export const make = Effect.gen(function* () { ) { const cached = yield* getCachedStatus(cwd); if (cached?.local) { - return cached.local.value; + const branch = yield* workflow.currentBranch(cwd); + if (branch === cached.local.value.refName) return cached.local.value; + return yield* refreshLocalStatusCore(cwd); } return yield* loadLocalStatus(cwd); }); @@ -341,11 +358,11 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + yield* getOrLoadLocalStatus(cwd); const cached = yield* getCachedStatus(cwd); if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } - yield* getOrLoadLocalStatus(cwd); return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -752,6 +769,7 @@ export const make = Effect.gen(function* () { ); return VcsStatusBroadcaster.of({ + streamBranchChanges: Stream.fromPubSub(branchChangesPubSub), getStatus, refreshLocalStatus, refreshStatus, From df0cc36dea0c36d155b39f58e33fef66b426be19 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:12:34 +0000 Subject: [PATCH 004/200] fix(pi): usage reports include sessions stored in a --session-dir launch argument (#30690) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/usage/UsageService.test.ts | 51 ++++++++++++++++--- .../provider-pi/src/server/mcpInjection.ts | 2 +- packages/provider-pi/src/server/usage.ts | 29 ++++++++++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index c33b6da64a728..31d7fbbda0a13 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1275,11 +1275,27 @@ describe("UsageService", () => { it.live("discovers Pi history in its default home and directory overrides", () => Effect.gen(function* () { const { home, settings } = yield* setup; - for (const [agentDir, sessionDir, relativeDir] of [ - [undefined, undefined, ".pi/agent/sessions"], - ["~/custom-pi", undefined, "custom-pi/sessions"], - ["~/ignored-pi", "~/custom-sessions", "custom-sessions"], - ["~/ignored-pi", NodePath.join(home, "absolute-sessions"), "absolute-sessions"], + for (const [agentDir, sessionDir, relativeDir, launchArgs, instanceHome, platform] of [ + [undefined, undefined, ".pi/agent/sessions", undefined], + ["~/custom-pi", undefined, "custom-pi/sessions", undefined], + ["~/ignored-pi", "~/custom-sessions", "custom-sessions", undefined], + ["~/ignored-pi", NodePath.join(home, "absolute-sessions"), "absolute-sessions", undefined], + ["~/ignored-pi", undefined, "launch-sessions", "--session-dir ~/launch-sessions"], + [ + "~/ignored-pi", + undefined, + "instance-home /launch-sessions", + "--session-dir ~/launch-sessions", + "instance-home ", + ], + [ + "~/ignored-pi", + undefined, + "windows-profile/launch-sessions", + "--session-dir ~/launch-sessions", + "windows-profile", + "win32", + ], ] as const) { const dir = NodePath.join(home, relativeDir); yield* Effect.promise(async () => { @@ -1318,11 +1334,34 @@ describe("UsageService", () => { layerService({ prefix: "usage-pi-" + relativeDir.replaceAll("/", "-"), home, - settings, + settings: + launchArgs === undefined + ? settings + : { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + config: { launchArgs }, + environment: + instanceHome === undefined + ? [] + : [ + { + name: platform === "win32" ? "USERPROFILE" : "HOME", + value: NodePath.join(home, instanceHome), + sensitive: false, + }, + ], + }, + }, + }, environment: { PI_CODING_AGENT_DIR: agentDir, PI_CODING_AGENT_SESSION_DIR: sessionDir, }, + platform: platform ?? "linux", }), ), Effect.provideService(HostProcess.HomeDirectory, home), diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index ab92ab73dde7a..d88cdeb434c50 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -32,7 +32,7 @@ const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ "-v", ]); -const PI_ARGUMENTS_WITH_VALUES = new Set([ +export const PI_ARGUMENTS_WITH_VALUES = new Set([ "--api-key", "--append-system-prompt", "--exclude-tools", diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index 961a73e838acd..408042a7e481b 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type { PiSettings } from "../settings.ts"; +import { PI_ARGUMENTS_WITH_VALUES, resolvePiLaunchArgs } from "./mcpInjection.ts"; const PiScanState = Schema.Struct({ sessionId: Schema.mutableKey(Schema.String) }); const Usage = Schema.Struct({ @@ -128,13 +129,37 @@ export const piUsageReader: ProviderUsageReader = { kind: "transcripts", provider: "pi", format: piUsageFormat, - directories: Effect.fn("piUsageReader.directories")(function* ({ environment }) { + directories: Effect.fn("piUsageReader.directories")(function* ({ config, environment }) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; - return [{ dir: resolvePiSessionsDir(environment, home, path) }]; + const platform = yield* HostProcess.Platform; + const launchHome = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || home; + const sessionDirArg = config === undefined ? undefined : piLaunchSessionDir(config.launchArgs); + const launchDir = + sessionDirArg === undefined ? undefined : expandHomePath(sessionDirArg, launchHome); + // --session-dir moves only T3-launched sessions; terminal sessions stay in Pi's own directory. + return [ + ...(launchDir !== undefined && path.isAbsolute(launchDir) + ? [{ dir: path.resolve(launchDir) }] + : []), + { dir: resolvePiSessionsDir(environment, home, path) }, + ]; }), }; +function piLaunchSessionDir(launchArgs: string): string | undefined { + const resolved = resolvePiLaunchArgs(launchArgs); + if (!resolved.ok) return undefined; + let sessionDir: string | undefined; + for (let index = 0; index < resolved.args.length; index += 1) { + const arg = resolved.args[index]; + if (arg === undefined || !PI_ARGUMENTS_WITH_VALUES.has(arg)) continue; + index += 1; + if (arg === "--session-dir") sessionDir = resolved.args[index] || undefined; + } + return sessionDir; +} + /** Where Pi stores session JSONL files for a provider environment, matching Pi's own lookup. */ export function resolvePiSessionsDir( environment: NodeJS.ProcessEnv, From 6ae0d3feecc02f78e8a41999a051438f75162022 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:30:32 +0000 Subject: [PATCH 005/200] perf(mobile): skip unchanged terminal toolbar fade updates (#25312) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../mobile/src/components/ComposerToolbar.tsx | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index cabaabd64a24d..c0fbca1e1bb52 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ReactNode } from "react"; -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Pressable, ScrollView, @@ -149,37 +149,59 @@ export function ComposerToolbarScroller(props: { readonly fadeSurface?: "card" | "sheet"; readonly contentPaddingRight?: number; }) { - const [metrics, setMetrics] = useState({ + const metricsRef = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0, + showLeftFade: false, + showRightFade: false, + }); + const [scrollEdges, setScrollEdges] = useState({ + showLeftFade: false, + showRightFade: false, }); - const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); - const scrollEdges = { - showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, - showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, - }; + const updateScrollEdges = useCallback(() => { + const metrics = metricsRef.current; + const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); + const showLeftFade = metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON; + const showRightFade = metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON; + if (metrics.showLeftFade === showLeftFade && metrics.showRightFade === showRightFade) { + return; + } + metrics.showLeftFade = showLeftFade; + metrics.showRightFade = showRightFade; + setScrollEdges({ showLeftFade, showRightFade }); + }, []); - const handleLayout = (event: LayoutChangeEvent) => { - const viewportWidth = event.nativeEvent.layout.width; - setMetrics((current) => - current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, - ); - }; + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const viewportWidth = event.nativeEvent.layout.width; + if (metricsRef.current.viewportWidth === viewportWidth) return; + metricsRef.current.viewportWidth = viewportWidth; + updateScrollEdges(); + }, + [updateScrollEdges], + ); - const handleContentSizeChange = (contentWidth: number) => { - setMetrics((current) => - current.contentWidth === contentWidth ? current : { ...current, contentWidth }, - ); - }; + const handleContentSizeChange = useCallback( + (contentWidth: number) => { + if (metricsRef.current.contentWidth === contentWidth) return; + metricsRef.current.contentWidth = contentWidth; + updateScrollEdges(); + }, + [updateScrollEdges], + ); - const handleScroll = (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x; - setMetrics((current) => - Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, - ); - }; + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + if (Math.abs(metricsRef.current.offsetX - offsetX) < 1) return; + metricsRef.current.offsetX = offsetX; + updateScrollEdges(); + }, + [updateScrollEdges], + ); return ( From b5644426bf49d3ac20ab2c38b5c0bf5e0a939490 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:31:16 +0000 Subject: [PATCH 006/200] perf(mobile): assemble HTML replacements in one pass (#25278) --- apps/mobile/src/lib/markdownDocument.ts | 4 +--- apps/mobile/src/lib/markdownHtml.test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 6e5bc86cf771d..c771813f176b6 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -279,9 +279,7 @@ export function markdownWithHtml(markdown: string): string { } }; visit(parser.parse(markdown), false, false); - for (const edit of edits.reverse()) - markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end); - return markdown; + return applyMarkdownEdits(markdown, edits); } // Start only at the beginning of a backtick run to avoid retrying every suffix. diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts index cd845f314978f..911d6d466103e 100644 --- a/apps/mobile/src/lib/markdownHtml.test.ts +++ b/apps/mobile/src/lib/markdownHtml.test.ts @@ -116,6 +116,35 @@ describe("HTML in native Markdown", () => { it("keeps anchor text unlinked for unsafe hrefs", () => { expect(markdownWithHtml('Run this now')).toBe("Run this now"); }); + + it("keeps anchor labels and escaped bangs intact across nested formatting and table cells", () => { + const markdown = + '> **![one]** and tail\n\n| A |\n| - |\n| !x[y]
|'; + expect(markdownWithHtml(markdown)).toBe( + "> **\\![\\[one\\]]()** and tail\n\n| A |\n| - |\n| \\![x\\[y\\]]() ![]() |", + ); + expect( + markdownWithHtml( + String.raw`Before \!escaped \\!even`, + ), + ).toBe(String.raw`Before \![escaped]() \\\![even]()`); + }); + + it("converts separated HTML without changing code examples or an unfinished tag", () => { + const literal = '`
`\n\n```html\n\n```\n\n `row ${index} text
!go`, + ); + const expected = Array.from( + { length: 100 }, + (_, index) => `row ${index} text \n\\![go]()`, + ); + expect(markdownWithHtml(rows.join("\n\n") + "\n\n" + literal)).toBe( + expected.join("\n\n") + "\n\n" + literal, + ); + }); }); describe("file embeds in native thread and file Markdown", () => { From 09eb65344f6a2198039d17af653390475d653a1a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:31:54 +0000 Subject: [PATCH 007/200] perf(mobile): skip canceled SVG parsing (#25314) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../threads/ThreadMarkdownImage.test.tsx | 42 +++++++++++++++++++ .../features/threads/ThreadMarkdownImage.tsx | 4 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 7c0b08c2c5c1a..46da789a3fc2b 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -161,3 +161,45 @@ it("fetches a failed SVG again on the next mount but keeps a parsed one", async registry.dispose(); } }); + +it("does not parse an obsolete SVG response after its last reader unmounts", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + queueMicrotask(task); + return () => {}; + }, + }); + const root = createRoot(document.createElement("div")); + const fetchSvg = vi.mocked(fetch); + const parseSvg = vi.mocked(parse); + fetchSvg.mockReset(); + parseSvg.mockClear(); + const body = Promise.withResolvers(); + fetchSvg.mockResolvedValue({ ok: true, status: 200, text: () => body.promise } as never); + try { + await act(async () => + root.render( + + {}} + /> + , + ), + ); + expect(fetchSvg).toHaveBeenCalledOnce(); + const signal = fetchSvg.mock.calls[0]![1]!.signal!; + await act(async () => root.render(null)); + expect(signal.aborted).toBe(true); + await act(async () => body.resolve("")); + expect(parseSvg).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + registry.dispose(); + } +}); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index d510bc536e285..81d569fafb466 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -74,7 +74,9 @@ const svgDocumentAtom = Atom.family((uri: string) => if (cached !== null) return Effect.succeed(cached); return Effect.tryPromise({ try: async (signal): Promise => { - const ast = parse(await readSvgXml(uri, signal)); + const xml = await readSvgXml(uri, signal); + signal.throwIfAborted(); + const ast = parse(xml); if (!ast) throw new Error("SVG has no root element"); return { ast, size: resolveSvgImageSize(ast.props) }; }, From 221d8b8812d10a59b81c960c5fbc23a50753d4a7 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:40:04 +0000 Subject: [PATCH 008/200] test(server): strengthen v1 cutover proof with event-stream waits and content snapshots (#11869) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../LegacyV1Cutover.integration.test.ts | 247 ++++++++++++++---- 1 file changed, 189 insertions(+), 58 deletions(-) diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index 83c34c7edab17..fe8e999eec0a2 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, @@ -14,8 +15,10 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -400,6 +403,37 @@ const seedV1Database = (fixturePath: string, workspace: string) => }); yield* sql`PRAGMA wal_checkpoint(TRUNCATE);`; + + // Deterministic pre-migration snapshot of the legacy recovery tables; + // post-boot assertions compare preserved column contents, not counts. + // Migrations may append columns, so the comparison is restricted to the + // column set recorded here. + const tableColumns = Effect.fn("seedV1Database.tableColumns")(function* (table: string) { + const info = yield* sql<{ + readonly name: string; + }>`SELECT name FROM pragma_table_info(${table})`; + return info.map((row) => row.name); + }); + return { + projects: { + columns: yield* tableColumns("projection_projects"), + rows: yield* sql>` + SELECT * FROM projection_projects ORDER BY project_id + `, + }, + threads: { + columns: yield* tableColumns("projection_threads"), + rows: yield* sql>` + SELECT * FROM projection_threads ORDER BY thread_id + `, + }, + messages: { + columns: yield* tableColumns("projection_thread_messages"), + rows: yield* sql>` + SELECT * FROM projection_thread_messages ORDER BY message_id + `, + }, + }; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: fixturePath }))), ); @@ -551,32 +585,62 @@ const makeCodexAdapter = (capturedTurns: Ref.Ref>) = }), }) satisfies ProviderAdapter.ProviderAdapterV2["Service"]; -const waitForIdle = Effect.fn("LegacyV1Cutover.waitForIdle")(function* (threadId: ThreadId) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const isIdle = (projection: OrchestrationV2ThreadProjection) => - projection.runs.every( - (run) => !["queued", "starting", "running", "waiting"].includes(run.status), +// Await the dispatched run's terminal event on the persisted domain-event stream +// instead of polling the projection, then drain remaining outbox effects. +const dispatchMessageAwaitingRun = Effect.fn("LegacyV1Cutover.dispatchMessageAwaitingRun")( + function* (input: { + readonly threadId: ThreadId; + readonly messageId: string; + readonly commandId: string; + readonly text: string; + }) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const messageId = MessageId.make(input.messageId); + // Capture the cursor before dispatch so even a late subscriber replays + // the terminal event instead of starting its live tail after completion. + const afterSequence = yield* eventSink.latestSequence(); + const terminal = eventSink.stream({ afterSequence }).pipe( + Stream.map((stored) => stored.event), + Stream.filter((event) => event.type === "run.updated"), + Stream.filter( + (event) => + ["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes( + event.payload.status, + ) && + event.payload.threadId === input.threadId && + event.payload.userMessageId === messageId, + ), + Stream.take(1), + Stream.runForEach((event) => + Effect.sync(() => + assert.equal(event.payload.status, "completed", `Run for ${messageId} did not complete`), + ), + ), ); - const afterSequence = yield* eventSink.latestSequence(); - const current = yield* orchestrator.getThreadProjection(threadId); - if (isIdle(current)) { - return current; - } - // Projections commit with their events, so each later event is the only time to re-read. - const [idle] = yield* eventSink.stream({ afterSequence }).pipe( - Stream.mapEffect(() => orchestrator.getThreadProjection(threadId)), - Stream.filter(isIdle), - Stream.take(1), - Stream.runCollect, - ); - if (idle === undefined) { - return yield* Effect.die( - new Error("Cutover test event stream ended before the thread went idle"), + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(input.commandId), + threadId: input.threadId, + messageId, + text: input.text, + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* terminal.pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.die(`No terminal run.updated for ${messageId}`), + }), ); - } - return idle; -}); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(input.threadId); + }, +); const layerBoot = (input: { readonly name: string; @@ -639,6 +703,26 @@ const makeCapturingLogger = (logs: CapturedLog[]) => }); }); +const sha256File = Effect.fn("LegacyV1Cutover.sha256File")(function* (filePath: string) { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + return Hex.encode( + yield* crypto.digest("SHA-256", yield* fs.readFile(filePath)).pipe(Effect.orDie), + ); +}, Effect.provide(NodeCrypto.layer)); + +interface LegacyTableSnapshot { + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray>; +} + +// Migrations legitimately append columns; preservation means the columns the +// v1 schema already had keep their exact values. +const restrictToSnapshotColumns = ( + rows: ReadonlyArray>, + snapshot: LegacyTableSnapshot, +) => rows.map((row) => Object.fromEntries(snapshot.columns.map((column) => [column, row[column]]))); + const messageOrdinals = (projection: OrchestrationV2ThreadProjection) => projection.turnItems .filter( @@ -663,7 +747,8 @@ describe("orchestration v2 legacy v1 cutover", () => { const copyPath = path.join(stateDir, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.join(stateDir, "userdata"), { recursive: true }); - yield* seedV1Database(fixturePath, workspace); + const v1Snapshot = yield* seedV1Database(fixturePath, workspace); + const fixtureHashBefore = yield* sha256File(fixturePath); yield* fs.copyFile(fixturePath, copyPath); const capturedTurns = yield* Ref.make>([]); @@ -676,7 +761,6 @@ describe("orchestration v2 legacy v1 cutover", () => { const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const maintenance = yield* ProjectionReplay.ProjectionReplay; const projections = yield* ProjectionStore.ProjectionStoreV2; - const orchestrator = yield* Orchestrator.OrchestratorV2; assert.equal(yield* importer.pendingThreadCount, ALL_THREADS.length); const shellImport = yield* importer.reconcileShells; @@ -808,6 +892,22 @@ describe("orchestration v2 legacy v1 cutover", () => { `; assert.isNull(snoozedImportRow[0]?.transcript_imported_at); + // A failed hydration leaves the legacy rows and pending marker + // available for retry in a fresh runtime after restart. + yield* sql` + CREATE TRIGGER fail_snoozed_hydration + BEFORE INSERT ON orchestration_events + WHEN NEW.event_id = 'migration:v1:message:message:cutover:snoozed:2' + BEGIN + SELECT RAISE(ABORT, 'injected cutover hydration failure'); + END + `; + const hydrationError = yield* importer + .ensureTranscript(ThreadId.make(SNOOZED_THREAD)) + .pipe(Effect.flip); + assert.equal(hydrationError._tag, "LegacyV1ThreadImportError"); + assert.equal(yield* importer.pendingThreadCount, 1); + const interrupted = yield* projections.getThreadProjection( ThreadId.make(INTERRUPTED_THREAD), ); @@ -816,18 +916,12 @@ describe("orchestration v2 legacy v1 cutover", () => { ["message:cutover:interrupted:2", 2, "interrupted"], ]); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cutover:continue"), + const continued = yield* dispatchMessageAwaitingRun({ threadId: longThreadId, - messageId: MessageId.make("message:cutover:long:continuation"), + messageId: "message:cutover:long:continuation", + commandId: "command:cutover:continue", text: CONTINUATION_PROMPT, - attachments: [], - dispatchMode: { type: "start_immediately" }, }); - const continued = yield* waitForIdle(longThreadId); const runs = continued.runs.filter((run) => run.ordinal >= 1); assert.equal(runs.at(-1)?.status, "completed"); @@ -844,18 +938,12 @@ describe("orchestration v2 legacy v1 cutover", () => { assert.include(turns[0]!.text, "Context handoff (manual_context):"); assert.include(turns[0]!.text, `User message:\n${CONTINUATION_PROMPT}`); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cutover:continue:again"), + const continuedAgain = yield* dispatchMessageAwaitingRun({ threadId: longThreadId, - messageId: MessageId.make("message:cutover:long:continuation:2"), + messageId: "message:cutover:long:continuation:2", + commandId: "command:cutover:continue:again", text: "One more.", - attachments: [], - dispatchMode: { type: "start_immediately" }, }); - const continuedAgain = yield* waitForIdle(longThreadId); assert.equal(continuedAgain.contextHandoffs.length, 1); assert.equal((yield* Ref.get(capturedTurns)).length, 2); @@ -876,11 +964,14 @@ describe("orchestration v2 legacy v1 cutover", () => { FROM orchestration_v2_legacy_imports ORDER BY thread_id `; - const legacyMessageCount = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM projection_thread_messages + const legacyProjects = yield* sql>` + SELECT * FROM projection_projects ORDER BY project_id + `; + const legacyThreads = yield* sql>` + SELECT * FROM projection_threads ORDER BY thread_id `; - const legacyThreadCount = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM projection_threads + const legacyMessages = yield* sql>` + SELECT * FROM projection_thread_messages ORDER BY message_id `; const recordedMigration41 = yield* sql<{ readonly name: string }>` SELECT name FROM effect_sql_migrations WHERE migration_id = 41 @@ -891,8 +982,9 @@ describe("orchestration v2 legacy v1 cutover", () => { return { migrationEventCount: migrationEventCount[0]?.count ?? 0, importRows, - legacyMessageCount: legacyMessageCount[0]?.count ?? 0, - legacyThreadCount: legacyThreadCount[0]?.count ?? 0, + legacyProjects, + legacyThreads, + legacyMessages, longProjection: continuedAgain, migration41Name: recordedMigration41[0]?.name ?? null, authSessionColumnNames: authSessionColumns.map((column) => column.name), @@ -924,6 +1016,21 @@ describe("orchestration v2 legacy v1 cutover", () => { assert.notInclude(firstBoot.authSessionColumnNames, "client_surface"); assert.notInclude(firstBoot.authSessionColumnNames, "client_app_version"); + // Full row contents of the legacy recovery tables survive migration + // and continuation unchanged, not just their counts. + assert.deepStrictEqual( + restrictToSnapshotColumns(firstBoot.legacyProjects, v1Snapshot.projects), + v1Snapshot.projects.rows, + ); + assert.deepStrictEqual( + restrictToSnapshotColumns(firstBoot.legacyThreads, v1Snapshot.threads), + v1Snapshot.threads.rows, + ); + assert.deepStrictEqual( + restrictToSnapshotColumns(firstBoot.legacyMessages, v1Snapshot.messages), + v1Snapshot.messages.rows, + ); + assert.equal(firstBoot.importRows.length, ALL_THREADS.length); const unhydratedRows = firstBoot.importRows.filter( (row) => row.transcript_imported_at === null, @@ -945,6 +1052,7 @@ describe("orchestration v2 legacy v1 cutover", () => { importedThreadCount: 0, importedMessageCount: 0, }); + yield* sql`DROP TRIGGER fail_snoozed_hydration`; assert.deepStrictEqual( yield* importer.ensureTranscript(ThreadId.make(SNOOZED_THREAD)), { importedThreadCount: 1, importedMessageCount: 1 }, @@ -983,14 +1091,33 @@ describe("orchestration v2 legacy v1 cutover", () => { firstBoot.longProjection.runs.map((run) => run.status), ); - const legacyMessageCount = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM projection_thread_messages - `; - const legacyThreadCount = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM projection_threads - `; - assert.equal(legacyMessageCount[0]?.count, firstBoot.legacyMessageCount); - assert.equal(legacyThreadCount[0]?.count, firstBoot.legacyThreadCount); + assert.deepStrictEqual( + restrictToSnapshotColumns( + yield* sql>` + SELECT * FROM projection_projects ORDER BY project_id + `, + v1Snapshot.projects, + ), + v1Snapshot.projects.rows, + ); + assert.deepStrictEqual( + restrictToSnapshotColumns( + yield* sql>` + SELECT * FROM projection_threads ORDER BY thread_id + `, + v1Snapshot.threads, + ), + v1Snapshot.threads.rows, + ); + assert.deepStrictEqual( + restrictToSnapshotColumns( + yield* sql>` + SELECT * FROM projection_thread_messages ORDER BY message_id + `, + v1Snapshot.messages, + ), + v1Snapshot.messages.rows, + ); const quickCheck = yield* sql<{ readonly quick_check: string }>` PRAGMA quick_check `; @@ -1017,6 +1144,10 @@ describe("orchestration v2 legacy v1 cutover", () => { assert.isTrue( boot2Logs.some((log) => String(log.message).includes("migration history diverges")), ); + + // The seeded source file is the untouched recovery copy: migration + // only ever ran against the file copied out of it. + assert.equal(yield* sha256File(fixturePath), fixtureHashBefore); }).pipe(Effect.provide(NodeServices.layer)), ), ); From 9529ee34f02b09628b5c4dd44674c90df18e5f5c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 13:47:46 -0500 Subject: [PATCH 009/200] fix(contracts): a stored secret reference is always sensitive (#31403) Signed-off-by: Yordis Prieto --- .../contracts/src/providerInstance.test.ts | 19 +++++++++++++++++++ packages/contracts/src/providerInstance.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/providerInstance.test.ts diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts new file mode 100644 index 0000000000000..ea73ffb231e46 --- /dev/null +++ b/packages/contracts/src/providerInstance.test.ts @@ -0,0 +1,19 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceEnvironmentVariable } from "./providerInstance.ts"; + +const decodeEnvironmentVariable = Schema.decodeUnknownSync(ProviderInstanceEnvironmentVariable); + +describe("ProviderInstanceEnvironmentVariable", () => { + it("treats a stored reference as sensitive even when the flag is missing or false", () => { + const secret = { provider: "onepassword", reference: "op://Private/item/field" }; + expect(decodeEnvironmentVariable({ name: "API_KEY", secret })).toMatchObject({ + value: secret, + sensitive: true, + }); + expect(decodeEnvironmentVariable({ name: "API_KEY", sensitive: false, secret }).sensitive).toBe( + true, + ); + }); +}); diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index 61fee930c3193..1a93350bbdf53 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -53,7 +53,7 @@ export const ProviderInstanceEnvironmentVariable = StoredProviderInstanceEnviron typeof StoredProviderInstanceEnvironmentVariable.Type >({ decode: ({ secret, ...variable }) => - secret === undefined ? variable : { ...variable, value: secret }, + secret === undefined ? variable : { ...variable, value: secret, sensitive: true }, encode: ({ value, ...variable }) => typeof value === "string" ? { ...variable, value } From edeea8cd67e6cce673a58c643cb2dd4fdce3c858 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 13:47:50 -0500 Subject: [PATCH 010/200] fix(server): a settings save no longer reads an unreadable secret again (#31226) Signed-off-by: Yordis Prieto --- .../provider/ProviderInstanceRegistry.test.ts | 10 ++- .../src/provider/ProviderInstanceRegistry.ts | 41 +++++++++-- .../ProviderInstanceRegistryHydration.test.ts | 70 +++++++++++++++++++ .../ProviderInstanceRegistryHydration.ts | 28 +++++++- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 4bae35385d62b..c7ea68d585d91 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -878,7 +878,7 @@ describe("ProviderInstanceRegistry secret environment", () => { yield* PubSub.take(changes); return built; }); - return { vault, reads, invalidated, created, configs, make, makeGated, gate }; + return { vault, reads, invalidated, created, configs, configMap, make, makeGated, gate }; } it.effect("starts an instance with the secret its environment points at", () => { @@ -899,13 +899,19 @@ describe("ProviderInstanceRegistry secret environment", () => { it.effect("marks an instance unavailable and names the variable it could not read", () => { const fixture = setup({}); return Effect.gen(function* () { - const { registry } = yield* fixture.make; + const { registry, mutator } = yield* fixture.make; + const readsAtStartup = fixture.reads.length; expect(yield* registry.getInstance(instanceId)).toBeUndefined(); expect(fixture.created).toEqual([]); const [unavailable] = yield* registry.listUnavailable; expect(unavailable?.instanceId).toBe(instanceId); expect(unavailable?.unavailableReason).toContain("OPENAI_API_KEY"); + + // A settings save that leaves it alone does not read the vault again. + yield* mutator.reconcile({ ...fixture.configMap }); + expect(fixture.reads).toHaveLength(readsAtStartup); + expect(yield* registry.listUnavailable).toHaveLength(1); }).pipe(Effect.scoped); }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index cc3ca60fcb829..da1fd687f48d7 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -60,6 +60,9 @@ interface LiveEntry { interface RegistryState { readonly entries: Ref.Ref>; readonly unavailable: Ref.Ref>; + // Configs parked on an unreadable secret. A settings save that leaves one alone does not read + // its vault again, so a locked vault is not prompted for on every unrelated save. + readonly secretsUnreadable: Ref.Ref>; readonly changes: PubSub.PubSub; } @@ -155,7 +158,11 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* { @@ -234,6 +241,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { return { kind: "unavailable" as const, + secretsUnreadable: true as const, snapshot: yield* buildUnavailableProviderSnapshot({ driverKind: entry.driver, instanceId, @@ -304,6 +312,7 @@ const makeReconcile = (input: { Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); const previousUnavailable = yield* Ref.get(state.unavailable); + const previousSecretsUnreadable = yield* Ref.get(state.secretsUnreadable); const nextRaw = Object.entries(configMap); const nextKeys = new Set( nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), @@ -338,12 +347,21 @@ const makeReconcile = (input: { } } + const parkedSnapshot = (instanceId: ProviderInstanceId, entry: ProviderInstanceConfig) => { + const parked = previousSecretsUnreadable.get(instanceId); + return !rebuild.has(instanceId) && parked !== undefined && entryEqual(parked, entry) + ? previousUnavailable.get(instanceId) + : undefined; + }; + // Read every secret the builds below need in one go, so a vault unlocks once. const references = yield* Effect.forEach(nextRaw, ([rawInstanceId, entry]) => Effect.gen(function* () { const instanceId = ProviderInstanceId.make(rawInstanceId); const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId); - return kept || !(yield* isEntryEnabled(driversById, entry)) + return kept || + parkedSnapshot(instanceId, entry) !== undefined || + !(yield* isEntryEnabled(driversById, entry)) ? [] : secretReferencesOf(driversById, entry); }), @@ -354,6 +372,7 @@ const makeReconcile = (input: { const builtEntries = new Map(); const builtUnavailable = new Map(); + const builtSecretsUnreadable = new Map(); let orderChanged = false; const previousOrder = [...previousEntries.keys()]; const nextOrder: Array = []; @@ -367,6 +386,12 @@ const makeReconcile = (input: { builtEntries.set(instanceId, existing); continue; } + const parked = parkedSnapshot(instanceId, entry); + if (parked !== undefined) { + builtUnavailable.set(instanceId, parked); + builtSecretsUnreadable.set(instanceId, entry); + continue; + } const result = yield* buildEntry({ driversById, @@ -381,6 +406,7 @@ const makeReconcile = (input: { builtEntries.set(instanceId, result.live); } else { builtUnavailable.set(instanceId, result.snapshot); + if (result.secretsUnreadable) builtSecretsUnreadable.set(instanceId, entry); } } @@ -410,6 +436,7 @@ const makeReconcile = (input: { yield* Ref.set(state.entries, builtEntries); yield* Ref.set(state.unavailable, builtUnavailable); + yield* Ref.set(state.secretsUnreadable, builtSecretsUnreadable); if (entriesChanged || unavailableChanged) { yield* PubSub.publish(state.changes, undefined); @@ -446,10 +473,13 @@ export const makeProviderInstanceRegistry = Effect.fn( const entries = yield* Ref.make>(new Map()); const unavailable = yield* Ref.make>(new Map()); + const secretsUnreadable = yield* Ref.make< + ReadonlyMap + >(new Map()); const changes = yield* PubSub.unbounded(); yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); - const state: RegistryState = { entries, unavailable, changes }; + const state: RegistryState = { entries, unavailable, secretsUnreadable, changes }; const reconcileWithR = makeReconcile({ state, driversById, @@ -488,7 +518,10 @@ export const makeProviderInstanceRegistry = Effect.fn( const rebuild = new Set(); for (const [rawInstanceId, entry] of targets) { const current = live.get(ProviderInstanceId.make(rawInstanceId)); - if (current === undefined) continue; + if (current === undefined) { + rebuild.add(ProviderInstanceId.make(rawInstanceId)); + continue; + } const { secrets, unreadable } = yield* readInstanceSecrets( driversById.get(entry.driver), entry, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index c497e00ac17e9..dc44f0324a6b0 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -3,12 +3,16 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, + SecretProviderKind, type ServerSettings, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -16,8 +20,10 @@ import { ServerSettingsService } from "../serverSettings.ts"; import { DEV_REPLAY_INSTANCE_ID, deriveProviderInstanceConfigMap, + layerSettingsWatcher, seedDevReplayInstance, } from "./ProviderInstanceRegistryHydration.ts"; +import { ProviderInstanceRegistryMutator } from "./ProviderInstanceRegistryMutator.ts"; const projectId = ProjectId.make("project-a"); const instanceId = ProviderInstanceId.make("codex_work"); @@ -141,3 +147,67 @@ describe("seedDevReplayInstance", () => { ), ); }); + +describe("layerSettingsWatcher", () => { + const provider = (label: string) => ({ + [SecretProviderId.make("personal")]: { + kind: SecretProviderKind.make("one_password"), + label, + config: { account: "my" }, + }, + }); + + const watch = (initial: ServerSettings | undefined) => + Effect.gen(function* () { + const changes = yield* Queue.unbounded(); + const reconciled = yield* Queue.unbounded(); + let reloads = 0; + const mutator = Layer.succeed(ProviderInstanceRegistryMutator, { + reconcile: () => Queue.offer(reconciled, undefined), + reloadSecrets: () => Effect.sync(() => void reloads++), + }); + yield* Layer.build( + layerSettingsWatcher(Stream.fromQueue(changes), initial).pipe(Layer.provide(mutator)), + ); + return { + reloads: () => reloads, + // Changes run one at a time, so the repeat's reconcile means the first one's reload decision is done. + change: (settings: ServerSettings) => + Queue.offerAll(changes, [settings, settings]).pipe( + Effect.andThen(Queue.take(reconciled)), + Effect.andThen(Queue.take(reconciled)), + ), + }; + }); + + effectIt.effect( + "retries unreadable secrets when a secret provider changes, not when it is renamed", + () => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const { reloads, change } = yield* watch(yield* settings.getSettings); + + yield* change(yield* settings.updateSettings({ secretProviders: provider("Personal") })); + expect(reloads()).toBe(1); + + // A label is not read by any vault, so renaming a provider retries nothing. + yield* change(yield* settings.updateSettings({ secretProviders: provider("Renamed") })); + expect(reloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(ServerSettingsService.layerTest())), + ); + + effectIt.effect( + "compares a change queued during startup with the settings the registry started from", + () => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const started = yield* settings.getSettings; + const saved = yield* settings.updateSettings({ secretProviders: provider("Personal") }); + const { reloads, change } = yield* watch(started); + + yield* change(saved); + + expect(reloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(ServerSettingsService.layerTest())), + ); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index fbf7a5320c4a4..256bf2e797946 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -8,6 +8,8 @@ import { type ProviderInstanceConfigMap, ServerSettings, } from "@t3tools/contracts"; +import * as NodeUtil from "node:util"; + import * as HostProcess from "@t3tools/shared/HostProcess"; import type * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; @@ -122,18 +124,36 @@ export const seedDevReplayInstance = Effect.fn( }); }); -const layerSettingsWatcher = (settingsChanges: Stream.Stream) => +// A label is display-only, so only kind and config can change what a vault returns. +const vaultConfigs = (settings: ServerSettings) => + Object.entries(settings.secretProviders).map(([id, { kind, config }]) => [id, kind, config]); + +export const layerSettingsWatcher = ( + settingsChanges: Stream.Stream, + // The settings the registry was built from: a change queued during startup differs from these. + initialSettings: ServerSettings | undefined, +) => Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; const serverSettings = yield* Settings.ServerSettingsService; + let secretProviders = initialSettings && vaultConfigs(initialSettings); yield* settingsChanges.pipe( Stream.runForEach(() => // The subscription predates the seed read, so a queued change can be // older than the seed. A failed current read must leave the registry intact. serverSettings.getSettings.pipe( Effect.flatMap((current) => - mutator.reconcile(deriveProviderInstanceConfigMap(current)), + Effect.gen(function* () { + yield* mutator.reconcile(deriveProviderInstanceConfigMap(current)); + // Reconcile leaves instances parked on an unreadable secret alone, + // and a corrected secret provider is what can make them readable. + const next = vaultConfigs(current); + if (!NodeUtil.isDeepStrictEqual(secretProviders, next)) { + secretProviders = next; + yield* mutator.reloadSecrets(); + } + }), ), Effect.catchCause((cause) => Effect.logError("ProviderInstanceRegistry reconcile failed", cause), @@ -186,7 +206,9 @@ export const layer: Layer.Layer< Layer.provide(SecretResolver.layer), ); - return layerSettingsWatcher(settingsChanges).pipe(Layer.provideMerge(layerMutable)); + return layerSettingsWatcher(settingsChanges, initialSettings).pipe( + Layer.provideMerge(layerMutable), + ); }), ) as Layer.Layer< | ProviderInstanceRegistry.ProviderInstanceRegistry From e66c3168a46743e97af7c48ca2da02a2fa9e6889 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 5 Nov 2026 18:50:28 +0000 Subject: [PATCH 011/200] fix(threads): allow snoozing pending input requests (#20058) Co-authored-by: maria-rcks Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../src/features/home/useThreadListActions.ts | 4 +- .../src/features/threads/threadListV2.test.ts | 76 +++++++++---- .../src/features/threads/threadListV2.ts | 2 +- .../Orchestrator.control-reads.test.ts | 96 ++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 50 ++++++++- .../ProjectionSettlement.test.ts | 76 ++++++++++++- .../orchestration-v2/ProjectionStore.test.ts | 40 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 62 +++++++---- .../ThreadSettlementService.test.ts | 32 ++++++ .../ThreadSettlementService.ts | 9 +- .../src/orchestration-v2/runtimeLayer.test.ts | 46 ++++++++ apps/server/src/peer/PeerLinkRequests.test.ts | 35 ++++++ docs/user/thread-sidebar.md | 6 +- .../client-runtime/src/state/entities.test.ts | 14 ++- packages/client-runtime/src/state/models.ts | 5 + .../src/state/threadCommands.test.ts | 42 ++++++- .../src/state/threadCommands.ts | 4 +- .../client-runtime/src/state/threadSettled.ts | 43 +++++-- .../src/state/threadSnoozed.test.ts | 105 +++++++++++++++++- 19 files changed, 676 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 248c436905311..3893569c6886d 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -308,9 +308,7 @@ export function useThreadListActions(): { if (!canSnooze(thread, { now: new Date().toISOString() })) { Dialog.alert( "Could not snooze thread", - thread.hasPendingApprovals || - thread.hasPendingUserInput || - thread.pendingSubagentRequest != null + thread.hasPendingApprovals || thread.pendingSubagentRequest != null ? "This thread is waiting on you. Respond to the pending request before snoozing it." : "This thread is still starting a turn. Try again once it's running.", ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7ffd500a1f862..a88f0ddc10ae1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -14,7 +14,7 @@ import { type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, @@ -23,6 +23,7 @@ import { ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; @@ -37,6 +38,7 @@ import { getThreadListV2OrderedSection, layoutThreadListV2RowMenuActions, resolveThreadListV2SnoozeMenuSelection, + resolveThreadListV2SwipeActions, resolveThreadListV2SnoozePresets, resolveThreadListV2Status, resolveThreadListV2WokeAt, @@ -209,32 +211,35 @@ describe("resolveThreadListV2Status", () => { ); }); -describe("queued messages keep a settled thread active", () => { - const threads = [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), - makeThread({ - id: ThreadId.make("settled-queued"), - title: "Settled with outbox", - settledOverride: "settled", - }), - ]; - const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); +describe("resolveThreadListV2SwipeActions", () => { + it("offers snooze for a presented input request and moves it onto the snoozed shelf", () => { + const raw = makeRawThreadShell({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input", + createdAt: DateTime.makeUnsafe(NOW), + }, + }); + const thread = presentThreadShell(environmentId, raw); + expect(thread.pendingUserInputAt).toBe(NOW); + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: canSnooze(thread, { now: NOW }), + }).secondary, + ).toBe("snooze"); - it("lists the thread in the active block instead of the settled shelf", () => { const layout = buildThreadListV2Items({ - threads, + threads: [{ ...thread, snoozedAt: NOW, snoozedUntil: "2026-06-03T00:00:00.000Z" }], environmentId: null, searchQuery: "", now: NOW, - queuedThreadKeys, + snoozedShelfExpanded: true, + snoozeEnvironmentIds: new Set([environmentId]), }); - expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ - ["active", "card"], - ["settled-queued", "card"], - ["settled", "slim"], - ]); - expect(layout.settledCount).toBe(1); + expect(layout.snoozedCount).toBe(1); }); }); @@ -1768,3 +1773,32 @@ describe("Working section beta", () => { ]); }); }); + +describe("queued messages keep a settled thread active", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ + id: ThreadId.make("settled-queued"), + title: "Settled with outbox", + settledOverride: "settled", + }), + ]; + const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); + + it("lists the thread in the active block instead of the settled shelf", () => { + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["active", "card"], + ["settled-queued", "card"], + ["settled", "slim"], + ]); + expect(layout.settledCount).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 9c91aa7d8252a..1b79c9a15d6f0 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -183,7 +183,7 @@ export function resolveThreadListV2SnoozeGateExpiryMs( >, options: { readonly now: string }, ): number | null { - if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; + if (thread.hasPendingApprovals) return null; if (thread.pendingSubagentRequest != null) return null; if (!hasQueuedTurnStart(thread, options)) return null; const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index c806794732726..6544fafdacbff 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -559,6 +559,14 @@ it.effect( assert.deepEqual(yield* outbox.listByCommandId(commandId), []); } } + if (mode === "live") { + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("snooze-live-control"), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }); + } yield* orchestrator.dispatch( mode === "live" ? { @@ -583,6 +591,8 @@ it.effect( assert.equal(response.node?.status, mode === "live" ? "completed" : "cancelled"); assert.equal(response.item?.status, mode === "live" ? "completed" : "cancelled"); if (mode === "live") { + assert.isNull((yield* projections.getThread(threadId)).snoozedUntil); + assert.isNull((yield* projections.getThread(threadId)).snoozedAt); assert.deepEqual(response.request?.answers, normalizedAnswers); assert.equal(response.item?.type, "user_input_request"); if (response.item?.type === "user_input_request") { @@ -1537,6 +1547,92 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r }).pipe(Effect.provide(layerTest)), ); +it.effect("keeps a snooze when a live question is cancelled or an approval is answered", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + for (const kind of ["user_input", "command"] as const) { + const threadId = ThreadId.make(`thread:snoozed-live-${kind}`); + const requestId = RuntimeRequestId.make(`request:snoozed-live-${kind}`); + const sessionId = ProviderSessionId.make(`session:snoozed-live-${kind}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-snoozed-live-${kind}`), + threadId, + projectId: ProjectId.make("project:snoozed-live"), + title: "Deferred question", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-live-${kind}`), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }); + const before = yield* projections.getThread(threadId); + yield* projections.apply({ + id: EventId.make(`attach-snoozed-live-${kind}`), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* projections.apply({ + id: EventId.make(`request-snoozed-live-${kind}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: requestId, + nodeId: NodeId.make(`node:snoozed-live-${kind}`), + providerTurnId: null, + nativeRequestRef: null, + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId: sessionId }, + createdAt: now, + resolvedAt: null, + }, + }); + const commandId = CommandId.make(`respond-snoozed-live-${kind}`); + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId, + threadId, + requestId, + decision: kind === "user_input" ? "cancel" : "accept", + }); + const after = yield* projections.getThread(threadId); + assert.deepEqual(after.snoozedUntil, before.snoozedUntil); + assert.deepEqual(after.snoozedAt, before.snoozedAt); + assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "resolved"); + assert.equal( + (yield* outbox.listByCommandId(commandId))[0]?.request.type, + "runtime-request.respond", + ); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect("dispatch records the command outcome and its duration", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a6b876db1de96..daf64a2d438fb 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3526,11 +3526,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future.`, }); } - if (projection.runtimeRequests.some((request) => request.status === "pending")) { + if ( + projection.runtimeRequests.some( + (request) => request.status === "pending" && request.kind !== "user_input", + ) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed.`, + cause: `Thread ${command.threadId} has a pending approval and cannot be snoozed.`, }); } const queuedRuns = projection.runs.filter((run) => run.status === "queued"); @@ -8854,6 +8858,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.delivery === undefined ? {} : { delivery: command.delivery }), secretStatus: command.secretStatus, }; + if ( + !unchanged && + command.secretStatus === "saved" && + projection.thread.snoozedUntil != null + ) { + yield* emit( + events, + command, + )({ + type: "thread.unsnoozed", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: now, + payload: { ...projection.thread, snoozedUntil: null, snoozedAt: null, updatedAt: now }, + }); + } yield* emit( events, command, @@ -8980,6 +9000,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, type: "link_request", }; + if (!unchanged && command.linkStatus === "linked" && projection.thread.snoozedUntil != null) { + yield* emit( + events, + command, + )({ + type: "thread.unsnoozed", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: now, + payload: { ...projection.thread, snoozedUntil: null, snoozedAt: null, updatedAt: now }, + }); + } yield* emit( events, command, @@ -9401,6 +9433,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return yield* recordSubagentFollowup(answer, events).pipe(mapDispatchError(answer)); } if (providerSessionId === null) return; + if (runtimeRequest.kind === "user_input" && resolvedNodeStatus === "completed") { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe(mapDispatchError(command)); + if (thread.snoozedUntil != null) { + yield* emitEvent({ + type: "thread.unsnoozed", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, snoozedUntil: null, snoozedAt: null, updatedAt: now }, + }); + } + } yield* Ref.update(effects, (existing) => [ ...existing, { diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 1352e17585bfd..825bc5d7ce53c 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -5,6 +5,7 @@ import { NodeId, type OrchestrationV2AppThread, type OrchestrationV2Run, + type OrchestrationV2RuntimeRequest, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -20,7 +21,11 @@ import * as Layer from "effect/Layer"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; +import { + isAutoSettlementCandidate, + isSnoozed, + resolveAutoSettlementAt, +} from "./ThreadSettlementService.ts"; const layerSql = ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); @@ -136,6 +141,75 @@ const createItem = Effect.fn(function* ( }); }); +it.effect.each([["sql", layerSql]] as const)( + "%s: pending approvals wake a snoozed question even with older creation times", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("snoozed-question-approval", { + snoozedAt: now, + snoozedUntil: DateTime.add(now, { days: 1 }), + }); + const question: OrchestrationV2RuntimeRequest = { + id: RuntimeRequestId.make("question"), + nodeId: NodeId.make("node:question"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: DateTime.subtract(now, { hours: 1 }), + resolvedAt: null, + }; + const approval: OrchestrationV2RuntimeRequest = { + ...question, + id: RuntimeRequestId.make("approval"), + kind: "command", + createdAt: old, + }; + const newerApproval = { + ...approval, + id: RuntimeRequestId.make("newer-approval"), + createdAt: DateTime.add(old, { hours: 1 }), + }; + const setRequest = (request: OrchestrationV2RuntimeRequest) => + store.apply({ + id: EventId.make(`event:${request.id}:${request.status}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: request, + }); + const assertPending = (expected: RuntimeRequestId, snoozed: boolean) => + Effect.gen(function* () { + const snapshot = yield* store.getShellSnapshot(); + const shell = snapshot.threads.find((thread) => thread.id === threadId)!; + const projection = yield* store.getThreadProjection(threadId); + for (const candidate of [shell, ProjectionStore.threadShellFromProjection(projection)]) { + assert.equal(candidate.pendingRuntimeRequest?.id, expected); + assert.equal(isSnoozed(candidate, DateTime.toEpochMillis(now)), snoozed); + } + }); + yield* setRequest({ + ...question, + id: RuntimeRequestId.make("older-question"), + createdAt: old, + }); + yield* setRequest(question); + yield* assertPending(question.id, true); + + // A request becoming pending can retain a creation time older than the snooze. + yield* setRequest(approval); + yield* assertPending(approval.id, false); + yield* setRequest(newerApproval); + yield* assertPending(newerApproval.id, false); + yield* setRequest({ ...newerApproval, status: "resolved", resolvedAt: now }); + yield* assertPending(approval.id, false); + yield* setRequest({ ...approval, status: "resolved", resolvedAt: now }); + yield* assertPending(question.id, true); + }).pipe(Effect.provide(testLayer)), +); + it.effect( "sql: discovers settlement work with the same activity and background semantics as the shell", () => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index c1772916022f5..c584dd27a8d23 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -6605,6 +6605,46 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, }); + for (const responseCapability of [ + { type: "live", providerSessionId: ProviderSessionId.make("session:pending-secret") }, + { type: "message" }, + ] as const) { + for (const fresh of [true, false]) { + yield* projectionStore.apply({ + id: EventId.make("event:" + suffix + ":fresh-" + fresh), + type: "runtime-request.updated", + threadId, + runId, + nodeId, + driver, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability, + createdAt: fresh + ? DateTime.add(now, { seconds: 5 }) + : DateTime.subtract(now, { seconds: 1 }), + resolvedAt: null, + }, + }); + for (const shell of [ + (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ), + ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ), + ]) { + assert.equal(shell?.pendingRuntimeRequest?.id, fresh ? requestId : "secret:b"); + } + } + } + for (const status of [ "preparing", "starting", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index bd6a61db9030b..23874c6014fe0 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1559,9 +1559,11 @@ export function threadShellFromProjection( .filter((request) => request.status === "pending") .toSorted( (left, right) => + Number(left.kind === "user_input") - Number(right.kind === "user_input") || DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), )[0] ?? null, ); + const userMessages = projection.messages .filter((message) => message.role === "user" && idleCompactionOf(message) === undefined) .toSorted( @@ -1570,20 +1572,26 @@ export function threadShellFromProjection( ); const latestUserAuthoredMessageAt = userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null; - if (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) { - pendingRuntimeRequest = - secretRequestAsPendingInput( - projection.turnItems - .filter( - (item) => - item.status === "waiting" && - waitsOnUser(item, liveRunIds, latestUserAuthoredMessageAt), - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), - )[0] ?? null, - ) ?? pendingRuntimeRequest; + if (pendingRuntimeRequest === null || pendingRuntimeRequest.kind === "user_input") { + const waitingCard = secretRequestAsPendingInput( + projection.turnItems + .filter( + (item) => + item.status === "waiting" && waitsOnUser(item, liveRunIds, latestUserAuthoredMessageAt), + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null, + ); + if ( + waitingCard !== null && + (pendingRuntimeRequest === null || + DateTime.toEpochMillis(waitingCard.createdAt) >= + DateTime.toEpochMillis(pendingRuntimeRequest.createdAt)) + ) { + pendingRuntimeRequest = waitingCard; + } } const latestUserMessage = userMessages[0] ?? null; const pendingBackgroundTasks = derivePendingBackgroundWork({ @@ -6230,7 +6238,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' - ORDER BY request.created_at DESC, request.runtime_request_id DESC + ORDER BY (request.kind = 'user_input') ASC, + request.created_at DESC, request.runtime_request_id DESC LIMIT 1 ) AS pending_request_payload_json, ( @@ -6760,16 +6769,23 @@ export const layer: Layer.Layer = ) : null; if ( - (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) && + (pendingRuntimeRequest === null || pendingRuntimeRequest.kind === "user_input") && waitingCards.length > 0 ) { - pendingRuntimeRequest = - secretRequestAsPendingInput( - waitingCards.toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), - )[0] ?? null, - ) ?? pendingRuntimeRequest; + const waitingCard = secretRequestAsPendingInput( + waitingCards.toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null, + ); + if ( + waitingCard !== null && + (pendingRuntimeRequest === null || + DateTime.toEpochMillis(waitingCard.createdAt) >= + DateTime.toEpochMillis(pendingRuntimeRequest.createdAt)) + ) { + pendingRuntimeRequest = waitingCard; + } } let terminalFailureItem = row.terminal_failure_payload_json === null diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 1d8e514942719..ff5caf6a591fa 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -6,6 +6,7 @@ import { ProjectId, EventId, ProviderInstanceId, + RuntimeRequestId, RunId, ThreadId, TurnItemId, @@ -1587,6 +1588,37 @@ describe("ThreadSettlementServiceV2 terminals", () => { describe("isSnoozed", () => { const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) }; + it("defers existing input while new questions and approvals wake the thread", () => { + const request = { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: snoozed.snoozedAt, + }; + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: request }), + NOW_MS, + ), + ).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: { ...request, createdAt: at(-DAY_MS) } }), + NOW_MS, + ), + ).toBe(false); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: { ...request, kind: "command" } }), + NOW_MS, + ), + ).toBe(false); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, snoozedAt: null, pendingRuntimeRequest: request }), + NOW_MS, + ), + ).toBe(false); + }); it("keeps a run requested and completed after snoozing parked until the timer", () => { const thread = shell({ ...snoozed, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8c2dcfe8868cc..40c6bf7d057f5 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -174,10 +174,17 @@ export function isSnoozed( ): boolean { const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false; - if (thread.pendingRuntimeRequest !== null) return false; // Same as the client: a waiting guest message wakes the thread, and the snooze stays stored. if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; const snoozedAtMs = toMillis(thread.snoozedAt); + if ( + thread.pendingRuntimeRequest !== null && + (thread.pendingRuntimeRequest.kind !== "user_input" || + snoozedAtMs === null || + DateTime.toEpochMillis(thread.pendingRuntimeRequest.createdAt) > snoozedAtMs) + ) { + return false; + } const completedAtMs = toMillis(thread.latestRunCompletedAt); const wokeOnError = thread.status === "failed" && diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 17b103f75b85f..54ce798da42bc 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1616,6 +1616,52 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { type: "message", }); const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + const beforeSnooze = yield* orchestrator.getThreadProjection(threadId); + const snooze = { + type: "thread.snooze" as const, + commandId: CommandId.make("runtime-async-question-snooze"), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }; + yield* orchestrator.dispatch(snooze); + const snoozed = yield* orchestrator.getThreadProjection(threadId); + assert.isNotNull(snoozed.thread.snoozedUntil); + assert.deepEqual(snoozed.runtimeRequests, beforeSnooze.runtimeRequests); + assert.deepEqual(snoozed.turnItems, beforeSnooze.turnItems); + assert.deepEqual(snoozed.nodes, beforeSnooze.nodes); + assert.deepEqual(snoozed.runs, beforeSnooze.runs); + + // A question arriving after an earlier snooze must be acknowledged even + // when the user picks the same wake time again. + const priorSnooze = DateTime.makeUnsafe(DateTime.toEpochMillis(now) - 1_000); + yield* eventSink.write({ + commandId: CommandId.make("runtime-async-question-earlier-snooze"), + events: [ + { + id: EventId.make("runtime-async-question-earlier-snooze-event"), + type: "thread.snoozed", + threadId, + occurredAt: priorSnooze, + payload: { ...snoozed.thread, snoozedAt: priorSnooze }, + }, + ], + }); + yield* orchestrator.dispatch({ + ...snooze, + commandId: CommandId.make("runtime-async-question-resnooze"), + }); + assert.deepEqual((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedAt, now); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-async-question-wake"), + threadId, + reason: "user", + }); + const awake = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(awake.thread.snoozedUntil); + assert.deepEqual(awake.runtimeRequests, beforeSnooze.runtimeRequests); + const beforeInvalid = yield* orchestrator.getThreadProjection(threadId); const beforeSequence = yield* eventSink.latestSequence(); for (const [index, answers] of [ diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index c3ca78cbcb6a7..09f285954992b 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -482,6 +482,31 @@ it.live("the agent asks without waiting, and the user links the box after its tu { kind: "user_input" }, ); + for (const secretStatus of ["pending", "saved"] as const) { + yield* a.orchestrator.dispatch({ + type: "secret_request.record", + commandId: CommandId.make("command:secret-" + secretStatus), + threadId, + runId, + nodeId: rootNode, + turnItemId: TurnItemId.make("turn-item:snoozed-secret"), + label: "Credential", + reason: "Connect", + secretStatus, + }); + if (secretStatus === "pending") { + yield* a.orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("command:snooze-secret"), + threadId, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + expect((yield* a.projections.getThread(threadId)).snoozedUntil).not.toBeNull(); + } else { + expect((yield* a.projections.getThread(threadId)).snoozedUntil).toBeNull(); + } + } + // The agent ends its turn; the card stays answerable, and the thread still asks. yield* endRun(a); expect(yield* cardOf(a)).toMatchObject({ status: "waiting", linkStatus: "pending" }); @@ -533,6 +558,14 @@ it.live("the agent asks without waiting, and the user links the box after its tu ).toEqual([threadId]); expect(yield* cardOf(a)).toMatchObject({ status: "waiting", linkStatus: "pending" }); + yield* a.orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("command:snooze-link"), + threadId, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + expect((yield* a.projections.getThread(threadId)).snoozedUntil).not.toBeNull(); + // The user's client mints a code on the box and picks more than the agent suggested. const pairing = yield* b.auth.issuePairingCredential(); yield* a.requests.answer({ @@ -541,6 +574,8 @@ it.live("the agent asks without waiting, and the user links the box after its tu answer: { type: "link", urls: [b.url], access: "auto", pairingCode: pairing.credential }, }); + expect((yield* a.projections.getThread(threadId)).snoozedUntil).toBeNull(); + const link = yield* a.links.get(box.environmentId); expect(Option.getOrUndefined(link)?.access).toBe("auto"); expect(yield* cardOf(a)).toMatchObject({ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6f848b6795772..4829fa3c6b7b5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -326,6 +326,8 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze several selected threads together. Choose **Wake thread** to bring a thread back early. + A snoozed thread keeps working and stays hidden until its wake time, even while -its agent wakes itself for background work. It comes back early only when it needs -an approval or answer from you, when a run fails, or when you send it a message. +its agent wakes itself for background work. It comes back early for an approval, +a new question, a failed run, or a message or answer you send. You can snooze an existing +question and answer when the thread wakes; the question stays unanswered. diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 6cc9bc91595da..dc811ce4e69ff 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -22,7 +22,7 @@ import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; const environmentId = EnvironmentId.make("environment-v2"); describe("V2 client presentation", () => { - it("an async question blocks snooze and wakes a snoozed thread", () => { + it("an async question allows snooze and wakes an older snooze", () => { const runId = RunId.make("run-async-question"); const running = { ...v2ThreadShell, @@ -39,7 +39,17 @@ describe("V2 client presentation", () => { }, }; const active = presentThreadShell(environmentId, running); - expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); + expect(active.hasPendingUserInput).toBe(true); + expect(isThreadWorking(active)).toBe(false); + expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(true); + + const completed = presentThreadShell(environmentId, { + ...running, + activeRunId: null, + status: "completed", + }); + expect(completed.hasPendingUserInput).toBe(true); + expect(isThreadWorking(completed)).toBe(false); expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 196b5ff39e171..098b73141e3c9 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -158,6 +158,7 @@ export interface EnvironmentThreadShell { readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + readonly pendingUserInputAt?: string | null; readonly pendingSubagentRequest?: PendingSubagentRequest | null; /** Guest messages waiting for the owner to send or dismiss. */ readonly pendingGuestMessageCount?: number; @@ -312,6 +313,10 @@ export function presentThreadShell( thread.pendingRuntimeRequest.kind !== "auth_refresh", // Async questions count too, so a thread with any open question leaves Working. hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", + pendingUserInputAt: + thread.pendingRuntimeRequest?.kind === "user_input" + ? iso(thread.pendingRuntimeRequest.createdAt) + : null, pendingSubagentRequest, pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0, hasActionableProposedPlan: thread.hasActionableProposedPlan, diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 5fbfb1225a38b..9a723f0f6e05e 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -498,14 +498,52 @@ describe("remote thread lifecycle commands", () => { input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, }); const request = yield* Queue.take(h.requests); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(stale.threads[0]); + if (action === "settle") { + expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(stale.threads[0]); + } else { + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject({ + snoozedUntil: FUTURE, + pendingRuntimeRequest: USER_INPUT, + }); + } yield* Deferred.succeed(request.reply, { sequence: 2 }); expect((yield* Effect.promise(() => result))._tag).toBe("Success"); expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject( action === "settle" ? { settledOverride: "settled" } : { snoozedUntil: FUTURE }, ); - expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull(); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toEqual( + action === "settle" ? null : USER_INPUT, + ); expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); }), ); + it.effect("acknowledges a new question when snoozing again to the same wake time", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const initial = { + ...SNAPSHOT, + threads: [ + { + ...SNAPSHOT.threads[0]!, + pendingRuntimeRequest: USER_INPUT, + snoozedAt: DateTime.makeUnsafe("2026-09-12T09:00:00.000Z"), + snoozedUntil: FUTURE, + }, + ], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), initial); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, + }); + const request = yield* Queue.take(h.requests); + const snoozed = h.registry.get(h.visibleAtom)?.threads[0]; + expect(DateTime.toEpochMillis(snoozed!.snoozedAt!)).toBeGreaterThanOrEqual( + DateTime.toEpochMillis(USER_INPUT.createdAt), + ); + expect(snoozed?.pendingRuntimeRequest).toEqual(USER_INPUT); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 88a5759c5ed50..7a8d20830adec 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -404,13 +404,13 @@ export function createThreadEnvironmentAtoms( })), snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => (!accepted && - (thread.pendingRuntimeRequest !== null || + ((thread.pendingRuntimeRequest !== null && + thread.pendingRuntimeRequest.kind !== "user_input") || ["preparing", "queued", "starting"].includes(thread.status))) || !(Date.parse(input.snoozedUntil) > DateTime.toEpochMillis(now)) ? thread : { ...thread, - pendingRuntimeRequest: null, snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), snoozedAt: now, }, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index fa1c33fb8341f..c247f9d80489f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -74,18 +74,38 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + readonly pendingUserInputAt?: string | null; readonly pendingSubagentRequest?: PendingSubagentRequest | null; readonly pendingGuestMessageCount?: number; } +function pendingQuestionAt(shell: ThreadSnoozeShell): string | null { + const request = shell.source?.pendingRuntimeRequest; + return ( + shell.pendingUserInputAt ?? + (request?.kind === "user_input" ? DateTime.formatIso(request.createdAt) : null) + ); +} + /** * Only a request for the user or a fresh failure ends a snooze early. Completed runs never do: * a snoozed long-running thread keeps waking itself for background work and stays hidden * until its timer. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; - if (shell.source?.pendingRuntimeRequest != null) return true; + const request = shell.source?.pendingRuntimeRequest; + if (shell.hasPendingApprovals || (request != null && request.kind !== "user_input")) return true; + const questionAt = pendingQuestionAt(shell); + // Snoozing an existing question means "ask me later". Only a new question + // wakes it early; missing or invalid timestamps keep the request visible. + if ( + (shell.hasPendingUserInput || request?.kind === "user_input") && + (shell.snoozedAt == null || + questionAt == null || + !(Date.parse(questionAt) <= Date.parse(shell.snoozedAt))) + ) { + return true; + } if (shell.pendingSubagentRequest != null) return true; if ((shell.pendingGuestMessageCount ?? 0) > 0) return true; const failedAt = failureEdgeAt(shell); @@ -124,9 +144,9 @@ export function canSnooze( >, options: { readonly now: string }, ): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - // The server rejects snoozing for any pending request. - if (shell.source?.pendingRuntimeRequest != null) return false; + if (shell.hasPendingApprovals) return false; + const request = shell.source?.pendingRuntimeRequest; + if (request != null && request.kind !== "user_input") return false; if (shell.pendingSubagentRequest != null) return false; // A waiting guest message would wake it at once. if ((shell.pendingGuestMessageCount ?? 0) > 0) return false; @@ -157,14 +177,19 @@ export function threadWokeAt( // indicator the user already cleared by visiting (snoozedUntil is newer // than that visit's lastVisitedAt). if (threadRaisedHandWhileSnoozed(shell)) { - // An old failure must not replace the activity time of an approval or input wake. const failedAt = failureEdgeAt(shell); - if ( + const questionAt = pendingQuestionAt(shell); + const freshFailure = failedAt != null && - (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt)) - ) { + (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt)); + const freshQuestion = + questionAt != null && + shell.snoozedAt != null && + Date.parse(questionAt) > Date.parse(shell.snoozedAt); + if (freshFailure && (!freshQuestion || Date.parse(failedAt) >= Date.parse(questionAt))) { return failedAt; } + if (freshQuestion) return questionAt; const runtime = shell.runtime ?? shell.session ?? null; return runtime?.updatedAt ?? shell.snoozedAt ?? null; } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index f8f01ec6a1d9a..5da3be096509a 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,4 +1,6 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. +import * as DateTime from "effect/DateTime"; +import { RuntimeRequestId } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; @@ -30,6 +32,7 @@ function makeShell(input: { readonly snoozedAt?: string | null; readonly sessionStatus?: "starting" | "running" | "ready" | "error"; readonly pending?: "approval" | "user-input"; + readonly pendingUserInputAt?: string | null; readonly turnRequestedAt?: string | null; readonly turnCompletedAt?: string | null; }): ThreadSnoozeShell { @@ -39,6 +42,7 @@ function makeShell(input: { snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), hasPendingApprovals: input.pending === "approval", hasPendingUserInput: input.pending === "user-input", + pendingUserInputAt: input.pendingUserInputAt ?? null, session: input.sessionStatus === undefined ? null @@ -89,6 +93,64 @@ describe("effectiveSnoozed", () => { ).toBe(false); }); + it("defers an existing question until the timer, but wakes for a new question", () => { + const shell = makeShell({ + snoozedUntil: FUTURE_WAKE, + pending: "user-input", + pendingUserInputAt: SNOOZED_AT, + }); + expect(effectiveSnoozed(shell, { now: NOW })).toBe(true); + expect(threadWokeAt(shell, { now: NOW })).toBeNull(); + expect(effectiveSnoozed(shell, { now: FUTURE_WAKE })).toBe(false); + expect(threadWokeAt(shell, { now: FUTURE_WAKE })).toBe(FUTURE_WAKE); + + const newQuestion = { ...shell, pendingUserInputAt: "2026-04-10T10:00:00.000Z" }; + expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false); + expect(threadWokeAt(newQuestion, { now: NOW })).toBe(newQuestion.pendingUserInputAt); + expect(effectiveSnoozed({ ...shell, pendingUserInputAt: "invalid" }, { now: NOW })).toBe(false); + }); + + it.each([false, true])( + "snoozes existing source questions with blocking=%s and wakes for newer ones", + (blocking) => { + const shell: ThreadSnoozeShell = { + ...makeShell({ snoozedUntil: FUTURE_WAKE }), + source: { + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input", + blocking, + createdAt: DateTime.makeUnsafe(SNOOZED_AT), + }, + }, + }; + expect(canSnooze(shell, { now: NOW })).toBe(true); + expect(effectiveSnoozed(shell, { now: NOW })).toBe(true); + const newQuestion = { + ...shell, + source: { + pendingRuntimeRequest: { + ...shell.source!.pendingRuntimeRequest!, + createdAt: DateTime.makeUnsafe("2026-04-10T10:00:00.000Z"), + }, + }, + }; + expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false); + expect(threadWokeAt(newQuestion, { now: NOW })).toBe("2026-04-10T10:00:00.000Z"); + const approval = { + ...shell, + source: { + pendingRuntimeRequest: { + ...shell.source!.pendingRuntimeRequest!, + kind: "command" as const, + }, + }, + }; + expect(canSnooze(approval, { now: NOW })).toBe(false); + expect(effectiveSnoozed(approval, { now: NOW })).toBe(false); + }, + ); + it.each([ { reason: "stale failure", @@ -182,7 +244,7 @@ describe("threadRaisedHandWhileSnoozed", () => { }); describe("canSnooze", () => { - it("refuses blocked-on-you work", () => { + it("allows deferring user input while refusing approvals and subagent requests", () => { expect( canSnooze({ ...makeShell({ pending: "approval" }), latestUserMessageAt: null }, { now: NOW }), ).toBe(false); @@ -191,7 +253,7 @@ describe("canSnooze", () => { { ...makeShell({ pending: "user-input" }), latestUserMessageAt: null }, { now: NOW }, ), - ).toBe(false); + ).toBe(true); expect( canSnooze( { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" }, @@ -272,6 +334,45 @@ describe("threadWokeAt", () => { expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T11:00:00.000Z"); }); + it.each(["failure", "subagent request"] as const)( + "keeps an old question from masking a newer %s wake", + (wake) => { + const shell = { + ...makeShell({ + snoozedUntil: FUTURE_WAKE, + pending: "user-input", + pendingUserInputAt: SNOOZED_AT, + sessionStatus: wake === "failure" ? "error" : "running", + }), + pendingSubagentRequest: wake === "subagent request" ? ("user_input" as const) : null, + }; + const lastVisitedAt = "2026-04-10T10:00:00.000Z"; + expect(effectiveSnoozed(shell, { now: NOW })).toBe(false); + const wokeAt = threadWokeAt(shell, { now: NOW }); + expect(wokeAt).toBe("2026-04-10T11:00:00.000Z"); + expect(Date.parse(wokeAt!)).toBeGreaterThan(Date.parse(lastVisitedAt)); + }, + ); + + it.each([ + ["error", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"], + ["failed", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"], + ["failed", "2026-04-10T11:30:00.000Z", "2026-04-10T11:30:00.000Z"], + ["running", "2026-04-10T10:00:00.000Z", "2026-04-10T10:00:00.000Z"], + ] as const)( + "uses the latest wake event for a %s runtime and a question at %s", + (status, pendingUserInputAt, expectedWakeAt) => { + const shell = { + ...makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input", pendingUserInputAt }), + runtime: { status, updatedAt: "2026-04-10T11:00:00.000Z" }, + }; + const lastVisitedAt = "2026-04-10T10:30:00.000Z"; + const wokeAt = threadWokeAt(shell, { now: NOW }); + expect(wokeAt).toBe(expectedWakeAt); + expect(Date.parse(wokeAt!) > Date.parse(lastVisitedAt)).toBe(status !== "running"); + }, + ); + it("keeps the early wake authoritative after the scheduled time passes", () => { const shell: ThreadSnoozeShell = { ...makeShell({ snoozedUntil: PAST_WAKE }), From b18434e647cf38e92b6e57262cebd741cfed4745 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 5 Nov 2026 12:58:20 -0600 Subject: [PATCH 012/200] fix(server): repair cutover test crypto import breaking main CI (#31630) --- .../legacy/LegacyV1Cutover.integration.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index fe8e999eec0a2..7edb158f3ba69 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -15,10 +15,9 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import * as Crypto from "effect/Crypto"; +import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -705,11 +704,9 @@ const makeCapturingLogger = (logs: CapturedLog[]) => const sha256File = Effect.fn("LegacyV1Cutover.sha256File")(function* (filePath: string) { const fs = yield* FileSystem.FileSystem; - const crypto = yield* Crypto.Crypto; - return Hex.encode( - yield* crypto.digest("SHA-256", yield* fs.readFile(filePath)).pipe(Effect.orDie), - ); -}, Effect.provide(NodeCrypto.layer)); + const digest = yield* Digest.Digest; + return yield* digest.digestHex(Digest.sha256, yield* fs.readFile(filePath)); +}, Effect.provide(NodeCrypto.layerDigest)); interface LegacyTableSnapshot { readonly columns: ReadonlyArray; From e139768a7e7f53fd36c2ff88b63bc03c71f3fad7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:29:01 -0800 Subject: [PATCH 013/200] feat(server): sends can start a fresh provider session (#30323) Co-authored-by: Claude Opus 5.5 --- .../src/environment/ServerEnvironment.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 237 +++++++++++--- .../SteeringCompletion.integration.test.ts | 8 +- .../ProviderSwitch.integration.test.ts | 290 ++++++++++++++++++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestrationV2.ts | 6 + 6 files changed, 504 insertions(+), 40 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0787a3702c009..82010efdee806 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -268,6 +268,7 @@ export const make = Effect.gen(function* () { ignoredFileListing: true, triage: true, oneTimeSchedules: true, + freshContext: true, npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index daf64a2d438fb..d40ca624a11d2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -45,6 +45,7 @@ import { type OrchestrationV2DelegatedCompletionDelivery, type OrchestrationV2DomainEvent, type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RemoteTaskChild, @@ -998,16 +999,75 @@ function appendContextHandoffId( return handoffId === null ? handoffIds : Array.from(new Set([...handoffIds, handoffId])); } +/** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */ +function contextBoundaryOrdinal( + runs: ReadonlyArray< + Pick< + OrchestrationV2Run, + "ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId" + > + >, +): number { + let boundary = 0; + for (const run of runs) { + if ( + run.freshContext === true && + run.status !== "queued" && + !cancelledBeforeStart(run) && + run.ordinal > boundary + ) { + boundary = run.ordinal; + } + } + return boundary; +} + +/** Threads with no started run (imported, or reserved by another queued run) never cross it. */ +function providerThreadPredatesBoundary( + providerThread: Pick, + boundary: number, +): boolean { + return ( + boundary > 0 && + (providerThread.firstRunOrdinal === null || providerThread.firstRunOrdinal < boundary) + ); +} + +/** A provider that hosts one native thread per session keeps the old conversation alive otherwise. */ +function sessionsReleasedByFreshContext( + projection: Pick, + activeProviderThread: OrchestrationV2ProviderThread | undefined, +): ReadonlyArray { + const sessionId = activeProviderThread?.providerSessionId ?? null; + return projection.providerSessions.filter( + (session) => + session.id === sessionId && + session.status !== "stopped" && + session.status !== "error" && + !session.capabilities.sessions.supportsMultipleProviderThreadsPerSession, + ); +} + +const FRESH_CONTEXT_DETACH_REASON = "Started a fresh session."; + +function uniqueSessions( + sessions: ReadonlyArray, +): ReadonlyArray { + return Array.from(new Map(sessions.map((session) => [session.id, session])).values()); +} + function rootProviderThreadsForProvider( - projection: Pick, + projection: Pick, providerInstanceId: ModelSelection["instanceId"], ): ReadonlyArray { + const boundary = contextBoundaryOrdinal(projection.runs); return projection.providerThreads .filter( (providerThread) => providerThread.providerInstanceId === providerInstanceId && providerThread.appThreadId === projection.thread.id && - providerThread.ownerNodeId === null, + providerThread.ownerNodeId === null && + !providerThreadPredatesBoundary(providerThread, boundary), ) .toSorted( (left, right) => @@ -1807,15 +1867,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); const followsCurrent = followsCurrentConversation(queuedMessage); - const targetProviderThread = followsCurrent - ? activeProviderThread?.providerInstanceId === queuedRun.providerInstanceId + const freshContext = queuedRun.freshContext === true; + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + // A run queued before a fresh run started is bound to the conversation that run left behind. + const followsLatestContext = + !freshContext && + (followsCurrent || providerThreadPredatesBoundary(storedProviderThread, contextBoundary)); + const targetProviderThread = followsLatestContext + ? activeProviderThread?.providerInstanceId === queuedRun.providerInstanceId && + !providerThreadPredatesBoundary(activeProviderThread, contextBoundary) ? activeProviderThread : rootProviderThreadsForProvider(projection, queuedRun.providerInstanceId)[0] - : storedProviderThread; + : freshContext && + (storedProviderThread.firstRunOrdinal !== null || + storedProviderThread.nativeThreadRef !== null) + ? undefined + : storedProviderThread; const queuedProviderThread: OrchestrationV2ProviderThread = targetProviderThread ?? { id: idAllocator.derive.providerThread({ driver: targetAdapter.driver, - nativeThreadId: `pending:${queuedRun.id}`, + // The queue reserved `pending:` for this run; another run may have taken it. + nativeThreadId: freshContext + ? `pending:${queuedRun.id}:fresh` + : `pending:${queuedRun.id}`, }), driver: targetAdapter.driver, providerInstanceId: queuedRun.providerInstanceId, @@ -1870,12 +1944,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ) : null; - const releasableSessions = projection.providerSessions.filter( - (session) => - switchPlan?.releaseProviderSessionIds.includes(session.id) && - session.status !== "stopped" && - session.status !== "error", - ); + const releasableSessions = uniqueSessions([ + ...projection.providerSessions.filter( + (session) => + switchPlan?.releaseProviderSessionIds.includes(session.id) && + session.status !== "stopped" && + session.status !== "error", + ), + ...(freshContext ? sessionsReleasedByFreshContext(projection, activeProviderThread) : []), + ]); const busySessionIds = new Set(); for (const session of releasableSessions) { if (yield* sessionHasBackgroundWork(session, projection.providerThreads)) { @@ -1884,7 +1961,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } // Nobody waits on a queued start to refuse it. A handoff runs in its own session, so a // busy one stays up; a restart resumes the busy session's native thread, so wait instead. - if (busySessionIds.size > 0 && switchPlan?.transition.type === "restart_and_resume") { + // A fresh run resumes nothing, so it never waits. + if ( + busySessionIds.size > 0 && + !freshContext && + switchPlan?.transition.type === "restart_and_resume" + ) { yield* Effect.logInfo( "Holding the queue: the replaced provider session still runs background work", { threadId, runId: queuedRun.id, providerSessionIds: Array.from(busySessionIds) }, @@ -1907,6 +1989,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (session) => !busySessionIds.has(session.id), ); const canResumeAcrossInstances = + !freshContext && switchPlan?.instanceChanged === true && switchPlan.transition.type === "restart_and_resume" && activeProviderThread !== undefined && @@ -1936,6 +2019,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio queuedProviderThread.id, ); const coveredRuns = + freshContext || canResumeAcrossInstances || latestHandoffRun === undefined || latestHandoffRun.providerInstanceId === queuedRun.providerInstanceId @@ -1943,12 +2027,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : projection.runs.filter( (run) => isHandoffSourceRun(run) && + run.ordinal >= contextBoundary && run.ordinal > (targetLastCompletedRun?.ordinal ?? 0) && run.ordinal <= latestHandoffRun.ordinal, ); const needsFullContext = deliveryProviderThread.nativeThreadRef === null; const legacyImportItems = - projection.thread.historyOrigin === "v1_import" + projection.thread.historyOrigin === "v1_import" && contextBoundary === 0 ? yield* readHandoffItems(threadId, [null]) : []; const handoffStrategy = needsFullContext @@ -2153,6 +2238,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { idleCompaction: queuedMessage.idleCompaction }), }), + ...(freshContext ? { freshContext: true } : {}), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", status: "completed", @@ -2244,6 +2330,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ] : []; + const detachReason = freshContext + ? FRESH_CONTEXT_DETACH_REASON + : "Provider or model selection changed."; yield* writeSystemEvents( [ ...(selectionChanged @@ -2344,7 +2433,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: { providerSessionId: session.id, detachedAt: now, - reason: "Provider or model selection changed.", + reason: detachReason, }, })), ...executionIdentityEvents, @@ -2386,7 +2475,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio request: { type: "provider-session.detach" as const, providerSessionId: session.id, - detail: "Provider or model selection changed.", + detail: detachReason, }, })), { @@ -5219,6 +5308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "provider_handoff", }), ); + const contextBoundary = contextBoundaryOrdinal(input.projection.runs); restartHandoff = yield* contextHandoffService .prepareProviderHandoff({ threadId: input.command.threadId, @@ -5228,9 +5318,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio toProviderThreadId: targetProviderThreadBase.id, fromProviderInstanceId: targetRun.providerInstanceId, toProviderInstanceId: input.modelSelection.instanceId, - coveredRunOrdinals: { from: 1, to: targetRun.ordinal }, + coveredRunOrdinals: { from: Math.max(1, contextBoundary), to: targetRun.ordinal }, strategy: "full_thread_summary", - items: yield* readHandoffItems(input.command.threadId), + items: yield* readHandoffItems( + input.command.threadId, + contextBoundary === 0 + ? undefined + : input.projection.runs + .filter((run) => run.ordinal >= contextBoundary) + .map((run) => run.id), + ), runs: input.projection.runs, createdAt: now, }) @@ -5750,6 +5847,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { dispatchMode = { type: "queue_after_active" }; } + const freshContext = command.freshContext === true; + if ( + freshContext && + (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") + ) { + dispatchMode = { type: "queue_after_active" }; + } // Codex and Claude own /goal; elsewhere it is ordinary text that cannot steer. const parsedGoalCommand = parseGoalCommand(command); const goalCommand = @@ -5849,12 +5953,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // behind a long parent turn is never seen. Never interrupt/restart a turn // for a notification. if ( - delegatedCompletion !== undefined + !freshContext && + (delegatedCompletion !== undefined ? delegatedCompletion.taskIds.every( (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", ) - : command.notification?.source.kind === "delegated_task" + : command.notification?.source.kind === "delegated_task") ) { const active = projection.runs.find((run) => run.status === "running"); const providerThread = projection.providerThreads.find( @@ -6040,8 +6145,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const targetProviderThread = - modelSelection.instanceId === queueProviderThread.providerInstanceId + const targetProviderThread = freshContext + ? undefined + : modelSelection.instanceId === queueProviderThread.providerInstanceId ? queueProviderThread : rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0]; const queuedAdapter = yield* providerAdapters @@ -6160,6 +6266,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), + ...(freshContext ? { freshContext: true } : {}), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -6349,12 +6456,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + if ( + freshContext && + (pendingForkTransfer !== undefined || pendingMergeBackTransfer !== undefined) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A fresh session can't start while a fork or merge is still being applied.", + }); + } const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); const legacyImportItems = - projection.thread.historyOrigin === "v1_import" + projection.thread.historyOrigin === "v1_import" && contextBoundary === 0 ? yield* readHandoffItems(command.threadId, [null]) : []; const isProviderSwitch = @@ -6380,22 +6498,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(mapDispatchError(command)) : null; const canResumeAcrossInstances = + !freshContext && isProviderSwitch && activeProviderThread.nativeThreadRef !== null && providerSwitchPlan?.transition.type === "restart_and_resume"; - const sessionsToDetach = projection.providerSessions.filter( - (session) => - providerSwitchPlan?.releaseProviderSessionIds.includes(session.id) && - session.status !== "stopped" && - session.status !== "error", - ); + const sessionsToDetach = uniqueSessions([ + ...projection.providerSessions.filter( + (session) => + providerSwitchPlan?.releaseProviderSessionIds.includes(session.id) && + session.status !== "stopped" && + session.status !== "error", + ), + ...(freshContext ? sessionsReleasedByFreshContext(projection, activeProviderThread) : []), + ]); for (const detaching of sessionsToDetach) { if (yield* sessionHasBackgroundWork(detaching, projection.providerThreads)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: - "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", + cause: freshContext + ? "The current session is still running background agents or commands, and a fresh session would end them. Wait for them to finish, or press Stop, then send the message again." + : "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", }); } } @@ -6403,8 +6526,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( pendingForkTransfer === undefined && pendingMergeBackTransfer === undefined && - !isProviderSwitch + (!isProviderSwitch || freshContext) ) { + const reusableProviderThread = freshContext ? undefined : activeProviderThread; const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( Effect.mapError( (cause) => @@ -6416,7 +6540,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); const providerSessionId = - activeProviderThread?.providerSessionId ?? + reusableProviderThread?.providerSessionId ?? (yield* mapDispatchError(command)( providerSessionIdFor({ adapter, @@ -6425,7 +6549,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), )); const providerThreadId = - activeProviderThread?.id ?? + reusableProviderThread?.id ?? idAllocator.derive.providerThread({ driver: adapter.driver, nativeThreadId: `pending:${runId}`, @@ -6447,7 +6571,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(mapDispatchError(command)) : null; const providerThread: OrchestrationV2ProviderThread = - activeProviderThread === undefined + reusableProviderThread === undefined ? { id: providerThreadId, driver: adapter.driver, @@ -6466,11 +6590,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, } : { - ...activeProviderThread, + ...reusableProviderThread, providerSessionId, lastRunOrdinal: ordinal, handoffIds: appendContextHandoffId( - activeProviderThread.handoffIds, + reusableProviderThread.handoffIds, legacyImportHandoff?.id ?? null, ), updatedAt: now, @@ -6526,6 +6650,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : {}), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), ...wakeWorkStartedAt(projection.runs, command), + ...(freshContext ? { freshContext: true } : {}), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -6607,6 +6732,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: dispatchText, ...(command.context ? { context: command.context } : {}), attachments: command.attachments, + ...(freshContext ? { freshContext: true } : {}), ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}), }; const preparationTurnItem: OrchestrationV2TurnItem | null = @@ -6724,6 +6850,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: preparationTurnItem, }); } + for (const session of sessionsToDetach) { + if (session.id === providerSessionId) continue; + yield* emitEvent({ + type: "provider-session.detached", + threadId: command.threadId, + driver: session.driver, + providerInstanceId: session.providerInstanceId, + occurredAt: now, + payload: { + providerSessionId: session.id, + detachedAt: now, + reason: FRESH_CONTEXT_DETACH_REASON, + }, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:provider-session.detach:${session.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-session.detach", + providerSessionId: session.id, + detail: FRESH_CONTEXT_DETACH_REASON, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + } const pendingEffect = { id: `effect:${command.commandId}:provider-turn.start:${runId}`, commandId: command.commandId, @@ -7010,6 +7164,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : projection.runs.filter( (run) => isHandoffSourceRun(run) && + run.ordinal >= contextBoundary && run.ordinal > (requiresFullProviderSwitchContext ? 0 : (targetLastCompletedRun?.ordinal ?? 0)) && run.ordinal <= latestHandoffRun.ordinal, @@ -9681,6 +9836,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio now, }); } + // Skipping compaction starts the message as its own turn; steering would join the current one. + if (queuedRun.freshContext === true) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A message that starts a fresh session can't steer the current turn.", + }); + } if (goalCommand !== null) { yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); const goal = projection.providerThreads.find( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index bd3f4e5b2e7d8..2b08c0b8345b6 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1511,7 +1511,7 @@ it.effect("queues a plain send behind the stopped queue and releases its hold", const goalHarness = Effect.fn("goalHarness")(function* (name: string) { const harness = yield* nextTurnSelectionHarness(name); - const send = (id: string, text: string, delivery: "queue" | "auto") => + const send = (id: string, text: string, delivery: "queue" | "auto", freshContext = false) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ @@ -1522,6 +1522,7 @@ const goalHarness = Effect.fn("goalHarness")(function* (name: string) { text, attachments: [], modelSelection: runSelection, + ...(freshContext ? { freshContext } : {}), ...(delivery === "queue" ? { dispatchMode: { type: "queue_after_active" } } : { dispatchMode: { type: "start_immediately" }, deliveryIntent: "auto" }), @@ -1627,7 +1628,8 @@ it.effect("promotes a queued goal by stopping the running turn", () => ), ); -it.effect.each(["next", "/goal ship it"])( +// A fresh message skips compaction as its own turn, which steering a running turn would not. +it.effect.each(["next", "/goal ship it", "fresh next"])( "promoting %s into a compaction stops it and sends it next", (promoted) => Effect.scoped( @@ -1641,7 +1643,7 @@ it.effect.each(["next", "/goal ship it"])( const threadId = yield* startFirstTurn; yield* send("compact", "/compact", "queue"); yield* send("queued", "queued", "queue"); - yield* send("next", promoted, "queue"); + yield* send("next", promoted, "queue", promoted.startsWith("fresh")); const settled = yield* orchestrator.streamDomainEvents.pipe( Stream.filter( (event) => diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 629b3902e763d..e9b2636998386 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2767,6 +2767,296 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live.each(["immediately", "behind a running turn"] as const)( + "starts a fresh provider session %s and never hands earlier runs to a later provider", + (variant) => + Effect.scoped( + Effect.gen(function* () { + const queued = variant !== "immediately"; + const key = queued ? "queued" : "immediate"; + const cwd = yield* checkpointWorkspace(`fresh-context-${key}`); + const capturedTurns = yield* Ref.make>([]); + const closedSessions = yield* Ref.make>([]); + // A queued start has nobody to refuse, so a busy session must stay up. + const backgroundWork = yield* Ref.make(queued); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + closedSessions, + backgroundWork, + nativeThreadGeneration: yield* Ref.make(0), + ...(queued + ? { holdFirstTurn: started, releaseFirstTurn: release, holdRunOrdinal: 2 } + : {}), + }), + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + }), + ]); + const freshThreadId = ThreadId.make(`thread:fresh-context:${key}`); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = ( + name: string, + modelSelection: ModelSelection, + options: { readonly freshContext?: boolean } = {}, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:fresh-context:${key}:${name}`), + threadId: freshThreadId, + messageId: MessageId.make(`message:fresh-context:${key}:${name}`), + text: `Prompt ${name}`, + attachments: [], + modelSelection, + ...options, + // A fresh send must never steer the running turn. + deliveryIntent: "auto", + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:fresh-context:${key}:create`), + threadId: freshThreadId, + projectId: ProjectId.make(`project:fresh-context:${key}`), + title: "Fresh context", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + // Codex keeps a native thread from before the fresh run that must not resume. + yield* send("zero", CODEX_MODEL_SELECTION); + yield* worker.drain(); + yield* waitForIdle(freshThreadId); + yield* send("alpha", CLAUDE_MODEL_SELECTION); + if (queued) { + yield* Deferred.await(started); + yield* worker.drain(); + } else { + yield* worker.drain(); + yield* waitForIdle(freshThreadId); + } + const firstSession = (yield* orchestrator.getThreadProjection( + freshThreadId, + )).providerSessions.find( + (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* send("beta", CLAUDE_MODEL_SELECTION, { freshContext: true }); + if (queued) { + // Queued before the fresh run started, so it is bound to the old conversation. + yield* send("gamma", CLAUDE_MODEL_SELECTION); + const waiting = yield* orchestrator.getThreadProjection(freshThreadId); + assert.deepEqual( + waiting.runs.map((run) => run.status), + ["completed", "running", "queued", "queued"], + ); + // Steering would send it into the conversation it is meant to leave. + const promoted = yield* orchestrator + .dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make(`command:fresh-context:${key}:promote`), + threadId: freshThreadId, + queuedRunId: waiting.runs.find((run) => run.freshContext === true)!.id, + targetRunId: waiting.runs.find((run) => run.status === "running")!.id, + }) + .pipe(Effect.flip); + assert.instanceOf(promoted, Orchestrator.OrchestratorDispatchError); + assert.include(String(promoted.cause), "fresh session"); + yield* Deferred.succeed(release, undefined); + } + yield* worker.drain(); + yield* waitForIdle(freshThreadId); + // Claude hosts one native thread per session, so an idle old one is closed. + assert.deepEqual(yield* Ref.get(closedSessions), queued ? [] : [firstSession.id]); + yield* Ref.set(backgroundWork, false); + yield* send("delta", CODEX_MODEL_SELECTION); + yield* worker.drain(); + yield* waitForIdle(freshThreadId); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(freshThreadId); + const turns = yield* Ref.get(capturedTurns); + const turnFor = (name: string) => + turns.find((turn) => turn.text.endsWith(`Prompt ${name}`))!; + + const freshRun = projection.runs.find( + (run) => run.userMessageId === `message:fresh-context:${key}:beta`, + )!; + assert.isTrue(freshRun.freshContext); + assert.equal(freshRun.contextHandoffId, null); + assert.isTrue( + projection.turnItems.some( + (item) => + item.type === "user_message" && + item.messageId === freshRun.userMessageId && + item.freshContext === true, + ), + ); + assert.notEqual(turnFor("beta").providerThreadId, turnFor("alpha").providerThreadId); + assert.notEqual(turnFor("beta").nativeThreadId, turnFor("alpha").nativeThreadId); + assert.equal(turnFor("beta").text, "Prompt beta"); + if (queued) { + assert.equal(turnFor("gamma").providerThreadId, turnFor("beta").providerThreadId); + } + const codexTurn = turnFor("delta"); + assert.notEqual(codexTurn.providerThreadId, turnFor("zero").providerThreadId); + assert.include(codexTurn.text, "Prompt beta"); + assert.notInclude(codexTurn.text, "Prompt alpha"); + assert.notInclude(codexTurn.text, "Prompt zero"); + assert.equal(projection.contextHandoffs.at(-1)!.coveredRunOrdinals.from, 3); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: `fresh-context-${key}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + + it.live("keeps the thread a queued fresh run reserved for that run", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("fresh-context-reserved"); + const capturedTurns = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + holdFirstTurn: started, + releaseFirstTurn: release, + holdRunOrdinal: 2, + }), + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + const reservedThreadId = ThreadId.make("thread:fresh-context-reserved"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = ( + name: string, + modelSelection: ModelSelection, + options: { readonly freshContext?: boolean } = {}, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:fresh-context-reserved:${name}`), + threadId: reservedThreadId, + messageId: MessageId.make(`message:fresh-context-reserved:${name}`), + text: `Prompt ${name}`, + attachments: [], + modelSelection, + ...options, + dispatchMode: { type: "queue_after_active" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:fresh-context-reserved:create"), + threadId: reservedThreadId, + projectId: ProjectId.make("project:fresh-context-reserved"), + title: "Fresh context reserved", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* send("zero", CODEX_MODEL_SELECTION); + yield* worker.drain(); + yield* waitForIdle(reservedThreadId); + yield* send("alpha", CLAUDE_MODEL_SELECTION); + yield* Deferred.await(started); + yield* worker.drain(); + // Queued while no fresh run has started: gamma binds to zero's Codex thread, and + // delta reserves its own. Once beta starts, gamma must not take delta's thread. + yield* send("beta", CLAUDE_MODEL_SELECTION, { freshContext: true }); + yield* send("gamma", CODEX_MODEL_SELECTION); + yield* send("delta", CODEX_MODEL_SELECTION, { freshContext: true }); + yield* Deferred.succeed(release, undefined); + yield* worker.drain(); + yield* waitForIdle(reservedThreadId); + yield* worker.drain(); + const turns = yield* Ref.get(capturedTurns); + const turnFor = (name: string) => + turns.find((turn) => turn.text.endsWith(`Prompt ${name}`))!; + + assert.notEqual(turnFor("gamma").providerThreadId, turnFor("zero").providerThreadId); + assert.notEqual(turnFor("delta").providerThreadId, turnFor("gamma").providerThreadId); + assert.notEqual(turnFor("delta").nativeThreadId, turnFor("gamma").nativeThreadId); + assert.equal(turnFor("delta").text, "Prompt delta"); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "fresh-context-reserved", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + // regression: #21314 a queued PR update must follow the provider that took over the thread it.live.each([ ["automatic", "Claude"], diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9ac89ef499b3f..e7a34dfe675bd 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -168,6 +168,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ triage: Schema.optionalKey(Schema.Boolean), /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), + /** Server honors `freshContext` on sends; older servers ignore it and keep the old context. */ + freshContext: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 5eb2b6ed4bfdd..6e70cc66a7203 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -705,6 +705,8 @@ export const OrchestrationV2Run = Schema.Struct({ ), delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort), workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), + /** Starts a new provider conversation; earlier runs stay visible but never reach the provider. */ + freshContext: Schema.optional(Schema.Boolean), }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; @@ -1607,6 +1609,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ senderThreadId: Schema.optional(ThreadId), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript, + freshContext: OrchestrationV2Run.fields.freshContext, inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, /** Same as the text of message `messageId`. */ @@ -2443,6 +2446,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ senderThreadId: Schema.optional(ThreadId), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript, + freshContext: OrchestrationV2Run.fields.freshContext, inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), @@ -3152,6 +3156,8 @@ export const OrchestrationV2Command = Schema.Union([ scheduledTaskId: Schema.optional(ScheduledTaskId), senderThreadId: Schema.optional(ThreadId), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), + /** Start a new provider session without the thread's earlier context. Never steers. */ + freshContext: Schema.optional(Schema.Boolean), commandId: CommandId, threadId: ThreadId, messageId: MessageId, From 99e361e4153fdf9fd361c5c0df279a3e5f2c56c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:29:01 -0800 Subject: [PATCH 014/200] feat: /clear starts a fresh session in the same thread (#30325) Co-authored-by: Claude Opus 5.5 --- .../features/threads/FreshSessionNotice.tsx | 20 +++++ .../src/features/threads/ThreadComposer.tsx | 3 + .../features/threads/ThreadDetailScreen.tsx | 20 ++++- .../src/features/threads/ThreadFeed.tsx | 9 ++ .../features/threads/ThreadQueueControl.tsx | 5 +- .../threads/use-composer-command-menu.test.ts | 19 +++++ .../threads/use-composer-command-menu.ts | 25 +++++- apps/mobile/src/state/thread-outbox-model.ts | 3 + .../src/state/thread-task-permissions.test.ts | 84 ++++++++++++++++++- .../threadOutboxDelivery.permissions.test.ts | 1 + apps/mobile/src/state/threadOutboxDelivery.ts | 2 + apps/mobile/src/state/use-composer-drafts.ts | 35 +++++++- .../src/state/use-thread-composer-state.ts | 23 +++++ apps/server/src/mcp/OrchestratorMcpService.ts | 1 + .../ThreadLaunchService.test.ts | 15 ++++ .../ThreadManagementService.ts | 20 +++-- .../src/provider/agentContextBudget.json | 2 +- .../web/src/components/ChatView.logic.test.ts | 14 ++++ apps/web/src/components/ChatView.logic.ts | 11 ++- apps/web/src/components/ChatView.tsx | 20 +++++ apps/web/src/components/chat/ChatComposer.tsx | 13 +++ .../components/chat/ComposerFreshSession.tsx | 16 ++++ .../src/components/chat/QueuedRunsControl.tsx | 17 +++- .../chat/TimelineUserMessageRow.tsx | 3 +- .../src/components/chat/V2LifecycleRow.tsx | 6 ++ .../chat/composerSlashCommandSearch.ts | 7 +- .../src/components/chat/freshSessionArms.ts | 30 +++++++ .../components/chat/useComposerMenuItems.ts | 18 +++- .../src/components/chat/useComposerSend.ts | 35 ++++++-- apps/web/src/composer-logic.test.ts | 2 +- apps/web/src/composer-logic.ts | 4 +- docs/user/composer.md | 6 ++ docs/user/providers-antigravity.md | 2 +- evals/mcp-tool-use/cases/orchestration.ndjson | 3 +- .../client-runtime/src/operations/commands.ts | 4 + packages/contracts/src/orchestratorMcp.ts | 6 ++ packages/shared/src/composerTrigger.ts | 5 ++ 37 files changed, 471 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/threads/FreshSessionNotice.tsx create mode 100644 apps/web/src/components/chat/ComposerFreshSession.tsx create mode 100644 apps/web/src/components/chat/freshSessionArms.ts diff --git a/apps/mobile/src/features/threads/FreshSessionNotice.tsx b/apps/mobile/src/features/threads/FreshSessionNotice.tsx new file mode 100644 index 0000000000000..43ad7e6f32b75 --- /dev/null +++ b/apps/mobile/src/features/threads/FreshSessionNotice.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; + +import { Notice } from "../../components/ui/Notice"; +import { setFreshSessionPending, useComposerDraftSettings } from "../../state/use-composer-drafts"; + +/** Shown while `/clear` is armed; dismissing it keeps the current session. */ +export function FreshSessionNotice(props: { readonly threadKey: string }) { + if (useComposerDraftSettings(props.threadKey).freshContext !== true) return null; + return ( + + setFreshSessionPending(props.threadKey, false)} + dismissLabel="Keep the current session" + /> + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f68f9787f49d4..0de134de7c227 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -215,6 +215,8 @@ export interface ThreadComposerProps { readonly selectedThread: EnvironmentThreadShell; readonly reportedModelSelection?: ModelSelection | null; readonly hasCompactableConversation: boolean; + /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ + readonly onFreshSession?: () => void; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; @@ -561,6 +563,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps offersUsageLimits: usageLimitsOffered, onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, + ...(props.onFreshSession === undefined ? {} : { onFreshSession: props.onFreshSession }), }); const voiceInput = useVoiceInputController({ ownerKey: composerDraftKey, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c7a37b2938fdf..0d3258dd3bee7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -129,7 +129,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; -import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; +import { getComposerDraftSnapshot, setFreshSessionPending } from "../../state/use-composer-drafts"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { ANDROID_READING_MAX_WIDTH, @@ -174,6 +174,7 @@ import type { import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice"; +import { FreshSessionNotice } from "./FreshSessionNotice"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -890,6 +891,15 @@ function ThreadDetailBody( jumpMessageLoaded, selectedThreadKey, ]); + // A queued-run edit, question or approval owns the composer, and its send carries no fresh session. + const freshSessionAvailable = + !props.draftMode && + props.canOperateThread && + props.queuedRunEdit === null && + props.activePendingApproval === null && + props.activePendingUserInput === null && + props.selectedThread.latestRun != null && + props.serverConfig?.environment.capabilities.freshContext === true; const hasCompactableConversation = selectedThreadFeed.some( (entry) => @@ -1638,6 +1648,9 @@ function ThreadDetailBody( onDismiss={() => props.onDismissFeedback(submission.id)} /> ))} + {freshSessionAvailable ? ( + + ) : null} {props.canOperateThread ? ( setFreshSessionPending(selectedThreadKey, true), + } + : {})} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1abfb6061a6bb..a15037a5bc60e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3730,6 +3730,15 @@ export const ThreadFeed = memo(function ThreadFeed({ workspaceRoot={props.workspaceRoot} > + {info.item.type === "message" && + info.item.message.projectedItem?.item.type === "user_message" && + info.item.message.projectedItem.item.freshContext ? ( + + ) : null} {info.item.type === "message" && !isCompactionCommandFeedEntry(info.item) && info.item.message.projectedItem?.item.type === "user_message" && diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 3d26799c45036..82c7df56da291 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -238,7 +238,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { {queuedRuns.map(({ run, text, attachments, context, message }, index) => { const controls = resolveThreadQueueRowControls({ busy: busyRunId !== null, - canPromoteToSteer: workflow?.canPromoteToSteer ?? false, + // A fresh message starts its own turn: it may skip compaction, but not steer into a turn. + canPromoteToSteer: + (workflow?.canPromoteToSteer ?? false) && + (run.freshContext !== true || workflow?.promoteSkipsCompaction === true), canSendNow: workflow?.canSendNow ?? false, editBlocked: editBlockedReason !== null, isEditing: editing?.runId === run.id, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index c0044a74f7944..253c8d9e806ee 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -94,6 +94,25 @@ describe("mobile slash commands", () => { }, ); + it("offers T3's /clear in place of the provider's, only at the message start", () => { + const claude = { + driver: ProviderDriverKind.make("claudeAgent"), + slashCommands: [{ name: "clear", description: "Clear conversation history" }], + }; + const build = (offersFreshSession: boolean, atMessageStart = true) => + buildComposerSlashCommandItems({ + query: "cle", + atMessageStart, + hasThread: true, + allowInteractionMode: true, + offersFreshSession, + selectedProviderStatus: claude, + }).map((item) => item.id); + expect(build(true)).toEqual(["cmd:clear"]); + expect(build(false)).toEqual(["pcmd:clear"]); + expect(build(true, false)).toEqual([]); + }); + it("still applies the T3 plan command for supported providers", () => { const items = buildComposerSlashCommandItems({ query: "plan", diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 72987599f48f6..4363f26ad3b52 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -104,6 +104,8 @@ export function buildComposerSlashCommandItems(input: { readonly hasThread: boolean; readonly hasCompactableConversation?: boolean; readonly offersUsageLimits?: boolean; + /** T3's `/clear` replaces the provider's, which would clear a session T3 still treats as live. */ + readonly offersFreshSession?: boolean; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -141,10 +143,20 @@ export function buildComposerSlashCommandItems(input: { ); // Providers expand commands only at the start of a message. T3 commands - // change local state and do not have this restriction. + // change local state and do not have this restriction, except `/clear`. if (!input.atMessageStart) return items; + if (input.offersFreshSession && "clear".includes(query)) { + items.push({ + id: "cmd:clear", + type: "slash-command", + command: "clear", + label: "/clear", + description: "Start a fresh session", + }); + } for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; + if (command.name === "clear" && input.offersFreshSession) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { continue; @@ -225,6 +237,7 @@ export function useComposerCommandMenu({ onChangeDraftMessage, onUpdateInteractionMode, onUsageLimits, + onFreshSession, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -246,6 +259,8 @@ export function useComposerCommandMenu({ readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; readonly onUsageLimits?: () => void; + /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ + readonly onFreshSession?: () => void; }) { const [localSelection, setLocalSelection] = useState(() => ({ ownerKey, @@ -349,6 +364,7 @@ export function useComposerCommandMenu({ hasThread, hasCompactableConversation, offersUsageLimits, + offersFreshSession: onFreshSession !== undefined, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus: selectedProviderStatus ? { @@ -582,6 +598,13 @@ export function useComposerCommandMenu({ return; } + if (item.type === "slash-command" && item.command === "clear" && onFreshSession) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onFreshSession(); + return; + } if ( item.type === "provider-slash-command" && item.command.name === USAGE_LIMITS_COMMAND.name && diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 703d9f8450fdb..4796ec19f27ec 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -57,6 +57,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), compactBeforeSend: Schema.optional(Schema.Boolean), + freshContext: Schema.optional(Schema.Boolean), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), creation: Schema.optional(QueuedThreadCreationSchema), @@ -92,6 +93,8 @@ export interface QueuedThreadMessage { readonly dispatchMode?: ComposerDispatchMode; /** Sends "When you return"'s `/compact` ahead of the message, which queues behind it. */ readonly compactBeforeSend?: boolean; + /** Starts a new provider session (`/clear`). */ + readonly freshContext?: boolean; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index adf72cc6527b1..c1f83f5a6202a 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -19,6 +19,7 @@ import { import type { DraftComposerImageAttachment } from "../lib/composerImages"; import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; import { sendApprovalFeedback } from "@t3tools/client-runtime/state/thread-requests"; +import { DateTime } from "effect"; import { AsyncResult, type Atom } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -34,6 +35,9 @@ const state = vi.hoisted(() => ({ projection: null as OrchestrationV2ThreadProjection | null, pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), + freshPending: false, + compactDue: false, + setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), answer: vi.fn(), @@ -54,7 +58,7 @@ const state = vi.hoisted(() => ({ runtimeMode: "full-access", interactionMode: "default", runtime: null, - latestRun: null, + latestRun: null as unknown, }, })); @@ -70,6 +74,10 @@ vi.mock("react", () => ({ ], })); vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); +vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ + ...(await importOriginal()), + shouldCompactBeforeSend: () => state.compactDue, +})); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ @@ -142,6 +150,8 @@ vi.mock("./use-composer-drafts", async () => { composerDraftsAtom: Atom.make({}), composerContextImportsAtom: Atom.make({}), getComposerDraftSnapshot: () => state.draft, + isFreshSessionPending: () => state.freshPending, + setFreshSessionPending: state.setFreshPending, clearComposerDraftContent: state.clearDraft, clearComposerDraft: state.clearDraft, setComposerDraftText: (_key: string, text: string) => { @@ -181,6 +191,9 @@ beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.connectionState = "connected"; state.draft = { text: "Keep my draft", attachments: [], modelSelection: undefined }; + state.freshPending = false; + state.compactDue = false; + state.thread.latestRun = null; state.projection = null; state.serverConfig = { providers: [{ instanceId: "codex", driver: "codex", enabled: true }], @@ -254,6 +267,75 @@ describe("mobile task permissions", () => { }); }); + it("keeps an armed fresh session for the message after an approval redirect", async () => { + state.grantedEnvironments.add("secondary"); + state.freshPending = true; + const composer = useThreadComposerState(); + expect(await composer.onSendMessage(undefined, async () => true)).toBe("message"); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ freshContext: true }), + ); + expect(state.setFreshPending).not.toHaveBeenCalled(); + expect(await composer.onSendMessage()).toBe("message"); + expect(state.enqueue).toHaveBeenLastCalledWith(expect.objectContaining({ freshContext: true })); + expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false); + }); + + it("compacts a stale thread before sending unless a fresh session is armed", async () => { + state.grantedEnvironments.add("secondary"); + state.projection = { + thread: { + ...state.thread, + activeProviderThreadId: null, + lineage: { rootThreadId: "thread", parentThreadId: null, relationshipToParent: null }, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: DateTime.makeUnsafe(0), + } as unknown as OrchestrationV2ThreadProjection; + state.compactDue = true; + const composer = useThreadComposerState(); + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.objectContaining({ compactBeforeSend: true }), + ); + state.freshPending = true; + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + expect(state.enqueue).toHaveBeenLastCalledWith(expect.objectContaining({ freshContext: true })); + }); + + it("sends a typed /clear with an approval redirect as that reply's text", async () => { + state.grantedEnvironments.add("secondary"); + state.serverConfig = { + ...state.serverConfig, + environment: { capabilities: { freshContext: true } }, + } as ServerConfig; + state.thread.latestRun = { runId: "run" }; + state.draft = { ...state.draft, text: "/clear" }; + const composer = useThreadComposerState(); + const beforeDispatch = vi.fn(async () => "responded" as const); + expect(await composer.onSendMessage(undefined, beforeDispatch)).toBeNull(); + expect(beforeDispatch).toHaveBeenCalledWith("/clear", false); + expect(state.setFreshPending).not.toHaveBeenCalled(); + }); + it("rechecks a retained send callback before clearing or queueing the draft", async () => { state.grantedEnvironments.add("secondary"); const composer = useThreadComposerState(); diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index fdd5c71094161..3222101bf927a 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -151,6 +151,7 @@ vi.mock("./use-composer-drafts", async () => { state.draftText += `\n${input.text}`; }), replaceComposerDraftAttachments: vi.fn(), + setFreshSessionPending: vi.fn(), undoComposerDraftMerge: vi.fn(), updateComposerDraftSettings: vi.fn(), waitForComposerDraftsLoaded: vi.fn(), diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 44bd564ef8969..9b2da676eee44 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -449,6 +449,7 @@ export async function restoreRejectedQueuedMessage( ...(queuedMessage.modelSelection ? { modelSelection: queuedMessage.modelSelection } : {}), ...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}), ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}), + ...(queuedMessage.freshContext ? { freshContext: true } : {}), ...(queuedMessage.creation ? { workspaceSelection: { @@ -933,6 +934,7 @@ export function createThreadOutboxDelivery(): { interactionMode: sendSettings.interactionMode, createdAt: queuedMessage.createdAt, dispatchMode: queuedMessage.dispatchMode ?? "start", + ...(queuedMessage.freshContext ? { freshContext: true } : {}), }, }); if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 07da494625e4c..ed7a79c1f7a11 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -403,6 +403,8 @@ export interface ComposerDraft { readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; + /** `/clear` armed this draft: its next message starts a fresh provider session. */ + readonly freshContext?: boolean; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; readonly project?: ComposerDraftProject; } @@ -436,7 +438,12 @@ export interface ComposerDraftWorkspaceSelection { export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project" + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "freshContext" + | "workspaceSelection" + | "project" >; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ @@ -468,6 +475,7 @@ const ComposerDraftSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), + freshContext: Schema.optional(Schema.Boolean), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), project: Schema.optional(ComposerDraftProjectSchema), }); @@ -647,6 +655,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.modelSelection === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && + draft.freshContext !== true && draft.workspaceSelection === undefined ); } @@ -1532,6 +1541,15 @@ export function updateComposerDraftSettings( }); } +export function setFreshSessionPending(draftKey: string, pending: boolean): void { + if ((getComposerDraftSnapshot(draftKey).freshContext === true) === pending) return; + updateComposerDraftSettings(draftKey, { freshContext: pending ? true : undefined }); +} + +export function isFreshSessionPending(draftKey: string): boolean { + return getComposerDraftSnapshot(draftKey).freshContext === true; +} + export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; @@ -1777,6 +1795,7 @@ export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): bool a.modelSelection === b.modelSelection && a.runtimeMode === b.runtimeMode && a.interactionMode === b.interactionMode && + a.freshContext === b.freshContext && a.workspaceSelection === b.workspaceSelection ); } @@ -1802,7 +1821,12 @@ export function undoComposerDraftMergeState( .map((attachment) => attachment.id), ); const undoSetting = < - K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection", + K extends + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "freshContext" + | "workspaceSelection", >( key: K, ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]); @@ -1822,6 +1846,7 @@ export function undoComposerDraftMergeState( modelSelection: undoSetting("modelSelection"), runtimeMode: undoSetting("runtimeMode"), interactionMode: undoSetting("interactionMode"), + freshContext: undoSetting("freshContext"), workspaceSelection: undoSetting("workspaceSelection"), }; return withComposerDraft(current, draftKey, draft); @@ -2054,7 +2079,7 @@ export const composerDraftUploadSourcesAtom = Atom.make((get): ComposerDraftUplo type ComposerDraftSettings = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" + "modelSelection" | "runtimeMode" | "interactionMode" | "freshContext" >; const EMPTY_DRAFT_SETTINGS: ComposerDraftSettings = {}; const composerDraftSettingsAtom = Atom.family((draftKey: string) => @@ -2064,13 +2089,15 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) => modelSelection: draft.modelSelection, runtimeMode: draft.runtimeMode, interactionMode: draft.interactionMode, + freshContext: draft.freshContext, }; }).pipe( Atom.withEquality( (a: ComposerDraftSettings, b: ComposerDraftSettings) => a.modelSelection === b.modelSelection && a.runtimeMode === b.runtimeMode && - a.interactionMode === b.interactionMode, + a.interactionMode === b.interactionMode && + a.freshContext === b.freshContext, ), ), ); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 75b3b0df01fde..d10f3fb48c9e4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -72,14 +72,17 @@ import { clearComposerDraftContent, composerContextImportsAtom, getComposerDraftSnapshot, + isFreshSessionPending, mergeComposerDraftContent, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, + setFreshSessionPending, updateComposerDraftSettings, useComposerDraft, useComposerDraftSettings, } from "./use-composer-drafts"; +import { isClearComposerCommand } from "@t3tools/shared/composerTrigger"; import { resolveComposerDispatchMode, type ActiveTurnComposerAction, @@ -554,6 +557,19 @@ export function useThreadComposerState() { if (text.length === 0 && attachments.length === 0) { return null; } + // A redirect's text answers its approval, so /clear there is that text, not the command. + if ( + beforeDispatch === undefined && + selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.freshContext === true && + thread.latestRun != null && + attachments.length === 0 && + draft.context === undefined && + isClearComposerCommand(text) + ) { + setFreshSessionPending(threadKey, true); + clearComposerDraftContent(threadKey); + return null; + } // Recovery preserves over-cap attachments; require trimming or outbox delivery would reject indefinitely. if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { Dialog.alert( @@ -672,8 +688,12 @@ export function useThreadComposerState() { ? "queue" : "auto"; + // A redirect belongs to the approval it answers; the armed session waits for the next message. + const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); const metadata = makeQueuedMessageMetadata(); + // A fresh session drops the context a compaction would summarize. const compactBeforeSend = + !freshContext && followUpDispatchMode === null && selectedEnvironmentRuntime?.connectionState === "connected" && selectedThreadProjection !== null && @@ -714,9 +734,11 @@ export function useThreadComposerState() { : followUpDispatchMode === null ? {} : { dispatchMode: followUpDispatchMode }), + ...(freshContext ? { freshContext: true } : {}), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); + if (freshContext) setFreshSessionPending(threadKey, false); enqueuePromise.then( () => scheduleUnusedComposerAttachmentCleanup(attachments), (error: unknown) => { @@ -727,6 +749,7 @@ export function useThreadComposerState() { attachments: [], }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); + if (freshContext) setFreshSessionPending(threadKey, true); setThreadComposerError( threadKey, error instanceof Error ? error.message : "Failed to save the queued message.", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e53ed03a7c6fa..36fe752eb8f96 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -3133,6 +3133,7 @@ const make = Effect.gen(function* () { text: input.message, attachments: [], mode, + ...(input.freshContext ? { freshContext: true } : {}), createdBy: "agent", creationSource: "mcp", }) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 993ee5c3010f7..23b46afceca58 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -521,6 +521,21 @@ it.effect("retains automation and sender attribution while a message waits in th assert.equal(message?.scheduledTaskId, scheduledTaskId); assert.equal(message?.senderThreadId, senderThreadId); assert.equal(message?.text, "Run the audit"); + // A fresh send queues instead of steering, so it needs no steerable run. + const fresh = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make("command:automation:queued-fresh"), + threadId: launched.threadId, + messageId: MessageId.make("message:automation:queued-fresh"), + text: "Start over", + attachments: [], + mode: "steer", + freshContext: true, + createdBy: "agent", + creationSource: "mcp", + }); + assert.equal(fresh.delivery, "queued"); + assert.isTrue(fresh.run.freshContext); }).pipe(Effect.provide(harness.layer)); }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7aff0137562f3..b6c1e27120ae8 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -146,6 +146,8 @@ export interface ThreadManagementSendInput { readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly mode: ThreadManagementSendMode; + /** Start a new provider session; the orchestrator queues it instead of steering. */ + readonly freshContext?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; } @@ -820,26 +822,31 @@ const make = Effect.gen(function* () { }); } const steerableRun = latestSteerableRun(target); + // A fresh send never joins a running turn, so it needs none to steer or restart. + const mode = + input.freshContext && (input.mode === "steer" || input.mode === "restart") + ? "queue" + : input.mode; let dispatchMode: Extract< OrchestrationV2Command, { readonly type: "message.dispatch" } >["dispatchMode"]; - if (input.mode === "steer" || input.mode === "restart") { + if (mode === "steer" || mode === "restart") { if (steerableRun === undefined) { return yield* new ThreadManagementNoSteerableRunError({ threadId: input.threadId, - mode: input.mode, + mode, }); } dispatchMode = { - type: input.mode === "steer" ? "steer_active" : "restart_active", + type: mode === "steer" ? "steer_active" : "restart_active", targetRunId: steerableRun.id, }; - } else if (input.mode === "auto" && steerableRun !== undefined) { + } else if (mode === "auto" && steerableRun !== undefined) { dispatchMode = { type: "steer_active", targetRunId: steerableRun.id }; } else { dispatchMode = { - type: input.mode === "queue" ? "queue_after_active" : "start_immediately", + type: mode === "queue" ? "queue_after_active" : "start_immediately", }; } @@ -853,6 +860,7 @@ const make = Effect.gen(function* () { text: input.text, attachments: input.attachments, ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + ...(input.freshContext ? { freshContext: true } : {}), dispatchMode, createdBy: input.createdBy, creationSource: input.creationSource, @@ -888,7 +896,7 @@ const make = Effect.gen(function* () { ? "queued" : turnItem.inputIntent === "turn_start" ? "started" - : input.mode === "restart" + : mode === "restart" ? "restarted" : "steered"; return { dispatch: dispatched, projection, message, run, turnItem, delivery }; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index bb586d5b9f4b4..de57d486651a1 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47498 + "mcpToolListJsonBytes": 47642 } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 81b6992a59d3b..2e772cc0998c0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1077,6 +1077,7 @@ describe("planComposerTurn", () => { keepFullHistory: false, phase: "ready" as const, dispatchMode: "auto" as const, + freshContext: false, }; it("prepares a worktree from the selected branch on a new thread", () => { @@ -1126,6 +1127,19 @@ describe("planComposerTurn", () => { shouldQueueBehindActiveRun: false, baseBranchForWorktree: null, }); + // A fresh session cannot join the running turn, and a stale one is not compacted first. + expect( + planComposerTurn({ + ...running, + dispatchMode: "steer", + freshContext: true, + resumeCompactionTokens: 120_000, + }), + ).toMatchObject({ + compactBeforeSend: false, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e933abf86b2fb..d4b54a7e5a848 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1411,6 +1411,8 @@ export function planComposerTurn(input: { readonly keepFullHistory: boolean; readonly phase: SessionPhase; readonly dispatchMode: ComposerDispatchMode; + /** The turn starts a new provider session, so it neither compacts first nor steers. */ + readonly freshContext: boolean; }) { const isFirstMessage = !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); @@ -1420,9 +1422,14 @@ export function planComposerTurn(input: { // old history. The message queues behind the /compact run, since steering into it is // rejected. const compactBeforeSend = + !input.freshContext && input.resumeCompactionTokens !== null && !input.keepFullHistory && !COMPACT_COMMAND_PATTERN.test(input.messageText); + const dispatchMode = + input.freshContext && (input.dispatchMode === "steer" || input.dispatchMode === "restart") + ? "queue" + : input.dispatchMode; return { // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. @@ -1430,11 +1437,11 @@ export function planComposerTurn(input: { baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, compactBeforeSend, turnDispatchMode: - compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : dispatchMode, shouldQueueBehindActiveRun: compactBeforeSend || input.sendQueuesBehindSetup || - (input.phase === "running" && input.dispatchMode === "queue"), + (input.phase === "running" && dispatchMode === "queue"), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 192505ea45408..4408bee38ad01 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,6 +45,8 @@ import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; +import { freshSessionBannerItem } from "./chat/ComposerFreshSession"; +import { armFreshSession, disarmFreshSession, useFreshSessionArmed } from "./chat/freshSessionArms"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; import { useChatShortcuts } from "./chat/useChatShortcuts"; @@ -2510,8 +2512,15 @@ export default function ChatView(props: ChatViewProps) { return next; }); }; + const freshSessionAvailable = + activeMessageCount > 0 && serverConfig?.environment.capabilities.freshContext === true; + // While a question or approval owns the composer, its text answers that, not the agent. + const freshSessionCommandAvailable = + freshSessionAvailable && activePendingApproval === null && pendingUserInputs.length === 0; + const freshSessionArmed = useFreshSessionArmed(routeThreadKey); const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory); const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory); + const armFreshSessionStable = useStableCallback(() => armFreshSession(routeThreadKey)); const handleRestoreThreadBranch = () => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { @@ -2602,6 +2611,11 @@ export default function ChatView(props: ChatViewProps) { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; + // An approval's feedback goes to that approval; the armed session waits for the next message. + const freshSessionItems = + freshSessionAvailable && freshSessionArmed && activePendingApproval === null + ? [freshSessionBannerItem(() => disarmFreshSession(routeThreadKey))] + : []; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, @@ -2609,6 +2623,7 @@ export default function ChatView(props: ChatViewProps) { ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, + ...freshSessionItems, ...systemComposerBannerItems, ]; } @@ -2618,6 +2633,7 @@ export default function ChatView(props: ChatViewProps) { ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, + ...freshSessionItems, ...systemComposerBannerItems, branchMismatchBannerItem({ mismatchKey: activeBranchMismatchKey, @@ -2922,6 +2938,7 @@ export default function ChatView(props: ChatViewProps) { panelAnimationsActive, panelAnimationDurationMs, keepFullHistory, + freshSessionAvailable: freshSessionCommandAvailable, resumeCompactionTokens, showPlanFollowUpPrompt, usageLimitsKey, @@ -3640,6 +3657,9 @@ export default function ChatView(props: ChatViewProps) { onToggleKeepFullHistory={toggleKeepFullHistoryStable} sideChatAvailable={sideChat.available} sideChatDisabledReason={sideChatDisabledReason} + onFreshSession={ + freshSessionCommandAvailable ? armFreshSessionStable : undefined + } onUsageLimitsCommand={ usageLimitsOffered && usageLimitsKey !== null && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 95fa71d95323b..e4515f759fd7a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -383,6 +383,8 @@ export interface ChatComposerProps { onUsageLimitsCommand?: (() => void) | undefined; sideChatAvailable?: boolean; sideChatDisabledReason?: string | null; + /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ + onFreshSession?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -1151,6 +1153,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, sideChatAvailable: props.sideChatAvailable === true, sideChatDisabledReason: props.sideChatDisabledReason ?? null, + freshSessionAvailable: props.onFreshSession !== undefined, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -1880,6 +1883,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "clear") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + props.onFreshSession?.(); + } + return; + } if (item.command === "side" || item.command === "btw") { if (props.sideChatDisabledReason) return; const applied = applyPromptReplacement( diff --git a/apps/web/src/components/chat/ComposerFreshSession.tsx b/apps/web/src/components/chat/ComposerFreshSession.tsx new file mode 100644 index 0000000000000..3ae2cafdc32b8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerFreshSession.tsx @@ -0,0 +1,16 @@ +import { EraserIcon } from "lucide-react"; + +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +/** Shown while `/clear` is armed; dismissing it keeps the current conversation. */ +export function freshSessionBannerItem(onUndo: () => void): ComposerBannerStackItem { + return { + id: "fresh-session", + variant: "info", + icon: , + title: "Your next message starts a fresh session", + description: "The agent won't see earlier messages in this thread.", + dismissLabel: "Keep the current session", + onDismiss: onUndo, + }; +} diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 97b74a75a73dc..2b2cfa78dce2b 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -113,7 +113,11 @@ export function QueuedRunsControl({ const workflow = projection ? deriveThreadQueueWorkflowState(projection) : null; const queued = workflow?.queuedRuns ?? []; const activeRun = workflow?.activeRun ?? null; - const canSend = workflow?.canSendNow === true || workflow?.canPromoteToSteer === true; + // A fresh message starts its own turn: it may skip compaction, but not steer into a turn. + const canSendRun = (freshContext: boolean) => + workflow?.canSendNow === true || + (workflow?.canPromoteToSteer === true && + (!freshContext || workflow.promoteSkipsCompaction === true)); const queuedImageUrlById = new Map( queuedImageAttachmentUrls.flatMap((url, index) => { const resource = queuedImageAttachmentResources[index]; @@ -129,6 +133,7 @@ export function QueuedRunsControl({ message, runId: run.id, messageId: run.userMessageId, + freshContext: run.freshContext === true, serverIndex, text, // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it. @@ -148,6 +153,7 @@ export function QueuedRunsControl({ message: undefined, runId: null, messageId: null, + freshContext: false, serverIndex: null, text: message.text, editable: true, @@ -164,7 +170,10 @@ export function QueuedRunsControl({ ]; const sendQueuedMessage = async (queuedRunId: RunId) => { - if (!canSend || steerInFlightRef.current) return; + const freshContext = queued.some( + ({ run }) => run.id === queuedRunId && run.freshContext === true, + ); + if (!canSendRun(freshContext) || steerInFlightRef.current) return; steerInFlightRef.current = true; setBusyRunId(queuedRunId); if (workflow?.canSendNow) { @@ -185,7 +194,7 @@ export function QueuedRunsControl({ useImperativeHandle(ref, () => ({ steerNext(repeat) { const next = queued[0]; - if (!next || !canSend) return false; + if (!next || !canSendRun(next.run.freshContext === true)) return false; if (!repeat && busyRunId === null) void sendQueuedMessage(next.run.id); return true; }, @@ -266,7 +275,7 @@ export function QueuedRunsControl({ timestampFormat={props.timestampFormat} onOpenThread={props.onOpenThread} > - {canSend && !isEditing ? ( + {canSendRun(item.freshContext) && !isEditing ? ( <> }> diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index bf52aad538e18..66bb9b586c679 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,7 +38,7 @@ import { } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; -import { ModelSwitchRow } from "./V2LifecycleRow"; +import { FreshSessionRow, ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -252,6 +252,7 @@ export function UserMessageRow({ row }: { row: Extract + {item?.type === "user_message" && item.freshContext ? : null} {modelSwitch ? ( ) : null} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index c0dd0e0900d37..40fffaf608412 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -43,6 +43,7 @@ import { CircleStopIcon, ArrowRightLeftIcon, ArrowRightIcon, + EraserIcon, GitForkIcon, MessageSquareIcon, } from "lucide-react"; @@ -597,6 +598,11 @@ function EnvironmentHandoffRow({ return ; } +/** Marks where `/clear` or an automation started a new provider session in this thread. */ +export function FreshSessionRow() { + return ; +} + /** A same-harness model or effort change. Shows only what changed. */ export function ModelSwitchRow(props: { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 0c5c04e28caef..ba1aacb83de31 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,7 +12,7 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; -/** Provider commands, `/side` and `/btw` work only at the message start. */ +/** Provider commands, `/side`, `/btw` and `/clear` work only at the message start. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, @@ -23,7 +23,10 @@ export function slashCommandItemsForPromptPosition( return items.filter( (item) => item.type === "skill" || - (item.type === "slash-command" && item.command !== "side" && item.command !== "btw"), + (item.type === "slash-command" && + item.command !== "side" && + item.command !== "btw" && + item.command !== "clear"), ); } diff --git a/apps/web/src/components/chat/freshSessionArms.ts b/apps/web/src/components/chat/freshSessionArms.ts new file mode 100644 index 0000000000000..cd2dc9b52e48c --- /dev/null +++ b/apps/web/src/components/chat/freshSessionArms.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "../../rpc/atomRegistry"; + +/** Threads whose next message starts a fresh provider session, keyed by thread, valued by when it was armed. */ +const freshSessionArmsAtom = Atom.make>>({}).pipe(Atom.keepAlive); + +export function armFreshSession(threadKey: string): void { + appAtomRegistry.set(freshSessionArmsAtom, { + ...appAtomRegistry.get(freshSessionArmsAtom), + [threadKey]: Date.now(), + }); +} + +/** With `arm`, clears only that arm, so a send never consumes one made while it was in flight. */ +export function disarmFreshSession(threadKey: string, arm?: number): void { + const current = appAtomRegistry.get(freshSessionArmsAtom); + if (current[threadKey] === undefined || (arm !== undefined && current[threadKey] !== arm)) return; + const { [threadKey]: _removed, ...rest } = current; + appAtomRegistry.set(freshSessionArmsAtom, rest); +} + +export function readFreshSessionArm(threadKey: string): number | undefined { + return appAtomRegistry.get(freshSessionArmsAtom)[threadKey]; +} + +export function useFreshSessionArmed(threadKey: string): boolean { + return useAtomValue(freshSessionArmsAtom)[threadKey] !== undefined; +} diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 5f778ac6cac1e..741d129029330 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -66,6 +66,7 @@ export function useComposerMenuItems({ planModeUiEnabled, sideChatAvailable, sideChatDisabledReason, + freshSessionAvailable, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -85,6 +86,7 @@ export function useComposerMenuItems({ readonly planModeUiEnabled: boolean; readonly sideChatAvailable: boolean; readonly sideChatDisabledReason: string | null; + readonly freshSessionAvailable: boolean; readonly compactSlashCommandAvailable: boolean; readonly selectedProvider: ProviderDriverKind; readonly selectedProviderSkills: ServerProvider["skills"]; @@ -260,6 +262,17 @@ export function useComposerMenuItems({ }, ] as const) : []), + ...(freshSessionAvailable + ? ([ + { + id: "slash:clear", + type: "slash-command", + command: "clear", + label: "/clear", + description: "Start a fresh session; earlier messages stay visible", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( selectedProviderSkills, @@ -288,8 +301,11 @@ export function useComposerMenuItems({ skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); + // T3's `/clear` replaces the provider's, which would clear a session T3 still treats as live. const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( - (item) => item.command.name !== "compact" || compactSlashCommandAvailable, + (item) => + (item.command.name !== "compact" || compactSlashCommandAvailable) && + (item.command.name !== "clear" || !freshSessionAvailable), ); const slashCommandItems = slashCommandItemsForPromptPosition( [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 98084e9795443..db2fa10feb871 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,3 +1,5 @@ +import { isClearComposerCommand } from "@t3tools/shared/composerTrigger"; +import { armFreshSession, disarmFreshSession, readFreshSessionArm } from "./freshSessionArms"; import { mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, @@ -207,6 +209,8 @@ interface ComposerSendInput { readonly panelAnimationsActive: boolean; readonly panelAnimationDurationMs: number; readonly keepFullHistory: boolean; + /** `/clear` can arm a fresh session on this thread. */ + readonly freshSessionAvailable: boolean; readonly resumeCompactionTokens: number | null; readonly showPlanFollowUpPrompt: boolean; readonly usageLimitsKey: string | null; @@ -302,6 +306,7 @@ export function useComposerSend(input: ComposerSendInput) { panelAnimationsActive, panelAnimationDurationMs, keepFullHistory, + freshSessionAvailable, resumeCompactionTokens, showPlanFollowUpPrompt, usageLimitsKey, @@ -716,8 +721,26 @@ export function useComposerSend(input: ComposerSendInput) { return; } + const composerHoldsOnlyText = + composerImages.length === 0 && + composerFiles.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 && + composerThreadContexts.length === 0 && + composerEnvironmentContexts.length === 0; + if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(trimmed)) { + armFreshSession(routeThreadKey); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + return; + } + // Read once: an arm made while this send is in flight belongs to the next message. + const freshArm = freshSessionAvailable ? readFreshSessionArm(routeThreadKey) : undefined; if ( !directAnnotation && + freshArm === undefined && sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && @@ -798,14 +821,7 @@ export function useComposerSend(input: ComposerSendInput) { return; } const standaloneSlashCommand = - sendInteractionModeEnabled && - composerImages.length === 0 && - composerFiles.length === 0 && - sendableComposerTerminalContexts.length === 0 && - composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0 && - composerThreadContexts.length === 0 && - composerEnvironmentContexts.length === 0 + sendInteractionModeEnabled && composerHoldsOnlyText ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand && multipleModelSelections === null) { @@ -881,6 +897,7 @@ export function useComposerSend(input: ComposerSendInput) { keepFullHistory, phase, dispatchMode, + freshContext: freshArm !== undefined, }); if (missingWorktreeBaseBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); @@ -1364,6 +1381,7 @@ export function useComposerSend(input: ComposerSendInput) { runtimeMode, interactionMode: sendInteractionMode, dispatchMode: turnDispatchMode, + ...(freshArm === undefined ? {} : { freshContext: true }), ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -1397,6 +1415,7 @@ export function useComposerSend(input: ComposerSendInput) { } else { turnStartSucceeded = true; setKeepFullHistory(routeThreadKey, false); + if (freshArm !== undefined) disarmFreshSession(routeThreadKey, freshArm); clearUsageLimitsFor(routeThreadKey); if (turnUsesAttachmentUploads) { releaseDraftAttachments(composerAttachmentsSnapshot); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 51635a651d476..cb65ab6bde0b1 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -564,7 +564,7 @@ describe("parseSideChatCommand", () => { expect(parseSideChatCommand("/sidebar")).toBeNull(); expect(parseSideChatCommand("/btwx")).toBeNull(); expect(parseSideChatCommand("ask /side later")).toBeNull(); - const items = (["side", "btw", "plan"] as const).map((command) => ({ + const items = (["side", "btw", "clear", "plan"] as const).map((command) => ({ id: `slash:${command}`, type: "slash-command" as const, command, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9b7223797190e..1cfbaceb4b245 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,7 +18,7 @@ import { import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "plan" | "default" | "side" | "btw"; +export type ComposerSlashCommand = "model" | "plan" | "default" | "side" | "btw" | "clear"; export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; export interface ComposerTrigger { @@ -326,7 +326,7 @@ export function parseSideChatCommand(text: string): { prompt: string } | null { export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { +): Exclude | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; diff --git a/docs/user/composer.md b/docs/user/composer.md index c236da3c82c60..d1b0562203fb8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -264,6 +264,12 @@ Provider commands must start the message to run. T3 Code commands such as Send `/compact` in an existing conversation to reduce context usage when the provider supports it, even with the context meter turned off. +Send `/clear` on its own, or pick it from the slash menu, to start a fresh session +in the same thread. Your next message goes to a new provider session that doesn't see the +earlier messages, and the thread marks where it began with **New session**. +Earlier messages and diffs stay in the thread. Dismiss the notice above the +composer to keep the current session instead. + On web and desktop, turn on **Settings → Labs → Legacy → Context window indicator (legacy)** to see context usage. The meter is off by default and appears once the provider reports usage. Open it for usage details and diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 9a62a65efdcbc..1d3c72936f0fa 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -88,7 +88,7 @@ its conversation. Reverting a thread or editing and resubmitting an earlier turn is unavailable. Continue with a follow-up message or start a new thread. Manual context compaction is unavailable through Antigravity's ACP runtime, even -though its CLI has `/compact`. Start a new thread when you need a fresh context. +though its CLI has `/compact`. Send `/clear` when you need a fresh context. ### Skills and attachments diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index dc79eb774df7e..41eb03e0939fc 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -25,7 +25,7 @@ {"id": "opus-review-too-via-delegate", "area": "orchestration", "kind": "guard", "pattern": "same-provider review via delegate_task", "experience": "'Have Opus review the things too' from a Claude parent with no extra settings runs a native Opus subagent the parent waits for.", "rule": "Use your native tool when the child uses your provider, a model it offers, and no settings beyond yours, and you need it only in this turn.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Get a Sol review on https://github.com/acme/app/pull/123 before we file anything else."}, {"role": "assistant", "text": "Sol approved PR 123 after one fix to its description.", "toolCalls": [{"tool": "delegate_task", "input": {"title": "Sol review PR 123", "mode": "async", "clientRequestId": "sol-123-r1", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Review https://github.com/acme/app/pull/123. Read only."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "task_status", "input": {"taskId": "task-1"}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "workState": "result_available", "summary": "APPROVE after S1 (description wording)."}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"prs": [{"number": 123, "title": "fix(web): keep scroll position on thread switch", "headRefName": "fix/scroll"}]}, "harness": "claude"}, "prompt": "Have Opus review the things too, not just Sol", "expect": {"calls": [{"tool": "Agent", "args": {"model": "~opus"}}], "notCalls": ["delegate_task", "t3_thread_send"], "callsMustNot": [{"tool": "Agent", "args": {"run_in_background": true}}]}, "notes": "T3's 'prefer native for same-provider' line pulls toward the native Agent tool here; this stayed correct in the real thread."} {"id": "delegates-then-new-thread-with-model", "area": "orchestration", "kind": "guard", "pattern": "new thread asked: t3_thread_launch with model", "experience": "'Spin up 2 Opus delegates ... then start a new thread using Grok 4.7 to implement' gave 2 delegate_task calls and 1 t3_thread_launch on Grok.", "rule": "t3_thread_launch and create_threads ... Use them only when the user explicitly asks for separate/new/top-level threads.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Spin up 2 Opus 5.5 delegates to explore performance wins in this app."}, {"role": "assistant", "text": "Both Opus delegates are done.\n- Client: the thread list re-renders every row on each streamed token.\n- Server: each event write runs a full projection rebuild.", "toolCalls": [{"tool": "delegate_task", "input": {"title": "Opus: client performance", "mode": "wait", "clientRequestId": "perf-client", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "task": "Find concrete client performance wins. Read only."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "completed", "workState": "result_available", "providerInstanceId": "claudeAgent", "model": "claude-opus-5-5", "summary": "Client: the thread list re-renders every row on each streamed token. Memoize rows by thread id and select only the active thread's stream."}}, {"tool": "delegate_task", "input": {"title": "Opus: server performance", "mode": "wait", "clientRequestId": "perf-server", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "task": "Find concrete server performance wins. Read only."}, "result": {"taskId": "task-2", "childThreadId": "44444444-4444-4444-8444-000000000002", "childRunId": "run-child-2", "status": "completed", "workState": "result_available", "providerInstanceId": "claudeAgent", "model": "claude-opus-5-5", "summary": "Server: each event write runs a full projection rebuild. Apply events to the projection incrementally."}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "grok", "model": "grok-4.7"}, "status": "starting"}}}, "prompt": "Summarize their thoughts, then start a new thread using Grok 4.7 to implement your findings.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"modelSelection.model": "~grok"}}], "notCalls": ["delegate_task", "create_threads"]}, "notes": "Pattern 2 (new thread asked but delegate used) had only 1-2 real cases in 40 asks, so this is a guard. Check the arg path against the current t3_thread_launch schema."} {"id": "subagent-ask-no-new-threads", "area": "orchestration", "kind": "guard", "pattern": "subagent asked: no t3_thread_launch/create_threads", "experience": "'Have Haiku 5.5 subagents do the exploration' must give child tasks, not top-level threads. 0 of 98 subagent asks went wrong, and the user still warns about it.", "rule": "Never use them merely because the user said \"subagent\" or requested parallel delegated work.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"package.json": "{\n \"name\": \"app\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.4.0\",\n \"scripts\": {\n \"dev\": \"turbo run dev\",\n \"build\": \"turbo run build\",\n \"test\": \"vitest run\",\n \"typecheck\": \"turbo run typecheck\"\n }\n}\n", "pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*\n", "turbo.json": "{\n \"tasks\": {\n \"build\": { \"outputs\": [\"dist/**\"] },\n \"typecheck\": {},\n \"dev\": { \"cache\": false }\n }\n}\n", "apps/web/package.json": "{ \"name\": \"@app/web\", \"scripts\": { \"dev\": \"vite\", \"build\": \"vite build\" } }\n", "apps/server/package.json": "{ \"name\": \"@app/server\", \"scripts\": { \"dev\": \"tsx watch src/index.ts\", \"build\": \"tsc\" } }\n", ".github/workflows/ci.yml": "name: ci\non: [push, pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: pnpm install --frozen-lockfile\n - run: pnpm typecheck\n - run: pnpm test\n", "scripts/seed-dev-db.ts": "import { db } from '../apps/server/src/db';\n\n// Seeds the dev database with 20 fake users.\nfor (let i = 0; i < 20; i++) await db.insert('users', { name: `user${i}` });\n", "apps/web/src/main.tsx": "import { createRoot } from 'react-dom/client';\nimport { App } from './App';\n\ncreateRoot(document.getElementById('root')!).render();\n", "apps/web/src/App.tsx": "export function App() {\n return ;\n}\n", "apps/server/src/index.ts": "import { createServer } from './server';\n\ncreateServer().listen(Number(process.env.PORT ?? 3773));\n", "apps/server/src/db.ts": "import { Database } from 'better-sqlite3';\n\nexport const db = new Database(process.env.DB_PATH ?? 'dev.sqlite');\n"}}}, "prompt": "I want your honest thoughts on this project. Do a deep dive and really read through it so you know it well. Have Haiku 5.5 subagents do the exploration.", "expect": {"notCalls": ["t3_thread_launch", "create_threads"]}, "notes": "Narrow on purpose: native vs delegate is graded in the bad cases. The user's preemptive guard: 'Please don't make new threads with the T3 Code MCP, only create delegates, workflows, subagents etc.'."} -{"id": "thread-send-to-named-top-level-thread", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send to a top-level thread the user names", "experience": "Given another top-level thread id to talk to, the agent used t3_thread_send on it and linked it in the reply.", "rule": "To mention another thread to the user, write its `link` verbatim: />, with no title.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The other agent disagreed:\n\nTheirs is the better plan to execute. Mine is the wider survey. Start from theirs and graft three items from mine.\n\nTheir 5 points: 1) cache tsc output per package, 2) split the web typecheck into two projects, 3) drop the duplicate lint step in CI, 4) run typecheck only for changed packages in CI (memo), 5) measure before and after on main."}, {"role": "assistant", "text": "We now agree on almost everything. I accept points 1, 2, 3, and 5. I disagree with one detail of the memo in point 4: CI must also typecheck the packages that depend on a changed package, not only the changed ones. From my plan I would graft: project references, a warm tsc cache restored in CI, and a per-package timing report."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "messageId": "msg-1", "runId": "run-1", "queued": true}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Faster type checking", "link": "", "status": "idle"}, "items": [{"position": 41, "createdBy": "assistant", "text": "Plan: 1) cache tsc output per package, 2) split the web typecheck, 3) drop the duplicate lint step, 4) memo: typecheck only changed packages in CI, 5) measure on main."}, {"position": 42, "createdBy": "user", "text": "Another agent has a wider survey. I'll bring its answer back here."}], "nextPosition": 43, "hasMore": false}}}, "prompt": "That thread is on this machine, you can talk to it directly with t3 code: 11111111-1111-4111-8111-111111111111", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111"}}], "notCalls": ["delegate_task", "t3_thread_launch"], "textMust": [""]}, "notes": "Keeps a rewrite from over-banning t3_thread_send. The ban is only for delegated childThreadIds."} +{"id": "thread-send-to-named-top-level-thread", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send to a top-level thread the user names", "experience": "Given another top-level thread id to talk to, the agent used t3_thread_send on it and linked it in the reply.", "rule": "To mention another thread to the user, write its `link` verbatim: />, with no title.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The other agent disagreed:\n\nTheirs is the better plan to execute. Mine is the wider survey. Start from theirs and graft three items from mine.\n\nTheir 5 points: 1) cache tsc output per package, 2) split the web typecheck into two projects, 3) drop the duplicate lint step in CI, 4) run typecheck only for changed packages in CI (memo), 5) measure before and after on main."}, {"role": "assistant", "text": "We now agree on almost everything. I accept points 1, 2, 3, and 5. I disagree with one detail of the memo in point 4: CI must also typecheck the packages that depend on a changed package, not only the changed ones. From my plan I would graft: project references, a warm tsc cache restored in CI, and a per-package timing report."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "messageId": "msg-1", "runId": "run-1", "queued": true}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Faster type checking", "link": "", "status": "idle"}, "items": [{"position": 41, "createdBy": "assistant", "text": "Plan: 1) cache tsc output per package, 2) split the web typecheck, 3) drop the duplicate lint step, 4) memo: typecheck only changed packages in CI, 5) measure on main."}, {"position": 42, "createdBy": "user", "text": "Another agent has a wider survey. I'll bring its answer back here."}], "nextPosition": 43, "hasMore": false}}}, "prompt": "That thread is on this machine, you can talk to it directly with t3 code: 11111111-1111-4111-8111-111111111111", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111"}}], "notCalls": ["delegate_task", "t3_thread_launch"], "textMust": [""], "callsMustNot": [{"tool": "t3_thread_send", "args": {"freshContext": true}}]}, "notes": "Keeps a rewrite from over-banning t3_thread_send. The ban is only for delegated childThreadIds. An ordinary send keeps the thread's context."} {"id": "too-many-delegates-cancel-extras", "area": "orchestration", "kind": "guard", "pattern": "volume complaint: cancel, do not add", "experience": "After 'You're doing too much man, we have like 6+ delegates going', the agent stopped the stale review tasks and made the next fix without a new round.", "rule": "none (task_cancel description covers the action)", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Take over https://github.com/acme/app/pull/123. Have Sol and Opus delegates audit it hard."}, {"role": "assistant", "text": "Five audits are running on the PR: three Sol (error handling, concurrency, API surface) and two Opus (tests, migration safety).", "toolCalls": [{"tool": "delegate_task", "input": {"title": "Sol audit: error handling", "mode": "async", "clientRequestId": "pr123-sol-error", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Audit https://github.com/acme/app/pull/123 for error handling problems. Report each with file:line. Read only."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "delegate_task", "input": {"title": "Sol audit: concurrency", "mode": "async", "clientRequestId": "pr123-sol-concurrency", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Audit https://github.com/acme/app/pull/123 for concurrency problems. Report each with file:line. Read only."}, "result": {"taskId": "task-2", "childThreadId": "44444444-4444-4444-8444-000000000002", "childRunId": "run-child-2", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "delegate_task", "input": {"title": "Sol audit: API surface", "mode": "async", "clientRequestId": "pr123-sol-api", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Audit https://github.com/acme/app/pull/123 for API surface problems. Report each with file:line. Read only."}, "result": {"taskId": "task-3", "childThreadId": "44444444-4444-4444-8444-000000000003", "childRunId": "run-child-3", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "delegate_task", "input": {"title": "Opus audit: tests", "mode": "async", "clientRequestId": "pr123-opus-tests", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "task": "Audit https://github.com/acme/app/pull/123 for tests problems. Report each with file:line. Read only."}, "result": {"taskId": "task-4", "childThreadId": "44444444-4444-4444-8444-000000000004", "childRunId": "run-child-4", "status": "running", "workState": "working", "providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}}, {"tool": "delegate_task", "input": {"title": "Opus audit: migration safety", "mode": "async", "clientRequestId": "pr123-opus-migration", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "task": "Audit https://github.com/acme/app/pull/123 for migration safety problems. Report each with file:line. Read only."}, "result": {"taskId": "task-5", "childThreadId": "44444444-4444-4444-8444-000000000005", "childRunId": "run-child-5", "status": "running", "workState": "working", "providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}}]}], "mocks": {"task_status": {"status": "running", "workState": "working"}, "task_cancel": {"status": "cancel_requested"}}, "world": {"prs": [{"number": 123, "title": "feat(server): resumable uploads", "headRefName": "feat/resumable-uploads"}]}}, "prompt": "You're doing too much man, we have like 6+ delegates going. We don't need to go that hard.", "expect": {"calls": [{"tool": "task_cancel"}], "maxCalls": {"delegate_task": 0}, "notCalls": ["t3_thread_interrupt"]}, "notes": "Pairs with review-round-per-bot-nit."} {"id": "control-new-thread-view-wording", "area": "orchestration", "kind": "control", "pattern": "'new thread' as product text", "experience": "A UI bug report that says 'new thread view' needs a code fix, not a new T3 thread or delegate.", "rule": "t3_thread_launch and create_threads ... only when the user explicitly asks for separate/new/top-level threads.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"files": {"apps/mobile/src/Sidebar.tsx": "export function Sidebar() {\n return (\n \n \n \n );\n}\n", "apps/mobile/src/NewThreadView.tsx": "export function NewThreadView() {\n return {/* composer */};\n}\n"}}}, "prompt": "On Android foldables the sidebar is visible but tapping threads does nothing in some places, like the new thread view or settings. It makes the sidebar feel broken. Fix it.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "orchestrator_capabilities"]}, "notes": ""} {"id": "control-subagent-label-bug-wording", "area": "orchestration", "kind": "control", "pattern": "'subagent' as product text", "experience": "A bug report about how subagent models are labeled is a code task. It needs no delegation.", "rule": "none", "privacy": "commit-safe", "setup": {"role": "top", "world": {"commits": [{"sha": "61b0e9d", "subject": "fix(web): label subagent messages with the subagent's model"}], "files": {"apps/web/src/subagentLabel.ts": "export function subagentModelLabel(run: Run, parent: Thread) {\n return run.model ?? parent.model;\n}\n"}}}, "prompt": "I recently fixed a bug where models were labeled wrong inside subagents that run through Claude Code. I think it came back in a different form. A Sol subagent shows the parent's model name in the UI. Find the cause and fix it.", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "t3_thread_launch", "create_threads", "Workflow"]}, "notes": ""} @@ -34,3 +34,4 @@ {"id": "two-children-same-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for two Sol xhigh subagents, an Astra parent started one and refused the second, citing 'One child per named model' (#24967).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}}, "prompt": "Launch two Sol xhigh subagents. Give each a trivial task that only replies OK.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "target.options.reasoningEffort": "xhigh"}}], "minCalls": {"delegate_task": 2}, "callsMustNot": [{"tool": "delegate_task", "args": {"target.model": "~muse"}}, {"tool": "delegate_task", "args": {"target.model": "~claude"}}]}, "notes": "From #24967. One child plus the parent doing the work fails, and so does a second model in place of Sol."} {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} +{"id": "thread-send-fresh-context", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send that should start a fresh provider session", "experience": "The user wanted a confused long-running thread to start over in the same thread without its old context; a plain send would keep feeding it the history.", "rule": "`freshContext: true` on t3_thread_send starts a new provider session in the same thread, like /clear.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The thread working on the CSV importer keeps reverting to the old column mapping from early in its history."}, {"role": "assistant", "text": "That thread has a long history with three different mappings in it, so the model keeps picking up the stale one."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "messageId": "msg-1", "runId": "run-1", "status": "starting", "delivery": "started"}}}, "prompt": "Send it the final mapping below and have it start over with a clean context in that same thread, so the old history stops leaking in: thread 11111111-1111-4111-8111-111111111111.\n\nFinal mapping: id -> customer_id, amt -> amount_cents, ts -> created_at.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111", "freshContext": true}}], "notCalls": ["delegate_task", "t3_thread_launch", "create_threads"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: fresh provider sessions in one T3 thread"}, "notes": "Pairs with thread-send-to-named-top-level-thread, where an ordinary send must not set freshContext."} diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 777f7e2adb4f5..f813ece95dc45 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -183,6 +183,8 @@ export interface StartThreadTurnInput extends ThreadCommandInput { readonly bootstrap?: StartThreadBootstrap; readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId }; readonly dispatchMode?: "auto" | "queue" | "steer" | "restart" | "start"; + /** Start a new provider session; needs the server's `freshContext` capability. */ + readonly freshContext?: boolean; } export interface InterruptThreadTurnInput extends ThreadCommandInput { @@ -752,6 +754,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }), + ...(input.freshContext ? { freshContext: true } : {}), dispatchMode: { type: "start_immediately" }, }); } @@ -776,6 +779,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ...(input.titleSeed === undefined ? {} : { titleSeed: input.titleSeed }), ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }), + ...(input.freshContext ? { freshContext: true } : {}), ...(requestedMode !== "queue" ? { deliveryIntent: requestedMode } : {}), dispatchMode, }); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 6756225666fd4..17f41e9f8d139 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -503,6 +503,12 @@ export const OrchestratorMcpThreadSendInput = Schema.Struct({ threadId: ThreadId, message: OrchestratorMcpPrompt, mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), + freshContext: Schema.optional( + Schema.Boolean.annotate({ + description: + "Start a new provider session without the thread's earlier context, like /clear. Never steers.", + }), + ), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInput.Type; diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 77566a2b09608..68e3c51087b6e 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -169,3 +169,8 @@ export function replaceTextRange( const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; return { text: nextText, cursor: safeStart + replacement.length }; } + +/** `/clear` as the whole message arms a fresh session instead of sending. */ +export function isClearComposerCommand(text: string): boolean { + return /^\/clear\s*$/i.test(text.trim()); +} From 4aab23cd72d71ab3b58878482f3d22f93a088125 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:29:02 -0800 Subject: [PATCH 015/200] feat: scheduled tasks can run in one thread with a fresh session each run (#30326) Co-authored-by: Claude Opus 5.5 --- .../SettingsScheduledTasksRouteScreen.tsx | 40 ++++ .../features/settings/scheduledTaskDraft.ts | 7 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/mcp/OrchestratorMcpService.test.ts | 72 +++++- apps/server/src/mcp/OrchestratorMcpService.ts | 31 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 2 + apps/server/src/persistence/Migrations.ts | 3 +- .../Migrations/080_ScheduledTaskThreadMode.ts | 9 + .../reconcileV2PreviewMigration.test.ts | 2 + .../src/provider/agentContextBudget.json | 2 +- .../ScheduledTaskService.test.ts | 205 +++++++++++++++++- .../scheduledTasks/ScheduledTaskService.ts | 88 ++++++-- .../settings/ScheduledTasksSettings.tsx | 71 +++++- .../settings/scheduledTasksSettings.logic.ts | 4 + docs/user/project-settings.md | 7 + evals/mcp-tool-use/cases/pull-requests.ndjson | 3 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestratorMcp.ts | 8 + packages/contracts/src/scheduledTask.ts | 17 ++ 19 files changed, 539 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/080_ScheduledTaskThreadMode.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 32f05c21020d8..888ad2fb1d742 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -11,6 +11,7 @@ import type { EnvironmentId, ProjectId, ScheduledTask, + ScheduledTaskThreadMode, ScheduledTaskUpsertInput, } from "@t3tools/contracts"; import { @@ -109,6 +110,12 @@ const DAYS = [ { index: 0, label: "Sun" }, ] as const; +const THREAD_MODE_LABELS: Record = { + new_thread: "New thread each run", + continue: "One thread, same conversation", + fresh_session: "One thread, fresh session", +}; + function describeSchedule(task: ScheduledTask): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "once") { @@ -722,6 +729,7 @@ function TaskForm({ const offersOnce = config?.environment.capabilities.oneTimeSchedules === true || draft.task?.schedule.type === "once"; + const offersThreadModes = config?.environment.capabilities.scheduledTaskThreadModes === true; const save = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -795,6 +803,7 @@ function TaskForm({ schedule, enabled: draft.enabled, threadId: draft.task?.threadId ?? null, + ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), workspaceStrategy: draft.workspace === "root" ? { type: "root" } @@ -932,6 +941,37 @@ function TaskForm({ /> + {offersThreadModes ? ( + + ({ + id: mode, + title: THREAD_MODE_LABELS[mode], + state: draft.threadMode === mode ? "on" : undefined, + }), + )} + onSelect={(id) => { + if (id === "new_thread" || id === "continue" || id === "fresh_session") + setDraft({ ...draft, threadMode: id }); + }} + /> + + ) : null} + [path, op, value]), + draft.threadMode, draft.workspace, draft.baseRef, draft.checkoutPath, @@ -260,6 +263,7 @@ export function createDraft( modelSelection, modelSelectionIsExplicit: false, schedule: DEFAULT_SCHEDULE, + threadMode: "new_thread", workspace: "worktree", baseRef: "main", checkoutPath: "", @@ -278,6 +282,7 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { modelSelection: task.modelSelection, modelSelectionIsExplicit: true, schedule: scheduleDraftForTask(task), + threadMode: scheduledTaskThreadMode(task), workspace: task.workspaceStrategy.type, baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", checkoutPath: diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 82010efdee806..12a05e4879024 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -269,6 +269,7 @@ export const make = Effect.gen(function* () { triage: true, oneTimeSchedules: true, freshContext: true, + scheduledTaskThreadModes: true, npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 0faf43acb9dd5..681f5ae3368ae 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -13,6 +13,7 @@ import { OrchestratorMcpTarget, type OrchestrationV2ThreadShell, type ScheduledTask, + type ScheduledTaskUpsertInput, ScheduledTaskId, ProjectId, ProviderDriverKind, @@ -2776,6 +2777,8 @@ describe("OrchestratorMcpService provider resolution", () => { const service = ( git: Partial, saved: Ref.Ref>, + task: ScheduledTask = existing, + modes?: Ref.Ref>, ) => OrchestratorMcpService.layer.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -2784,7 +2787,9 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mergeAll( NodeServices.layer, NodeCrypto.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ @@ -2801,10 +2806,15 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => Effect.succeed({ tasks: [existing] }), + list: () => Effect.succeed({ tasks: [task] }), upsert: (input) => Ref.update(saved, (strategies) => [...strategies, input.workspaceStrategy]).pipe( - Effect.as({ task: existing }), + Effect.andThen( + modes === undefined + ? Effect.void + : Ref.update(modes, (all) => [...all, input.threadMode]), + ), + Effect.as({ task }), ), }), ), @@ -2831,6 +2841,62 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("keeps one thread when fresh sessions are turned off before the first run", () => + Effect.gen(function* () { + const modes = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + service( + masterRepository, + yield* Ref.make>([]), + { ...existing, threadMode: "fresh_session" }, + modes, + ), + ), + ); + yield* mcp.updateScheduledTask(client, { + scheduledTaskId: existing.id, + freshSession: false, + }); + yield* mcp.updateScheduledTask(client, { + scheduledTaskId: existing.id, + bindToCurrentThread: false, + }); + assert.deepEqual(yield* Ref.get(modes), ["continue", "new_thread"]); + }), + ); + + it.effect( + "refuses unbinding into fresh sessions in one call, which would keep the thread", + () => + Effect.gen(function* () { + const modes = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + service( + masterRepository, + yield* Ref.make>([]), + { + ...existing, + threadId: ThreadId.make("thread:scheduled-base"), + threadMode: "continue", + }, + modes, + ), + ), + ); + const refused = yield* mcp + .updateScheduledTask(client, { + scheduledTaskId: existing.id, + bindToCurrentThread: false, + freshSession: true, + }) + .pipe(Effect.flip); + assert.include(refused.message, "first unbind it"); + assert.deepEqual(yield* Ref.get(modes), []); + }), + ); + it.effect("follows the default branch when a task is unbound from its thread", () => Effect.gen(function* () { const saved = yield* Ref.make>([]); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 36fe752eb8f96..074b19fad3fec 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -58,6 +58,7 @@ import { type RuntimeMode, type ScheduledTask, type ScheduledTaskUpsertInput, + scheduledTaskThreadMode, type ServerProvider, type ServerProviderModel, ThreadId, @@ -266,6 +267,7 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato enabled: task.enabled, projectId: task.projectId, boundThreadId: task.threadId, + freshSession: scheduledTaskThreadMode(task) === "fresh_session", schedule: task.schedule, nextRunAt: task.nextRunAt, lastRunStatus: task.lastRunStatus, @@ -2009,6 +2011,11 @@ const make = Effect.gen(function* () { schedule: input.schedule, projectId, threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, + threadMode: input.freshSession + ? "fresh_session" + : bindToCurrentThread + ? "continue" + : "new_thread", workspaceStrategy: yield* scheduledTaskWorkspaceStrategy(bindToCurrentThread, projectId), modelSelection, runtimeMode: limits.runtimeMode, @@ -2071,6 +2078,18 @@ const make = Effect.gen(function* () { : "bindToCurrentThread binds to this thread, which belongs to a different project.", ); } + // A save keeps a bound thread unless the task goes back to new threads, so this pair + // would leave runs in the old thread. + if ( + input.bindToCurrentThread === false && + input.freshSession === true && + existing.threadId !== null + ) { + return yield* failure( + "invalid_request", + "To move this task to a new thread with fresh sessions, first unbind it with bindToCurrentThread: false, then set freshSession: true in a second call.", + ); + } const threadId = input.bindToCurrentThread === undefined ? existing.threadId @@ -2090,6 +2109,18 @@ const make = Effect.gen(function* () { schedule: input.schedule ?? existing.schedule, projectId: existing.projectId, threadId, + threadMode: + input.freshSession === undefined && input.bindToCurrentThread === undefined + ? scheduledTaskThreadMode(existing) + : (input.freshSession ?? + (input.bindToCurrentThread !== false && + scheduledTaskThreadMode(existing) === "fresh_session")) + ? "fresh_session" + : threadId === null && + (input.bindToCurrentThread === false || + scheduledTaskThreadMode(existing) === "new_thread") + ? "new_thread" + : "continue", workspaceStrategy, modelSelection: existing.modelSelection, runtimeMode: existing.runtimeMode, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 54ce798da42bc..83f77d0073001 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -425,6 +425,8 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i } const save = (name: string, target: ThreadId | null, webhook = false) => tasks.upsert({ + // As schedule_task sends it, so clearing the thread unbinds the task. + threadMode: target === null ? "new_thread" : "continue", id: ScheduledTaskId.make(`automation:${deletion}:${name}`), title: name, prompt: "Check orchestration progress", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 606770d1b6508..6a9c57e18cc43 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -81,8 +81,8 @@ import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts"; import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationIndex.ts"; - import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; +import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -165,6 +165,7 @@ const migrationEntries = [ [77, "RepairBooleanSettledOverride", Migration0077], [78, "DropOrchestrationEventCorrelationIndex", Migration0078], [79, "DelegatedRecoveryIndexes", Migration0079], + [80, "ScheduledTaskThreadMode", Migration0080], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/080_ScheduledTaskThreadMode.ts b/apps/server/src/persistence/Migrations/080_ScheduledTaskThreadMode.ts new file mode 100644 index 0000000000000..01552b57fd183 --- /dev/null +++ b/apps/server/src/persistence/Migrations/080_ScheduledTaskThreadMode.ts @@ -0,0 +1,9 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN thread_mode TEXT NOT NULL DEFAULT 'new_thread'`; + yield* sql`UPDATE scheduled_tasks SET thread_mode = 'continue' WHERE thread_id IS NOT NULL`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 5f51c237fd763..84fa4ddb44c77 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -59,6 +59,7 @@ describe("V2 preview upgrade", () => { [77, "RepairBooleanSettledOverride"], [78, "DropOrchestrationEventCorrelationIndex"], [79, "DelegatedRecoveryIndexes"], + [80, "ScheduledTaskThreadMode"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -161,6 +162,7 @@ describe("V2 preview upgrade", () => { [77, "RepairBooleanSettledOverride"], [78, "DropOrchestrationEventCorrelationIndex"], [79, "DelegatedRecoveryIndexes"], + [80, "ScheduledTaskThreadMode"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index de57d486651a1..0a03b931fcc08 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47642 + "mcpToolListJsonBytes": 47818 } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index c7b0e31f4f04d..71454e55a6540 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -485,6 +485,203 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("a fresh-session task launches one thread, then starts each later run fresh in it", () => + Effect.gen(function* () { + const launches = yield* Ref.make(0); + const sends = yield* Ref.make>([]); + const launched = ThreadId.make("thread:fresh-session-task"); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Ref.update(launches, (n) => n + 1).pipe( + Effect.as({ threadId: launched, resumed: false } as never), + ), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed({ deletedAt: null } as never), + sendToThread: (input) => + Ref.update(sends, (all) => [ + ...all, + { + threadId: input.threadId, + ...(input.freshContext === undefined ? {} : { freshContext: input.freshContext }), + }, + ]).pipe(Effect.as({} as never)), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Layer.succeed(Scheduler.Scheduler, { register: () => Effect.void }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = { + id: ScheduledTaskId.make("task:fresh-session"), + title: "Triage", + prompt: "Triage new issues", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + } as const; + yield* service.upsert({ ...input, threadMode: "fresh_session" }); + + assert.equal((yield* service.runNow({ id: input.id })).threadId, launched); + // A form opened before the first run saves no thread; the binding stays. + const { task } = yield* service.upsert({ + ...input, + threadId: null, + threadMode: "fresh_session", + }); + assert.equal(task.threadId, launched); + assert.equal(task.threadMode, "fresh_session"); + // A client that predates thread modes sends neither; both stay. + const legacy = yield* service.upsert({ ...input, threadId: null }); + assert.equal(legacy.task.threadId, launched); + assert.equal(legacy.task.threadMode, "fresh_session"); + yield* service.runNow({ id: input.id }); + + assert.equal(yield* Ref.get(launches), 1); + assert.deepEqual(yield* Ref.get(sends), [{ threadId: launched, freshContext: true }]); + // Its thread stays in the old project, so moving the task leaves it for a new one. + const moved = yield* service.upsert({ + ...input, + projectId: ProjectId.make("project:moved"), + threadId: launched, + }); + assert.equal(moved.task.threadId, null); + assert.equal(moved.task.threadMode, "fresh_session"); + const unbound = yield* service.upsert({ ...input, threadMode: "new_thread" }); + assert.equal(unbound.task.threadId, null); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("a first run whose thread was deleted before it bound removes the task", () => + Effect.gen(function* () { + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Effect.succeed({ + threadId: ThreadId.make("thread:deleted"), + resumed: false, + } as never), + }), + // Deleted before the binding landed, so its cleanup found no task to remove. + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ deletedAt: DateTime.makeUnsafe("2026-11-03T00:00:00Z") } as never), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Layer.succeed(Scheduler.Scheduler, { register: () => Effect.void }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:thread-deleted-mid-launch"), + title: "Triage", + prompt: "Triage new issues", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + threadMode: "continue", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* service.runNow({ id: task.id }); + assert.deepEqual((yield* service.list()).tasks, []); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect.each(["recreated under the same id", "moved to another project"] as const)( + "a first run never binds its thread to a task %s", + (change) => + Effect.gen(function* () { + const launching = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Deferred.succeed(launching, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + Effect.as({ + threadId: ThreadId.make("thread:old-generation"), + resumed: false, + } as never), + ), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Layer.succeed(Scheduler.Scheduler, { register: () => Effect.void }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = { + id: ScheduledTaskId.make("task:recreated-mid-launch"), + title: "Triage", + prompt: "Triage new issues", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + threadMode: "fresh_session", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + } as const; + yield* service.upsert(input); + const run = yield* Effect.forkChild(service.runNow({ id: input.id })); + yield* Deferred.await(launching); + if (change === "recreated under the same id") { + yield* service.delete({ id: input.id }); + yield* TestClock.adjust("1 second"); + yield* service.upsert(input); + } else { + yield* service.upsert({ ...input, projectId: ProjectId.make("project:moved") }); + } + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.await(run); + + const replacement = (yield* service.list()).tasks.find((task) => task.id === input.id); + assert.equal(replacement?.threadId, null); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("refuses saving an enabled task above the policy ceiling", () => Effect.gen(function* () { const layer = ScheduledTaskService.layer.pipe( @@ -534,19 +731,21 @@ it.effect("refuses saving an enabled task above the policy ceiling", () => .pipe(Effect.flip); assert.equal(reenabled.message, "Example Corp limits threads to Auto-accept edits."); // A task bound to a thread runs in that thread's mode, so its own is not checked. - yield* service.upsert({ + const bound = { id: ScheduledTaskId.make("task:bound"), title: "Follow up", prompt: "Check progress", enabled: true, schedule: { type: "interval", everyMs: 60_000 }, projectId: ProjectId.make("project:test"), - threadId: ThreadId.make("thread:bound"), workspaceStrategy: { type: "root" }, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", - }); + } as const; + yield* service.upsert({ ...bound, threadId: ThreadId.make("thread:bound") }); + // Nor when an edit omits the thread it stays bound to. + yield* service.upsert(bound); }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 546d68c8ebe9b..9dd04b41c9275 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -22,8 +22,10 @@ import { type ScheduledTaskRunNowInput, type ScheduledTaskRunNowResult, type ScheduledTaskSetEnabledInput, + type ScheduledTaskThreadMode, type ScheduledTaskUpsertInput, managedPolicyDeniedMessage, + scheduledTaskThreadMode, managedRuntimeModeDenial, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -191,6 +193,19 @@ const decodeMissingFieldsJson = Schema.decodeUnknownOption(MissingFieldsJson); const encodeHeadersJson = Schema.encodeSync(HeadersJson); const encodeMissingFieldsJson = Schema.encodeSync(MissingFieldsJson); +/** + * Clients that predate thread modes send none, and may send a thread loaded before the first run + * bound one. An edit keeps the stored mode; binding a thread to a new-thread task continues it. + */ +function resolveThreadMode( + input: Pick, + existing: Pick | null, +): ScheduledTaskThreadMode { + if (input.threadMode !== undefined) return input.threadMode; + const stored = existing === null ? "new_thread" : scheduledTaskThreadMode(existing); + return stored === "new_thread" && input.threadId != null ? "continue" : stored; +} + interface ScheduledTaskRow { readonly task_id: string; readonly title: string; @@ -199,6 +214,7 @@ interface ScheduledTaskRow { readonly schedule_json: string; readonly project_id: string; readonly thread_id: string | null; + readonly thread_mode: string; readonly workspace_strategy_json: string; readonly model_selection_json: string; readonly runtime_mode: string; @@ -372,6 +388,7 @@ const decodeRow = Effect.fnUntraced( schedule, projectId: row.project_id, threadId: row.thread_id, + threadMode: row.thread_mode, workspaceStrategy, modelSelection, runtimeMode: row.runtime_mode, @@ -494,6 +511,7 @@ export const layer = Layer.effect( schedule_json, project_id, thread_id, + thread_mode, workspace_strategy_json, model_selection_json, runtime_mode, @@ -535,6 +553,7 @@ export const layer = Layer.effect( schedule_json, project_id, thread_id, + thread_mode, workspace_strategy_json, model_selection_json, runtime_mode, @@ -612,6 +631,7 @@ export const layer = Layer.effect( schedule_json, project_id, thread_id, + thread_mode, workspace_strategy_json, model_selection_json, runtime_mode, @@ -636,6 +656,7 @@ export const layer = Layer.effect( ${JSON.stringify(task.schedule)}, ${task.projectId}, ${task.threadId}, + ${scheduledTaskThreadMode(task)}, ${JSON.stringify(task.workspaceStrategy)}, ${JSON.stringify(task.modelSelection)}, ${task.runtimeMode}, @@ -660,7 +681,14 @@ export const layer = Layer.effect( enabled = excluded.enabled, schedule_json = excluded.schedule_json, project_id = excluded.project_id, - thread_id = excluded.thread_id, + -- A first run may bind the thread while this save is in flight; only a + -- switch to new threads unbinds. + thread_id = CASE + WHEN excluded.thread_mode = 'new_thread' THEN NULL + WHEN excluded.project_id != scheduled_tasks.project_id THEN excluded.thread_id + ELSE COALESCE(excluded.thread_id, scheduled_tasks.thread_id) + END, + thread_mode = excluded.thread_mode, workspace_strategy_json = excluded.workspace_strategy_json, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, @@ -798,6 +826,25 @@ export const layer = Layer.effect( ), ); + // The run already started the thread, so a failed write only costs the binding. + // A thread deleted before the binding landed already ran its task cleanup, so apply that + // cleanup here; a later deletion finds the binding. An unconfirmed lookup keeps the binding. + const bindThread = Effect.fn("ScheduledTaskService.bindThread")( + function* (task: ScheduledTask, threadId: ThreadId) { + yield* sql` + UPDATE scheduled_tasks SET thread_id = ${threadId} + WHERE task_id = ${task.id} AND created_at = ${task.createdAt} + AND project_id = ${task.projectId} + AND thread_id IS NULL AND thread_mode != 'new_thread' + `; + const shell = yield* threadManagement.getThreadShell(threadId); + if (shell === null || shell.deletedAt != null) yield* deleteForThread(threadId); + }, + Effect.catchCause((cause) => + Effect.logWarning("Could not bind a scheduled task to its first thread.", cause), + ), + ); + const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, trigger: "scheduled" | "manual" | "webhook", @@ -885,6 +932,7 @@ export const layer = Layer.effect( const commandId = CommandId.make(`scheduled-task:${fireKey}`); const messageId = MessageId.make(`scheduled-task-message:${fireKey}`); const prompt = webhook?.prompt ?? active.prompt; + const threadMode = scheduledTaskThreadMode(active); // A bound thread can move to another project (No project to a project) while // this fires. Follow it; one move needs at most two retries. Same ids replay safely. @@ -903,6 +951,7 @@ export const layer = Layer.effect( text: prompt, attachments: [], mode: "queue", + ...(threadMode === "fresh_session" ? { freshContext: true } : {}), createdBy: active.createdBy, creationSource: active.creationSource, }) @@ -944,7 +993,12 @@ export const layer = Layer.effect( createdBy: active.createdBy, creationSource: active.creationSource, }) - .pipe(Effect.map((launched) => launched.threadId)), + .pipe( + Effect.map((launched) => launched.threadId), + Effect.tap((threadId) => + threadMode === "new_thread" ? Effect.void : bindThread(active, threadId), + ), + ), ) : yield* Effect.exit( // The stored selection only seeds fresh threads. A bound run @@ -1209,16 +1263,6 @@ export const layer = Layer.effect( input.id === undefined ? {} : { taskId: input.id }, ); } - // Refused on save rather than at run time; a disabled task can still be saved, so one - // stored above the ceiling can be turned off before its mode is lowered. A task bound to a - // thread runs in that thread's mode, which the orchestrator checks instead. - const modeDenial = - input.enabled && input.threadId == null - ? managedRuntimeModeDenial(managedPolicy.summary, input.runtimeMode) - : undefined; - if (modeDenial !== undefined) { - return yield* taskError(modeDenial, input.id === undefined ? {} : { taskId: input.id }); - } const now = yield* localNow; const uuid = input.commandId === undefined @@ -1239,6 +1283,23 @@ export const layer = Layer.effect( // keep their run history, and so real load failures propagate instead // of silently resetting an existing row. const existingTask = yield* findTask(id); + const threadMode = resolveThreadMode(input, existingTask); + const keptThreadId = input.threadId ?? existingTask?.threadId ?? null; + // A bound thread belongs to its project, so moving the task leaves it for a new one. + const movedOffThread = + existingTask !== null && + existingTask.projectId !== input.projectId && + keptThreadId === existingTask.threadId; + const threadId = threadMode === "new_thread" || movedOffThread ? null : keptThreadId; + // Refused on save rather than at run time; a disabled task can still be saved, so one + // stored above the ceiling can be turned off before its mode is lowered. A task bound to a + // thread runs in that thread's mode, which the orchestrator checks instead. Checked before + // the save consumes a webhook secret. + const modeDenial = + input.enabled && threadId === null + ? managedRuntimeModeDenial(managedPolicy.summary, input.runtimeMode) + : undefined; + if (modeDenial !== undefined) return yield* taskError(modeDenial, { taskId: id }); // Keep the existing next_run_at when the schedule itself is untouched: // editing a title or prompt must not postpone (or resurrect) a due // run — only schedule/enabled changes restart the clock. @@ -1327,7 +1388,8 @@ export const layer = Layer.effect( enabled: input.enabled, schedule, projectId: input.projectId, - threadId: input.threadId ?? null, + threadId, + threadMode, workspaceStrategy, modelSelection: input.modelSelection, runtimeMode: input.runtimeMode, diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 1c65371cfbdbc..fbff362b78ea9 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -20,6 +20,7 @@ import type { ScheduledTask, ScheduledTaskId, ScheduledTaskSchedule, + ScheduledTaskThreadMode, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, ThreadId, @@ -149,6 +150,7 @@ const EMPTY_DRAFT: DraftState = { weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", + threadMode: "new_thread", workspaceMode: "worktree", baseRef: "main", startFromOrigin: true, @@ -1141,12 +1143,23 @@ function ScheduledTaskEditorDialog({ ); } +const THREAD_MODE_LABELS: Record = { + new_thread: "New thread each run", + continue: "One thread, continuing the conversation", + fresh_session: "One thread, fresh session each run", +}; + function ScheduledTaskDestination({ environmentId, threadId, + threadMode, + onThreadModeChange, }: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId | null; + readonly threadMode: ScheduledTaskThreadMode; + /** Absent on servers that predate thread modes. */ + readonly onThreadModeChange: ((mode: ScheduledTaskThreadMode) => void) | undefined; }) { const ref = threadId === null ? null : scopeThreadRef(environmentId, threadId); const shell = useThreadShell(ref); @@ -1155,20 +1168,45 @@ function ScheduledTaskDestination({ ref !== null && shell === null ? threadFamilies.familyAtom(environmentId, ref.threadId) : null, ); const thread = shell ?? family.data?.threads.find((row) => row.id === threadId); + const bound = ref !== null && threadMode !== "new_thread"; + const detail = + threadMode === "new_thread" + ? "Each run starts a new thread with the workspace and model below." + : !bound + ? threadMode === "continue" + ? "The first run starts a thread with the workspace and model below. Later runs continue that conversation." + : "The first run starts a thread with the workspace and model below. Later runs post there, each in a fresh session without earlier runs' context." + : threadMode === "continue" + ? "Each run continues this conversation, in its workspace and with its current model." + : "Each run starts a fresh session in this thread, without earlier runs' context."; return ( - - {ref === null ? ( -

- Each run starts a new thread with the workspace and model below. -

- ) : ( -
+ + {onThreadModeChange ? ( + + ) : null} +
+ {bound ? (

{thread?.title.trim() || `Thread ${ref.threadId}`}

-

- Each run continues this conversation, in its workspace and with its current model. -

-
- )} + ) : null} +

{detail}

+ {threadMode !== "new_thread" ? ( +

Deleting the thread also deletes this task.

+ ) : null} +
); } @@ -1193,6 +1231,8 @@ function ScheduledTaskEditorForm({ const offersOnce = environment?.serverConfig?.environment.capabilities.oneTimeSchedules === true || task?.schedule.type === "once"; + const offersThreadModes = + environment?.serverConfig?.environment.capabilities.scheduledTaskThreadModes === true; const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( @@ -1356,6 +1396,7 @@ function ScheduledTaskEditorForm({ schedule, projectId: selectedProjectId as ProjectId, threadId: draft.threadId ? (draft.threadId as ThreadId) : null, + ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), workspaceStrategy, modelSelection, // Tasks have no mode picker; a mode above the administrator's ceiling would fail every run. @@ -1482,6 +1523,12 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, threadMode })) + : undefined + } />
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 9e0d0dad27cb9..522554e1cce32 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -5,7 +5,9 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskThreadMode, type ScheduledTaskUpsertSchedule, + scheduledTaskThreadMode, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -70,6 +72,7 @@ export interface DraftState { readonly weekdays: ReadonlySet; readonly projectId: string; readonly threadId: string; + readonly threadMode: ScheduledTaskThreadMode; readonly workspaceMode: WorkspaceMode; readonly baseRef: string; readonly startFromOrigin: boolean; @@ -254,6 +257,7 @@ export function taskToDraft(task: ScheduledTask): DraftState { weekdays, projectId: task.projectId, threadId: task.threadId ?? "", + threadMode: scheduledTaskThreadMode(task), workspaceMode: task.workspaceStrategy.type, baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", startFromOrigin: diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index ba481be9f5b00..671ea14eb3f77 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -86,6 +86,13 @@ environment's time zone, which may differ from your phone's. Choose **Once** to run a task a single time at a date and time in your own time zone. If the environment is offline then, the task runs when it is back. +Under **Destination**, choose where runs go. **New thread each run** keeps every +run separate. The two one-thread choices start a thread on the first run and post +later runs there: one continues the same conversation, the other starts each run +in a fresh session that doesn't see earlier runs, so the thread stays readable +without the agent's context growing. Moving such a task to another project starts +a new thread there on its next run. + Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 997243d873a49..e0c7144220030 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -21,7 +21,7 @@ {"id": "stack-links-every-layer", "area": "pull-requests", "kind": "guard", "pattern": "stacked PRs: every layer ends up linked", "experience": "Must keep working: for a 2-PR stack, both layers end up linked, not only the top.", "rule": "For a stack, call it for every layer, not just the current branch or the top PR.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch feature/error-detail (on main) and branch feature/frozen-host (on top of feature/error-detail) are committed locally. Nothing is pushed yet.", "mocks": {"link_pull_request": "default"}, "world": {"branch": "feature/frozen-host", "branches": {"feature/error-detail": "e1d2c3b"}, "commits": [{"sha": "f7e6d5c", "subject": "feat(server): detect a frozen host and say so"}, {"sha": "e1d2c3b", "subject": "feat(server): include the failing step in connection errors"}], "nextPullRequests": "The next pull request created on acme/app is #124, and the one after it is #125."}, "shellMocks": [{"match": "gh pr create.*(--base|-B)[= ]['\"]?main\\b", "output": "https://github.com/acme/app/pull/124"}, {"match": "gh pr create.*(--base|-B)[= ]['\"]?feature/error-detail\\b", "output": "https://github.com/acme/app/pull/125"}]}, "prompt": "Open both as stacked PRs: feature/error-detail against main, and feature/frozen-host on top of it.", "expect": {"linked": ["~/pull/124", "~/pull/125"]}, "notes": "No real 'only the top layer linked' miss exists after v2 (checked all stacked creates since Oct 2). The real run used watch because of 'Full stop'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} {"id": "takeover-links-pr", "area": "pull-requests", "kind": "guard", "pattern": "takeover of a pasted PR links it", "experience": "Must keep working: on 'take over this PR and get it in', link the pasted PR before working on it.", "rule": "register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "context": "This worktree has the PR's branch fix/config-snapshot checked out.", "world": {"branch": "fix/config-snapshot", "commits": [{"sha": "abc1234", "subject": "fix(server): stream config snapshots so slow ones keep the socket alive"}], "diff": "diff --git a/apps/server/src/config/snapshot.ts b/apps/server/src/config/snapshot.ts\n--- a/apps/server/src/config/snapshot.ts\n+++ b/apps/server/src/config/snapshot.ts\n@@ -1,6 +1,8 @@\n-import { buildSnapshot, streamSnapshot } from './build';\n+import { streamSnapshot } from './build';\n \n export async function sendSnapshot(socket: Socket, config: Config) {\n- const snapshot = await buildSnapshot(config);\n- socket.send(snapshot);\n+ // Send in chunks so a slow snapshot never blocks the heartbeat.\n+ for await (const chunk of streamSnapshot(config)) {\n+ socket.send(chunk);\n+ }\n }\n", "files": {"apps/server/src/config/snapshot.ts": "import { streamSnapshot } from './build';\n\nexport async function sendSnapshot(socket: Socket, config: Config) {\n // Send in chunks so a slow snapshot never blocks the heartbeat.\n for await (const chunk of streamSnapshot(config)) {\n socket.send(chunk);\n }\n}\n", "apps/server/src/config/build.ts": "export async function buildSnapshot(config: Config) {\n return JSON.stringify(config);\n}\n\nexport async function* streamSnapshot(config: Config) {\n for (const [key, value] of Object.entries(config)) yield JSON.stringify({ key, value });\n}\n"}, "prs": [{"number": 123, "title": "fix(server): slow config snapshots no longer drop connections", "headRefName": "fix/config-snapshot", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pending"}, {"name": "Test Web", "state": "pending"}], "author": {"login": "contributor"}}]}}, "prompt": "https://github.com/acme/app/pull/123\n\nThis looks legit. Take over and get it in.", "expect": {"calls": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "A run that calls watch_pull_request first (which also links) would fail this check. If the runner treats watch as a link, relax it.", "maxSteps": 14} {"id": "handback-unwatches", "area": "pull-requests", "kind": "guard", "pattern": "unwatch before handing back", "experience": "Must keep working: when the babysit goal is met and the user merges, call unwatch_pull_request before the hand-back reply.", "rule": "When monitoring is done and you hand the work back to the user, call unwatch_pull_request first", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Make CI setup survive npm registry outages, then open a PR. You are done when CI on the PR is green. I will merge it myself."}, {"role": "assistant", "text": "Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it; I will report when CI is green.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml", "old_string": " - run: pnpm install", "new_string": " - run: pnpm install || pnpm install --registry https://registry.npmmirror.com"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/.github/workflows/ci.yml has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"ci: fall back to the registry mirror when npm is down\""}, "result": "[ci/npm-outage abc1234] ci: fall back to the registry mirror when npm is down\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin ci/npm-outage"}, "result": "To github.com:acme/app.git\n * [new branch] ci/npm-outage -> ci/npm-outage\nbranch 'ci/npm-outage' set up to track 'origin/ci/npm-outage'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"ci: fall back to the registry mirror when npm is down\" --fill"}, "result": "https://github.com/acme/app/pull/124"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "alreadyLinked": false}}, {"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "watching": true, "wasWatching": false}}]}], "mocks": {"unwatch_pull_request": "default"}, "world": {"branch": "ci/npm-outage", "commits": [{"sha": "abc1234", "subject": "ci: fall back to the registry mirror when npm is down"}], "diff": "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n--- a/.github/workflows/ci.yml\n+++ b/.github/workflows/ci.yml\n@@ -8,1 +8,1 @@\n- - run: pnpm install\n+ - run: pnpm install || pnpm install --registry https://registry.npmmirror.com\n", "prs": [{"number": 124, "title": "ci: fall back to the registry mirror when npm is down", "headRefName": "ci/npm-outage", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 12 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", "expect": {"calls": [{"tool": "unwatch_pull_request", "args": {"url": "~/pull/124"}}], "notCalls": ["watch_pull_request"], "shellMustNot": ["gh pr merge"]}, "notes": "Unwatch was followed 7 of 7 times after the fixes. No real 'forgot to unwatch' miss was found, so this is a guard, not a bad case.", "maxSteps": 7} -{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "once", "runAt": "2026-10-25T23:00:00-07:00"}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"]}, "notes": "A one-off time uses a once schedule. Before once existed, a weekly fixed_time plus a later delete was accepted."} +{"id": "schedule-one-off-tomorrow", "area": "pull-requests", "kind": "guard", "pattern": "schedule asked: schedule_task, not the job", "experience": "Must keep working: a schedule ask creates a schedule_task with an object schedule and reports the next run, without running the check now.", "rule": "schedule_task creates persistent recurring work in the app scheduler. Pass schedule as a structured object / After scheduling a timer, report the returned cadence and next run time", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Check app for memory leaks", "schedule": {"type": "once", "runAt": "2026-10-25T23:00:00-07:00"}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-25T23:00:00-07:00"}}, "shellMocks": [{"match": "\\bdate\\b", "output": "Saturday 2026-10-24 10:00 PDT"}]}, "prompt": "Definitely want to schedule something to check the app for memory leaks at some point tomorrow, maybe like 11pm?", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "textMust": ["(11(:00)? ?[Pp]\\.?[Mm]|23:00)"], "callsMustNot": [{"tool": "schedule_task", "args": {"freshSession": true}}]}, "notes": "A one-off time uses a once schedule. Before once existed, a weekly fixed_time plus a later delete was accepted."} {"id": "control-pr-lookup-question", "area": "pull-requests", "kind": "control", "pattern": "PRs named only as background in a question", "experience": "Asking about PRs must not link, watch, or list them.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"prs": [{"number": 101, "title": "fix(web): small UI fix", "state": "MERGED", "diff": "diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx\n--- a/apps/web/src/components/Sidebar.tsx\n+++ b/apps/web/src/components/Sidebar.tsx\n@@ -10,1 +10,1 @@\n- {title}\n+ {title}\n"}, {"number": 102, "title": "fix(ws): cap reconnect backoff at 30s", "state": "MERGED", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return Math.min(base * 2 ** attempt, 60_000);\n+ return Math.min(base * 2 ** attempt, 30_000);\n"}, {"number": 103, "title": "chore: update dependencies", "state": "MERGED", "diff": "diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml\n--- a/pnpm-lock.yaml\n+++ b/pnpm-lock.yaml\n@@ -120,1 +120,1 @@\n- vitest: 4.0.1\n+ vitest: 4.0.3\n"}]}}, "prompt": "Did any of #101, #102, or #103 touch the reconnect backoff logic?", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "watch_pull_request", "list_thread_pull_requests", "unlink_pull_request"]}, "notes": ""} {"id": "control-link-means-url", "area": "pull-requests", "kind": "control", "pattern": "'link the PR' means show the URL", "experience": "'Link the PR that did this' about a merged culprit PR means give the URL in the reply, not link_pull_request.", "rule": "Do not link unrelated PRs mentioned only as background.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Why isn't the new thread selector grouping projects anymore?"}, {"role": "assistant", "text": "#120, merged yesterday, removed the grouping step when it moved the selector to the new list component.", "toolCalls": [{"tool": "Bash", "input": {"command": "git log --oneline -S groupByProject -- apps/web/src/ThreadSelector.tsx"}, "result": "8e1f2a3 refactor(web): move thread selector to the shared list (#120)"}, {"tool": "Bash", "input": {"command": "git show 8e1f2a3 --stat"}, "result": "commit 8e1f2a3\n\n refactor(web): move thread selector to the shared list (#120)\n\n apps/web/src/ThreadSelector.tsx | 14 ++------------"}]}], "world": {"prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}}, "prompt": "Link the pr that did this", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "watch_pull_request"], "textMust": ["github\\.com/acme/app/pull/120"]}, "notes": "Least certain control: 'link' is ambiguous. The user's next message was 'File revert pr', which shows he wanted to see it, not track it."} {"id": "control-nightly-time-question", "area": "pull-requests", "kind": "control", "pattern": "question about a CI schedule is not a schedule ask", "experience": "'When does the next nightly go out?' is answered from the workflow file, with no scheduler tools.", "rule": "none", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Saturday 2026-10-24, 10:00 local time (UTC-7).", "shellMocks": [{"match": "^date\\b", "output": "Sat Oct 24 10:00:00 PDT 2026"}, {"match": "gh run list", "output": "completed\tsuccess\tnightly\tmain\tschedule\t991\t21m\t2026-10-24T09:00:00Z"}], "world": {"files": {".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 9 * * *'\n workflow_dispatch: {}\n"}}}, "prompt": "When does the next nightly go out? Want to start testing this asap", "expect": {"notCalls": ["schedule_task", "list_scheduled_tasks", "run_scheduled_task_now"]}, "notes": ""} @@ -35,3 +35,4 @@ {"id": "link-pasted-prs", "area": "pull-requests", "privacy": "commit-safe", "kind": "bad", "pattern": "links many PRs one call at a time", "experience": "Given a list of PR URLs to link, the agent linked them one call each.", "rule": "link_pull_request takes one PR per call", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link these here so i can track them:\nhttps://github.com/acme/app/pull/301\nhttps://github.com/acme/app/pull/302\nhttps://github.com/acme/app/pull/303\nhttps://github.com/acme/app/pull/304\nhttps://github.com/acme/app/pull/305\nhttps://github.com/acme/app/pull/306\nhttps://github.com/acme/app/pull/307\nhttps://github.com/acme/app/pull/308\nhttps://github.com/acme/app/pull/309\nhttps://github.com/acme/app/pull/310\nhttps://github.com/acme/app/pull/311\nhttps://github.com/acme/app/pull/312", "expect": {"calls": [{"tool": "link_pull_requests"}], "linked": ["~/pull/301", "~/pull/302", "~/pull/303", "~/pull/304", "~/pull/305", "~/pull/306", "~/pull/307", "~/pull/308", "~/pull/309", "~/pull/310", "~/pull/311", "~/pull/312"], "maxCalls": {"link_pull_requests": 1, "link_pull_request": 0}}, "maxSteps": 10, "source": {"thread": "Maintainer thread, 2026-10-05: 'can you link all of the stacked prs into this thread?' The agent linked 14 PRs one call each; another run linked 58."}} {"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}} {"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} +{"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e7a34dfe675bd..799802bc1c181 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -170,6 +170,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), /** Server honors `freshContext` on sends; older servers ignore it and keep the old context. */ freshContext: Schema.optionalKey(Schema.Boolean), + /** Server saves and runs scheduled task `threadMode`; older servers ignore it. */ + scheduledTaskThreadModes: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 17f41e9f8d139..c73bbd48e26f5 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -667,6 +667,12 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ description: "Default true: runs post in this thread. False: new thread per run.", }), ), + freshSession: Schema.optional( + Schema.Boolean.annotate({ + description: + "Runs share one thread but each starts a fresh session without earlier runs' context. Unbound: the first run makes the thread.", + }), + ), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -688,6 +694,7 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({ enabled: Schema.Boolean, projectId: ProjectId, boundThreadId: Schema.NullOr(ThreadId), + freshSession: Schema.Boolean, schedule: ScheduledTaskSchedule, nextRunAt: Schema.NullOr(IsoDateTime), lastRunStatus: ScheduledTaskRunStatus, @@ -730,6 +737,7 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ schedule: Schema.optional(OrchestratorMcpSchedule), enabled: Schema.optional(Schema.Boolean), bindToCurrentThread: Schema.optional(Schema.Boolean), + freshSession: Schema.optional(Schema.Boolean), }); export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type; diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 6d80c962a1fed..2a77777a9c3dc 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -224,6 +224,20 @@ export const ScheduledTaskWebhookEndpoint = Schema.Struct({ }); export type ScheduledTaskWebhookEndpoint = typeof ScheduledTaskWebhookEndpoint.Type; +/** + * Where runs go. One-thread modes launch their thread on the first run and keep it; `fresh_session` + * starts each later run in a new provider session there. + */ +export const ScheduledTaskThreadMode = Schema.Literals(["new_thread", "continue", "fresh_session"]); +export type ScheduledTaskThreadMode = typeof ScheduledTaskThreadMode.Type; + +/** Servers before thread modes omit it; their bound tasks continue the conversation. */ +export function scheduledTaskThreadMode( + task: Pick, +): ScheduledTaskThreadMode { + return task.threadMode ?? (task.threadId === null ? "new_thread" : "continue"); +} + export const ScheduledTask = Schema.Struct({ id: ScheduledTaskId, title: TrimmedNonEmptyString, @@ -232,6 +246,7 @@ export const ScheduledTask = Schema.Struct({ schedule: ScheduledTaskSchedule, projectId: ProjectId, threadId: Schema.NullOr(ThreadId), + threadMode: Schema.optional(ScheduledTaskThreadMode), workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -271,6 +286,8 @@ export const ScheduledTaskUpsertInput = Schema.Struct({ schedule: ScheduledTaskUpsertSchedule, projectId: ProjectId, threadId: Schema.optional(Schema.NullOr(ThreadId)), + /** Omitted: keep the stored mode, or derive it from `threadId` for a new or rebound task. */ + threadMode: Schema.optional(ScheduledTaskThreadMode), workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy, modelSelection: ModelSelection, runtimeMode: RuntimeMode, From 37481f044e749a658d94a927b33b268a733f94c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:38:21 -0800 Subject: [PATCH 016/200] fix(web): dragging a sidebar thread no longer scrolls the list out from under the pointer (#30346) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 4 ++++ apps/web/src/components/sidebar/SidebarListMarkers.tsx | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a82da0bf5f4de..1ed3065a92c0d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3297,6 +3297,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve "relative flex flex-col gap-px", sidebarListItems.length > 0 && "flex-1", actionSweep !== null && "**:pointer-events-none", + // Projected rows (two boundary labels, two hints) extend the scroll range; a range that + // shrinks as the target changes clamps scrollTop and flips the target under a still pointer. + dragState !== null && + "after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-30 after:h-px after:content-['']", )} > {(() => { diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 3416d80ff5e53..da9444a5c1b24 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -56,7 +56,10 @@ export function SidebarSectionPlaceholder(props: { marker={props.marker} data-testid={`sidebar-${props.marker}`} className="relative mx-0.5 -mb-px h-0" - revealOverflow={props.marker === "settled-placeholder" && props.showHint} + // Revealing on every drag scrolls the list out from under the pointer. + revealOverflow={ + props.marker === "settled-placeholder" && props.showHint && props.isDropTarget + } > {props.showHint ? (
Date: Thu, 5 Nov 2026 11:58:35 -0800 Subject: [PATCH 017/200] fix(assets): preserve the PR repository for GitHub media (#21631) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestMarkdown.tsx | 4 +- .../pull-requests/PullRequestMedia.tsx | 26 ++++----- .../pullRequestMediaSource.test.ts | 11 +++- .../pull-requests/pullRequestMediaSource.ts | 8 ++- apps/mobile/src/lib/videoPreviewSource.ts | 1 + apps/server/src/assets/AssetAccess.test.ts | 25 +++++++++ apps/server/src/assets/AssetAccess.ts | 13 +++-- apps/web/src/components/ChatMarkdown.tsx | 12 ++++- .../pullRequest/PullRequestDetailPanel.tsx | 3 +- .../PullRequestMarkdown.browser.test.tsx | 54 +++++++++++++++++-- .../pullRequest/PullRequestMarkdown.tsx | 14 ++++- packages/contracts/src/assets.ts | 4 +- 12 files changed, 138 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index f08b5c4846884..a067af12d1ec2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -57,7 +57,9 @@ export function PullRequestMarkdownRepository(props: { projectId: target.input.projectId, }); const cwd = project?.workspaceRoot; - const media = cwd ? { environmentId: target.environmentId, cwd } : null; + const media = cwd + ? { environmentId: target.environmentId, cwd, repository: target.input.repository } + : null; return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 54d3a82227731..3fcfbceb842f4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,12 +1,10 @@ import { createContext, useContext, useState } from "react"; -import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; -import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { - SOURCE_CONTROL_MEDIA_ASSET, - type AssetResource, - type EnvironmentId, -} from "@t3tools/contracts"; + pullRequestMediaSource, + pullRequestImageSource, + type PullRequestMediaLocation, +} from "./pullRequestMediaSource"; +import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; import { videoMimeType } from "@t3tools/shared/video"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; @@ -25,25 +23,19 @@ import { export const PullRequestMediaVisibleContext = createContext(true); -export const PullRequestMediaContext = createContext<{ - readonly environmentId: EnvironmentId; - readonly cwd: string; -} | null>(null); +export const PullRequestMediaContext = createContext(null); export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); - const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; - const resource: AssetResource | null = - context && fetchUrl - ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } - : null; + const source = pullRequestMediaSource(url, context); + const resource = "resource" in source ? (source.resource ?? null) : null; const asset = useAssetUrlState(context?.environmentId ?? null, resource); const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); return { uri: asset._tag === "Success" ? asset.url : resource ? null : url, cacheKey: context && resource ? assetImageCacheKey(context.environmentId, resource) : undefined, unavailable: resource !== null && asset._tag === "Failure", - source: pullRequestMediaSource(url, context), + source, resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined, }; } diff --git a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts index 3651b28402328..52ee08d3a6091 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts @@ -3,7 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; import { pullRequestImageSource } from "./pullRequestMediaSource"; -const location = { environmentId: EnvironmentId.make("test"), cwd: "/repo" }; +const location = { + environmentId: EnvironmentId.make("test"), + cwd: "/repo", + repository: "enterprise/private", +}; const url = "https://github.com/user-attachments/assets/abc-123"; describe("PR media previews", () => { @@ -13,6 +17,9 @@ describe("PR media previews", () => { const selected = { ...second, sourceIdentifier: "tapped" }; const gallery = filePreviewGallery(selected, [first, second]); expect(gallery).toEqual([first, selected]); - expect(gallery.every((item) => "resource" in item)).toBe(true); + expect(gallery).toMatchObject([ + { resource: { url, repository: location.repository } }, + { resource: { url: `${url}-second`, repository: location.repository } }, + ]); }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts index 075eccfc38d9b..d20bbeac6385c 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts @@ -5,6 +5,7 @@ import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; export interface PullRequestMediaLocation { readonly environmentId: EnvironmentId; readonly cwd: string; + readonly repository?: string; } /** Keep the original resource identity so previewers can resolve and renew its capability. */ @@ -13,7 +14,12 @@ export function pullRequestMediaSource(url: string, location: PullRequestMediaLo return location && fetchUrl ? { environmentId: location.environmentId, - resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: location.cwd, url: fetchUrl }, + resource: { + _tag: SOURCE_CONTROL_MEDIA_ASSET, + cwd: location.cwd, + url: fetchUrl, + ...(location.repository ? { repository: location.repository } : {}), + }, } : { uri: url }; } diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index 837b4fbb0ae40..c6688359b239d 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -63,6 +63,7 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string source.environmentId, source.resource.cwd, source.resource.url, + source.resource.repository ?? "", ] : [ "media-video", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index cc423a14f9607..b983a431d7908 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1471,6 +1471,31 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("preserves the change request repository through RPC decoding and signed media", () => + Effect.gen(function* () { + const decodeAssetCreateUrlInput = Schema.decodeEffect(AssetCreateUrlInput); + const resource = { + _tag: SOURCE_CONTROL_MEDIA_ASSET, + cwd: "/personal-fork", + url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + repository: "enterprise/private", + }; + const input = yield* decodeAssetCreateUrlInput({ resource }); + const issued = yield* issueAssetUrl(input); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const resolved = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + + expect(resolved).toEqual({ + kind: "source-control-media", + url: resource.url, + cwd: resource.cwd, + repository: resource.repository, + expiresAt: issued.expiresAt, + }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves a host's credentialed media through that host's driver", () => { const fetched: Array<{ url: string; headers: Readonly> }> = []; const url = "https://github.com/owner/repo/blob/main/docs/shot.png"; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index bbaa3f5c1f802..ecf7180b13efb 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -169,6 +169,7 @@ const AssetClaimsSchema = Schema.Union([ /** A host's `fetchUrl`, narrowed at mint time; the signature is what keeps it there. */ url: Schema.String, cwd: Schema.String, + repository: Schema.optionalKey(Schema.String), expiresAt: Schema.Number, }), ]); @@ -197,6 +198,7 @@ export type ResolvedAsset = readonly kind: "source-control-media"; readonly url: string; readonly cwd: string; + readonly repository?: string; readonly expiresAt: number; }; @@ -830,11 +832,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (media === null) { return yield* new AssetSourceControlMediaUrlValidationError({}); } + const { _tag, ...resource } = input.resource; claims = { version: 1, kind: SOURCE_CONTROL_MEDIA_ASSET, + ...resource, url: media.fetchUrl, - cwd: input.resource.cwd, expiresAt, }; fileName = media.fileName; @@ -944,12 +947,8 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } if (claims.kind === SOURCE_CONTROL_MEDIA_ASSET) { - return { - kind: "source-control-media", - url: claims.url, - cwd: claims.cwd, - expiresAt: claims.expiresAt, - } satisfies ResolvedAsset; + const { version: _version, ...media } = claims; + return { ...media, kind: "source-control-media" } satisfies ResolvedAsset; } if (claims.kind === "tool-output-image") { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2f2cfea67fd20..5e3c7ca96fd3b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -248,6 +248,8 @@ interface ChatMarkdownProps { /** Loads media a source control host keeps behind a credential through the server, which holds `cwd`'s; without it a private repository's uploads load unauthenticated and 404. */ sourceControlMedia?: boolean | undefined; + /** The change request's repository, when it can differ from the checkout at `cwd`. */ + sourceControlMediaRepository?: string | undefined; headingLevelOffset?: number | undefined; } @@ -494,6 +496,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, sourceControlMedia = false, + sourceControlMediaRepository, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -891,6 +894,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, sourceControlMedia, + sourceControlMediaRepository, renderContextReference, headingLevelOffset, imageBaseDir, @@ -1365,6 +1369,7 @@ const CHAT_MARKDOWN_COMPONENTS = { cwd, environmentId, sourceControlMedia, + sourceControlMediaRepository, imageBaseDir, threadRef, workspaceMutationId, @@ -1451,7 +1456,12 @@ const CHAT_MARKDOWN_COMPONENTS = { return ( | null, + detail: Pick | null, threadRef: ScopedThreadRef | null, ) { return { remarkPlugins: pullRequestAutolinkPlugins(detail), lineBreaks: sourceControlClients.find(detail?.provider ?? "")?.hardLineBreaks ?? false, threadRef, + repository: detail?.repository ?? null, }; } diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index 6bbfcdadd54ad..fae5270d27123 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -1,23 +1,41 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { EnvironmentId } from "@t3tools/contracts"; -import { expect, it } from "vite-plus/test"; +import { type AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; import { render } from "../../../test/render"; import { PullRequestMarkdown, PullRequestMarkdownContext } from "./PullRequestMarkdown"; -async function renderBody(host: string, options: { lineBreaks?: boolean } = {}) { +const requested = vi.hoisted(() => [] as Array); +vi.mock(import("~/assets/assetUrls"), async (original) => ({ + ...(await original()), + useAssetUrlState: (_environmentId, resource) => { + requested.push(resource); + return { _tag: "Loading" }; + }, +})); + +function setup() { + requested.length = 0; +} + +async function renderBody( + host: string, + options: { lineBreaks?: boolean; repository?: string; text?: string } = {}, +) { + const { repository = null, text = "first line\nsecond line", ...props } = options; const context = { remarkPlugins: [], lineBreaks: sourceControlClients.get(host).hardLineBreaks ?? false, threadRef: null, + repository, }; return render( , ); @@ -43,3 +61,29 @@ it("lets text the host did not write opt out of the host's breaks", async () => await expect.element(screen.getByText(/first line/)).toBeInTheDocument(); expect(screen.container.querySelectorAll("p br")).toHaveLength(0); }); + +it("loads the body's host media in the pull request's repository, not the checkout's", async () => { + setup(); + const image = "https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422"; + const video = "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456"; + await using _screen = await renderBody("github", { + repository: "enterprise/private", + text: `![shot](${image})\n\n${video}`, + }); + await vi.waitFor(() => + expect(requested).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cwd: "/repo", + url: expect.stringContaining("7195f963"), + repository: "enterprise/private", + }), + expect.objectContaining({ + cwd: "/repo", + url: expect.stringContaining("2f8c1a90"), + repository: "enterprise/private", + }), + ]), + ), + ); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 68d354f175846..00f7a5d0bd118 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -23,6 +23,8 @@ export const PullRequestMarkdownContext = createContext<{ remarkPlugins: PluggableList; lineBreaks: boolean; threadRef: ScopedThreadRef | null; + /** The change request's `owner/name`, which can differ from the checkout the body renders in. */ + repository: string | null; } | null>(null); function PullRequestHostVideo({ @@ -30,13 +32,20 @@ function PullRequestHostVideo({ cwd, url, fetchUrl, + repository, }: { environmentId: EnvironmentId; cwd: string; url: string; fetchUrl: string; + repository: string | undefined; }) { - const resource: AssetResource = { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd, url: fetchUrl }; + const resource: AssetResource = { + _tag: SOURCE_CONTROL_MEDIA_ASSET, + cwd, + url: fetchUrl, + ...(repository ? { repository } : {}), + }; const assetUrl = useAssetUrlState(environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource); const src = @@ -73,6 +82,7 @@ export function PullRequestMarkdown({ }) { const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); + const repository = context?.repository ?? undefined; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; return (
@@ -116,6 +127,7 @@ export function PullRequestMarkdown({ cwd={cwd} url={segment.url} fetchUrl={hostMedia.fetchUrl} + repository={repository} /> ); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index da3eb0e42062f..77bb94b99bea3 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -54,10 +54,12 @@ export const AssetResource = Schema.Union([ }), // Media a change request body points at on its host. A private repository serves these only // to a request that carries a credential, which the client has none of, so the server fetches - // them with the credential the host's driver holds for the repository at `cwd`. + // them with the credential the host's driver holds for the repository at `cwd`. `repository` + // names the change request's repository, which can differ from the checkout at `cwd`. Schema.TaggedStruct(SOURCE_CONTROL_MEDIA_ASSET, { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), + repository: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(512))), }), ]); export type AssetResource = typeof AssetResource.Type; From a09aef3287d29f74e2270fae05ab0cef8202cfa1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:58:36 -0800 Subject: [PATCH 018/200] fix(server): load PR uploads from SSO organizations (#21632) Co-authored-by: Claude Opus 5.5 --- apps/server/src/assets/AssetAccess.test.ts | 31 ++- .../src/assets/sourceControlMediaResponse.ts | 23 ++- .../source-control-core/src/server/driver.ts | 2 + .../src/server/gitHubMediaFetch.test.ts | 178 ++++++++++++++++-- .../src/server/gitHubMediaFetch.ts | 151 +++++++++++++-- 5 files changed, 345 insertions(+), 40 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index b983a431d7908..d762a15a830f8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -35,6 +35,7 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { fakeSourceControlDriverInstance } from "@t3tools/source-control-testing/fakeSourceControlDriver"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; @@ -1497,7 +1498,11 @@ describe("AssetAccess", () => { ); it.effect("serves a host's credentialed media through that host's driver", () => { - const fetched: Array<{ url: string; headers: Readonly> }> = []; + const fetched: Array<{ + url: string; + headers: Readonly>; + repository?: string; + }> = []; const url = "https://github.com/owner/repo/blob/main/docs/shot.png"; const media = sourceControlDefinitions.mediaUrl(url)!; const layerDrivers = Layer.succeed(BuiltInDrivers.SourceControlDrivers, [ @@ -1516,6 +1521,17 @@ describe("AssetAccess", () => { }), }, ]); + // Clients that predate `repository` get the checkout's. + const layerIdentities = Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/owner/repo", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "" }, + provider: media.definition.kind, + owner: "owner", + name: "repo", + }), + }); return Effect.gen(function* () { const decodeAssetCreateUrlInput = Schema.decodeEffect(AssetCreateUrlInput); @@ -1539,12 +1555,21 @@ describe("AssetAccess", () => { range: "bytes=0-4", cookie: "session", }); - expect(fetched).toEqual([{ url: media.fetchUrl, headers: { range: "bytes=0-4" } }]); + expect(fetched).toEqual([ + { url: media.fetchUrl, headers: { range: "bytes=0-4" }, repository: "owner/repo" }, + ]); expect(response.status).toBe(206); // The upstream sent no type, so it comes from the file name the host gave the media. expect(response.headers["content-type"]).toBe("image/png"); expect(response.headers["content-length"]).toBe("5"); - }).pipe(Effect.provide(Layer.merge(layerTest, layerDrivers)), Effect.scoped); + + // A pull request's own repository wins over the checkout it is shown in. + yield* sourceControlMediaResponse({ ...asset, repository: "enterprise/private" }, {}); + expect(fetched.at(-1)?.repository).toBe("enterprise/private"); + }).pipe( + Effect.provide(Layer.mergeAll(layerTest, layerDrivers, layerIdentities)), + Effect.scoped, + ); }); it.effect("refuses media no host keeps behind a credential, without echoing the URL", () => diff --git a/apps/server/src/assets/sourceControlMediaResponse.ts b/apps/server/src/assets/sourceControlMediaResponse.ts index 96e96f32a178a..0cc653bc06189 100644 --- a/apps/server/src/assets/sourceControlMediaResponse.ts +++ b/apps/server/src/assets/sourceControlMediaResponse.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { HttpServerResponse } from "effect/http"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; @@ -25,7 +26,12 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin export const sourceControlMediaResponse = Effect.fn( "SourceControlMediaResponse.sourceControlMediaResponse", )(function* ( - asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, + asset: { + readonly url: string; + readonly cwd: string; + readonly repository?: string; + readonly expiresAt: number; + }, requestHeaders: Record, ) { const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); @@ -46,7 +52,20 @@ export const sourceControlMediaResponse = Effect.fn( const value = requestHeaders[name]; if (value !== undefined) forwarded[name] = value; } - const response = yield* fetchMedia({ url: asset.url, headers: forwarded }); + // Clients that predate `repository` fall back to the repository checked out at `cwd`. + let repository = asset.repository; + if (repository === undefined) { + const identities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* identities.resolve(asset.cwd); + if (identity?.provider === media.definition.kind && identity.owner && identity.name) { + repository = `${identity.owner}/${identity.name}`; + } + } + const response = yield* fetchMedia({ + url: asset.url, + headers: forwarded, + ...(repository === undefined ? {} : { repository }), + }); if (response === null) return HttpServerResponse.empty({ status: 502, headers }); // It carries none of the upstream entity headers: a `content-length` with no body behind it // holds the connection open until the browser gives up on it. diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 7226faad651cf..c4fb4106a2054 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -25,6 +25,8 @@ export interface SourceControlMediaRequest { readonly url: string; /** Request headers to forward, such as `range`. */ readonly headers: Readonly>; + /** `owner/name` of the change request the media is in, else of the checkout it was shown in. */ + readonly repository?: string; } /** The server side of a definition's `credentials.appSignIn`. */ diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts index 07e2bbd8f724b..8248a76e4b04d 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts @@ -2,10 +2,78 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; +import * as GitHubRest from "./GitHubRest.ts"; + +const PR_REPOSITORY = "enterprise/private"; + +/** An SSO organization: the upload URL answers with a sign-in page, a rendered signed URL with bytes. */ +const makeSsoFixture = (uploadUrl: string) => { + const state = { account: "account-a", expired: new Set() }; + const rendered: Array = []; + const signedUrls: Array = []; + const requests: Array<{ url: string; authorization?: string; range?: string }> = []; + const credential = (host: string) => + Effect.sync(() => ({ + host, + token: Redacted.make(state.account), + fingerprint: state.account, + source: "gh" as const, + })); + const layer = Layer.mergeAll( + Layer.mock(GitHubCredentials.GitHubCredentials)({ get: credential }), + Layer.mock(GitHubRest.GitHubRest)({ + rest: (input) => + Effect.sync(() => { + rendered.push(input); + const signed = `https://private-user-images.githubusercontent.com/1/upload.mp4?jwt=${state.account}-${rendered.length}&x=1`; + const inRepository = + (input.body as { readonly context?: string } | undefined)?.context === PR_REPOSITORY; + if (inRepository) signedUrls.push(signed); + return { + status: 200, + headers: {}, + body: `

`, + truncated: false, + invalidUtf8: false, + }; + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + url: request.url, + ...(request.headers.authorization + ? { authorization: request.headers.authorization } + : {}), + ...(request.headers.range ? { range: request.headers.range } : {}), + }); + if (request.url === uploadUrl) { + return HttpClientResponse.fromWeb( + request, + new Response("Sign in with SSO", { + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + } + expect(signedUrls).toContain(request.url); + const status = state.expired.has(request.url) ? 403 : request.headers.range ? 206 : 200; + return HttpClientResponse.fromWeb( + request, + new Response(null, { status, headers: { "content-type": "video/mp4" } }), + ); + }), + ), + ), + ); + return { layer, state, rendered, signedUrls, requests }; +}; describe("GitHubMediaFetch", () => { it.effect("loads private media immediately after login with the GitHub credential", () => { @@ -24,17 +92,20 @@ describe("GitHubMediaFetch", () => { expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); }).pipe( Effect.provide( - Layer.mock(GitHubCredentials.GitHubCredentials)({ - get: (host) => - ++lookups === 1 - ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) - : Effect.succeed({ - host, - token: Redacted.make("signed-in"), - source: "gh" as const, - fingerprint: "fingerprint", - }), - }), + Layer.merge( + Layer.mock(GitHubRest.GitHubRest)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + ++lookups === 1 + ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) + : Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), ), Effect.provideService( HttpClient.HttpClient, @@ -70,15 +141,18 @@ describe("GitHubMediaFetch", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubCredentials.GitHubCredentials)({ - get: (host) => - Effect.succeed({ - host, - token: Redacted.make("signed-in"), - source: "gh" as const, - fingerprint: "fingerprint", - }), - }), + Layer.merge( + Layer.mock(GitHubRest.GitHubRest)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), ), Effect.provideService( HttpClient.HttpClient, @@ -101,4 +175,68 @@ describe("GitHubMediaFetch", () => { Effect.scoped, ); }); + + it.effect( + "renders SSO uploads in the PR repository and reuses the signed URL for a range", + () => { + const url = "https://github.com/user-attachments/assets/explicit-pr-context"; + const fixture = makeSsoFixture(url); + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const request = { url, headers: {}, repository: PR_REPOSITORY }; + expect((yield* fetchMedia(request))?.status).toBe(200); + expect((yield* fetchMedia({ ...request, headers: { range: "bytes=2-5" } }))?.status).toBe( + 206, + ); + expect(fixture.rendered).toHaveLength(1); + expect(fixture.rendered[0]).toMatchObject({ + host: "github.com", + method: "POST", + path: "markdown", + accept: "text/html", + body: { text: `![](${url})`, mode: "gfm", context: PR_REPOSITORY }, + }); + expect(fixture.requests).toEqual([ + { url, authorization: "Bearer account-a" }, + { url: fixture.signedUrls[0] }, + { url: fixture.signedUrls[0], range: "bytes=2-5" }, + ]); + }).pipe(Effect.provide(fixture.layer), Effect.scoped); + }, + ); + + it.effect("renders a legacy upload in the repository its path names", () => { + const url = `https://github.com/${PR_REPOSITORY}/assets/123/legacy`; + const fixture = makeSsoFixture(url); + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const response = yield* fetchMedia({ url, headers: {}, repository: "personal/unrelated" }); + expect(response?.status).toBe(200); + expect(fixture.rendered.map((input) => input.body)).toEqual([ + { text: `![](${url})`, mode: "gfm", context: PR_REPOSITORY }, + ]); + }).pipe(Effect.provide(fixture.layer), Effect.scoped); + }); + + it.effect("renders a fresh signed URL after an account switch, early expiry, or its TTL", () => { + const url = "https://github.com/user-attachments/assets/cache-lifecycle"; + const fixture = makeSsoFixture(url); + const request = { url, headers: {}, repository: PR_REPOSITORY }; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + yield* fetchMedia(request); + fixture.state.account = "account-b"; + yield* fetchMedia(request); + expect(fixture.rendered).toHaveLength(2); + + fixture.state.expired.add(fixture.signedUrls.at(-1)!); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect(fixture.rendered).toHaveLength(3); + + yield* TestClock.adjust("6 minutes"); + yield* fetchMedia(request); + expect(fixture.rendered).toHaveLength(4); + expect(fixture.requests.at(-1)?.url).toBe(fixture.signedUrls.at(-1)); + }).pipe(Effect.provide(fixture.layer), Effect.scoped); + }); }); diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.ts b/packages/source-control-github/src/server/gitHubMediaFetch.ts index 4dfb2a23e6341..b03c0d327a6ce 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.ts @@ -1,4 +1,5 @@ import type { SourceControlMediaRequest } from "@t3tools/source-control-core/server/driver"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import { @@ -11,6 +12,7 @@ import { import { GITHUB_CREDENTIALED_MEDIA_HOSTS } from "../shared/gitHubMedia.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubRest from "./GitHubRest.ts"; /** * Everything a redirect leads to — the presigned object stores GitHub hands assets off to — @@ -18,51 +20,170 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; * bearer token. */ const isCredentialedHost = (url: string) => { - try { - return GITHUB_CREDENTIALED_MEDIA_HOSTS.has(new URL(url).hostname.toLowerCase()); - } catch { - return false; - } + const target = URL.parse(url); + return ( + target !== null && + target.protocol === "https:" && + target.port === "" && + GITHUB_CREDENTIALED_MEDIA_HOSTS.has(target.hostname.toLowerCase()) + ); }; const MAX_REDIRECTS = 3; const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; +const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; +const ATTACHMENT_PATH_PATTERN = + /^\/(?:user-attachments\/assets\/[\w-]+|[^/]+\/[^/]+\/assets\/\d+\/[\w-]+)$/u; +const LEGACY_ATTACHMENT_REPOSITORY_PATTERN = /^\/([^/]+\/[^/]+)\/assets\//u; +const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u; +// GitHub's signed upload links last about five minutes. +const SIGNED_URL_CACHE_TTL_MS = 4 * 60_000; +const SIGNED_URL_CACHE_MAX_ENTRIES = 64; + +const contentTypeOf = (response: HttpClientResponse.HttpClientResponse) => + response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + +/** The repository an upload has to be rendered in, or `undefined` when it is not an upload. */ +const uploadRepository = (request: SourceControlMediaRequest) => { + const target = URL.parse(request.url); + if ( + target === null || + target.protocol !== "https:" || + target.hostname !== "github.com" || + target.port !== "" || + target.username !== "" || + target.password !== "" || + !ATTACHMENT_PATH_PATTERN.test(target.pathname) + ) { + return undefined; + } + // A legacy upload names its repository; one the caller supplies cannot override that. + const repository = + LEGACY_ATTACHMENT_REPOSITORY_PATTERN.exec(target.pathname)?.[1] ?? request.repository; + // An unusable explicit context must not fall back to an unrelated checkout. + return repository !== undefined && REPOSITORY_PATTERN.test(repository) ? repository : undefined; +}; export const make = Effect.gen(function* () { const credentials = yield* GitHubCredentials.GitHubCredentials; + const rest = yield* GitHubRest.GitHubRest; const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + const signedUrls = new Map(); // Both media hosts are served by github.com's account, which is the host `gh` stores it under. // Read on every fetch, so media loads as soon as the user signs in. - const token = Effect.suspend(() => credentials.get("github.com")).pipe( - Effect.map((credential) => credential.token), + const credential = Effect.suspend(() => credentials.get("github.com")).pipe( Effect.orElseSucceed(() => null), ); - return Effect.fn("GitHubMediaFetch.fetchMedia")(function* (request: SourceControlMediaRequest) { - const bearer = yield* token; - let target = request.url; + const fetchFollowingRedirects = Effect.fnUntraced(function* ( + url: string, + headers: Readonly>, + token: Redacted.Redacted | null, + ) { + let target = url; for (let hop = 0; ; hop += 1) { const authorization = - bearer !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(bearer)}` : null; + token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null; const response: HttpClientResponse.HttpClientResponse = yield* httpClient .execute( HttpClientRequest.get(target).pipe( HttpClientRequest.setHeaders({ - ...request.headers, + ...headers, // The bytes are streamed straight through, so never let an encoding layer in. "accept-encoding": "identity", ...(authorization === null ? {} : { authorization }), }), ), ) - .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), + // A signed URL's query string is a bearer capability; keep it out of tracing spans. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + ); const location = response.headers.location; if (response.status < 300 || response.status >= 400) return response; if (!location || hop >= MAX_REDIRECTS) return null; - const next = new URL(location, target); - if (next.protocol !== "https:") return null; + const next = URL.parse(location, target); + if (next === null || next.protocol !== "https:" || next.username || next.password) { + return null; + } target = next.toString(); } }); + + // An organization with SAML SSO answers a token-authenticated upload request with its sign-in + // page. Rendering the link as markdown in the repository's context hands back a signed URL. + const renderSignedUrl = Effect.fnUntraced(function* (url: string, repository: string) { + const rendered = yield* rest + .rest({ + host: "github.com", + operation: "renderMarkdown", + method: "POST", + path: "markdown", + accept: "text/html", + body: { text: `![](${url})`, mode: "gfm", context: repository }, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (rendered === null || rendered.status !== 200 || rendered.truncated) return null; + for (const match of rendered.body.matchAll(/]*\ssrc\s*=\s*(["'])(.*?)\1/giu)) { + const signed = URL.parse(match[2]?.replaceAll("&", "&") ?? ""); + if ( + signed?.protocol === "https:" && + signed.hostname === "private-user-images.githubusercontent.com" && + signed.port === "" && + signed.username === "" && + signed.password === "" && + signed.searchParams.get("jwt") + ) { + return signed.toString(); + } + } + return null; + }); + + return Effect.fn("GitHubMediaFetch.fetchMedia")(function* (request: SourceControlMediaRequest) { + const signedIn = yield* credential; + const repository = signedIn === null ? undefined : uploadRepository(request); + const cacheKey = + signedIn === null || repository === undefined + ? null + : [signedIn.fingerprint, repository, request.url].map(encodeURIComponent).join("|"); + const now = yield* Clock.currentTimeMillis; + const cached = cacheKey === null ? undefined : signedUrls.get(cacheKey); + let response: HttpClientResponse.HttpClientResponse | null; + if (cacheKey !== null && cached !== undefined && now - cached.at < SIGNED_URL_CACHE_TTL_MS) { + response = yield* fetchFollowingRedirects(cached.url, request.headers, null); + if (response === null || response.status >= 400) signedUrls.delete(cacheKey); + // A signed URL can expire before its TTL; only then is a fresh one worth rendering. + if (response?.status !== 403) return response; + } else { + if (cacheKey !== null) signedUrls.delete(cacheKey); + response = yield* fetchFollowingRedirects( + request.url, + request.headers, + signedIn?.token ?? null, + ); + if (response === null || contentTypeOf(response) !== "text/html") return response; + } + if (cacheKey === null || repository === undefined || signedIn === null) return response; + const signed = yield* renderSignedUrl(request.url, repository); + if (signed === null) return response; + const media = yield* fetchFollowingRedirects(signed, request.headers, null); + if ( + (media?.status === 200 || media?.status === 206) && + MEDIA_CONTENT_TYPE_PATTERN.test(contentTypeOf(media)) + ) { + // The render can run under another account if the user switched mid-request. + const current = yield* credential; + if (current?.fingerprint === signedIn.fingerprint) { + if (signedUrls.size >= SIGNED_URL_CACHE_MAX_ENTRIES) { + const oldest = signedUrls.keys().next().value; + if (oldest !== undefined) signedUrls.delete(oldest); + } + signedUrls.set(cacheKey, { at: now, url: signed }); + } + } + return media; + }); }); From 6a4031a4a0f1bbb106eb5bae3dce99b113c12715 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 12:12:20 -0800 Subject: [PATCH 019/200] fix(mobile): the Duo inspector stops at its widest instead of vanishing (#31665) Co-authored-by: Claude Opus 5.5 --- .../layout/AdaptiveWorkspaceLayout.tsx | 16 +++++++++------- .../createWorkspaceStackNavigator.ios.tsx | 3 ++- apps/mobile/src/lib/layout.test.ts | 17 ++++++++++++++++- apps/mobile/src/lib/layout.ts | 19 ++++++++++++++++--- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7196929b8a855..354d6413549fe 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -244,10 +244,14 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); - const nativeInspectorWidth = nativeInspectorColumnWidth( - viewportWidth, - nativeInspectorWidthFraction, - ); + const inspectorWidthFor = (fraction: number) => + nativeInspectorColumnWidth({ + viewportWidth, + horizontalSafeArea: + (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0), + fraction, + }); + const nativeInspectorWidth = inspectorWidthFor(nativeInspectorWidthFraction); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, ); @@ -1000,9 +1004,7 @@ function AdaptiveWorkspaceLayoutContent( setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, nativeInspectorWidth, setNativeInspectorWidth: (width: number) => - setNativeInspectorWidthFraction( - nativeInspectorColumnWidth(viewportWidth, width / viewportWidth) / viewportWidth, - ), + setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), composerKeyboardActive, keyboardHoldsSidebar, setComposerEditing, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index ab5d8baad876b..ea1b37ffb0693 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -33,6 +33,7 @@ import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { NATIVE_INSPECTOR_MIN_MAIN_WIDTH } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { @@ -389,7 +390,7 @@ function WorkspaceColumns( minimumPrimaryColumnWidth: 280, maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, - minimumSecondaryColumnWidth: 320, + minimumSecondaryColumnWidth: NATIVE_INSPECTOR_MIN_MAIN_WIDTH, // UIKit can retain its automatic width despite a new preference; constrain both edges. minimumInspectorColumnWidth: inspectorWidth, maximumInspectorColumnWidth: inspectorWidth, diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 1095bd75dd297..1cfe78e232332 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -169,7 +169,11 @@ describe("deriveWorkspacePaneLayout", () => { nativeWorkspace: true, primarySidebarPreferredVisible, auxiliaryPanePreferredVisible: true, - nativeInspectorWidth: nativeInspectorColumnWidth(width, 0.4), + nativeInspectorWidth: nativeInspectorColumnWidth({ + viewportWidth: width, + horizontalSafeArea: 0, + fraction: 0.4, + }), }), ).toMatchObject({ primarySidebarVisible: primarySidebarPreferredVisible && !suppressed, @@ -180,6 +184,17 @@ describe("deriveWorkspacePaneLayout", () => { }, ); + it.each([ + { viewportWidth: 1_376, horizontalSafeArea: 0 }, + { viewportWidth: 951, horizontalSafeArea: 76 }, + ])( + "never widens the native inspector past the page's minimum ($viewportWidth wide)", + ({ viewportWidth, horizontalSafeArea }) => { + const width = nativeInspectorColumnWidth({ viewportWidth, horizontalSafeArea, fraction: 1 }); + expect(viewportWidth - horizontalSafeArea - width).toBeGreaterThanOrEqual(320); + }, + ); + it("keeps an explicitly hidden thread sidebar hidden when the file inspector is visible", () => { const layout = deriveLayout({ width: 1_024, height: 1_366 }); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index d555a1c0e0e26..01464beb30c36 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -60,7 +60,7 @@ const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; // UIKit's inspector column keeps its own width, so the sidebar steps aside only when the page // would get narrower than this: landscape 11" and 13" iPads keep three columns, portrait ones don't. -const NATIVE_INSPECTOR_MIN_MAIN_WIDTH = 320; +export const NATIVE_INSPECTOR_MIN_MAIN_WIDTH = 320; /** * The window's width class. Compact: the sidebar is the root and pages push over it. Medium: the @@ -268,8 +268,21 @@ export function deriveWorkspacePaneLayout(input: { }; } -export function nativeInspectorColumnWidth(viewportWidth: number, fraction: number) { - return Math.max(260, Math.min(viewportWidth * 0.6, viewportWidth * fraction)); +/** + * UIKit drops the inspector column, and refuses to show it again, once the page beside it would + * fall under its minimum inside the safe area (the Duo's system bar takes width from the window). + */ +export function nativeInspectorColumnWidth(input: { + readonly viewportWidth: number; + readonly horizontalSafeArea: number; + readonly fraction: number; +}) { + const { viewportWidth } = input; + const maxWidth = Math.min( + viewportWidth * 0.6, + viewportWidth - input.horizontalSafeArea - NATIVE_INSPECTOR_MIN_MAIN_WIDTH, + ); + return Math.max(260, Math.min(maxWidth, viewportWidth * input.fraction)); } export function deriveFileInspectorPaneLayout(input: { From bc1b558442222027d324b97b524bbe9dc76419dc Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Thu, 5 Nov 2026 20:18:05 +0000 Subject: [PATCH 020/200] test(server): launcher PATH lookup test no longer drops the Windows drive letter (#22534) Co-authored-by: Alb11747 <40252880+Alb11747@users.noreply.github.com> --- apps/server/src/cli/update.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 7540c678f59d4..98ec13d32808e 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -407,6 +407,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const delimiter = (yield* HostProcess.Platform) === "win32" ? ";" : ":"; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); const launcher = path.join(root, "bin/t3"); yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); @@ -415,7 +416,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { const bare = yield* resolveLauncherPath.pipe( Effect.provideService(HostProcess.InvokedAs, "t3"), Effect.provideService(HostProcess.Environment, { - PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`, + PATH: [path.join(root, "missing"), path.join(root, "bin")].join(delimiter), }), Effect.provideService(HostProcess.WorkingDirectory, root), ); @@ -433,7 +434,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { assert.equal(bare, launcher); assert.equal(relative, launcher); assert.equal(absent, undefined); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.scoped), ); }); From ded4dacec19c532332b6b92c43514d4bd547dca8 Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 5 Nov 2026 14:18:42 -0600 Subject: [PATCH 021/200] test(server): replay tests delete their temp directories (#23445) Co-authored-by: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 2 +- .../Adapters/FxAdapterV2.testkit.ts | 2 +- .../Adapters/GrokAdapterV2.testkit.ts | 2 +- .../testkit/ProviderReplayHarness.test.ts | 19 +++++++++++++++++++ .../testkit/ProviderReplayHarness.ts | 5 +++-- 5 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 6151e57fb3024..88f514c30184f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -48,7 +48,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const replayGate = options.replayGate; const replayDir = yield* fileSystem - .makeTempDirectory({ + .makeTempDirectoryScoped({ prefix: `t3-orchestration-v2-acp-registry-replay-${transcript.scenario}-`, }) .pipe(Effect.orDie); diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.testkit.ts index 68d3b2c30ff3b..2f7e85a979602 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.testkit.ts @@ -46,7 +46,7 @@ function layerFxProviderAdapterRegistryReplay( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const replayGate = options.replayGate; const replayDir = yield* fileSystem - .makeTempDirectory({ + .makeTempDirectoryScoped({ prefix: `t3-orchestration-v2-fx-replay-${transcript.scenario}-`, }) .pipe(Effect.orDie); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 477ebbf7f0f86..0e7705882279b 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -50,7 +50,7 @@ function layerGrokProviderAdapterRegistryReplay( const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const replayGate = options.replayGate; const replayDir = yield* fileSystem - .makeTempDirectory({ + .makeTempDirectoryScoped({ prefix: `t3-orchestration-v2-grok-replay-${transcript.scenario}-`, }) .pipe(Effect.orDie); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.test.ts new file mode 100644 index 0000000000000..a4c6fb3075ae4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.test.ts @@ -0,0 +1,19 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { makeReplayServerConfig } from "./ProviderReplayHarness.ts"; + +it.effect("deletes its temp state directory when the scenario scope closes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* Effect.gen(function* () { + const config = yield* makeReplayServerConfig("temp-cleanup"); + assert.isTrue(yield* fs.exists(config.stateDir)); + return config.baseDir; + }).pipe(Effect.scoped); + + assert.isFalse(yield* fs.exists(baseDir)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index e4cac1bd4adfc..ed51ee642418c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import type * as Scope from "effect/Scope"; import type * as SqlClient from "effect/sql/SqlClient"; import type { MigrationError } from "effect/sql/Migrator"; import type { SqlError } from "effect/sql/SqlError"; @@ -80,13 +81,13 @@ export function makeReplayServerConfig( ): Effect.Effect< ServerConfig.ServerConfig["Service"], PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path + FileSystem.FileSystem | Path.Path | Scope.Scope > { const safeScenario = scenario.replace(/[^a-z0-9_-]+/gi, "-"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectory({ + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-orchestration-v2-replay-${safeScenario}-`, }); const stateDir = path.join(baseDir, "userdata"); From 66633fb6826c42ce2c08913fc99ab4adaae7648c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Nov 2026 12:18:53 -0800 Subject: [PATCH 022/200] fix(mobile): transcribe Android audio during recording (#31300) --- .../android/build.gradle | 2 + .../GoogleVoiceTranscriber.kt | 11 + .../t3voicetranscription/LivePcmCapture.kt | 216 ++++++++++++++++++ .../t3voicetranscription/LiveVoiceRecorder.kt | 198 ++++++++++++++++ .../modules/t3voicetranscription/PcmOutput.kt | 25 ++ .../T3VoiceTranscriptionModule.kt | 56 ++++- .../LivePcmCaptureTest.kt | 184 +++++++++++++++ .../LiveVoiceRecorderTest.kt | 123 ++++++++++ .../voice-input/VoiceInputProvider.tsx | 5 +- .../src/native/voiceRecorder.android.ts | 8 + apps/mobile/src/native/voiceRecorder.ts | 7 + .../native/voiceTranscription.android.test.ts | 22 +- .../src/native/voiceTranscription.android.ts | 20 ++ docs/user/composer.md | 4 +- .../src/voice-input/controller.test.ts | 93 ++++++++ .../src/voice-input/controller.ts | 32 ++- .../src/voice-input/transcription.ts | 1 + patches/expo-audio@58.0.9.patch | 6 +- pnpm-lock.yaml | 6 +- 19 files changed, 1004 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt create mode 100644 apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt create mode 100644 apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt create mode 100644 apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt create mode 100644 apps/mobile/src/native/voiceRecorder.android.ts create mode 100644 apps/mobile/src/native/voiceRecorder.ts diff --git a/apps/mobile/modules/t3-voice-transcription/android/build.gradle b/apps/mobile/modules/t3-voice-transcription/android/build.gradle index 05995a7cc4cce..ab2d08e070621 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/build.gradle +++ b/apps/mobile/modules/t3-voice-transcription/android/build.gradle @@ -16,9 +16,11 @@ android { dependencies { implementation project(':expo-modules-core') + implementation project(':expo-audio') implementation 'androidx.media3:media3-common:1.9.1' implementation 'com.google.mlkit:genai-speech-recognition:1.0.0-alpha2' testImplementation 'junit:junit:4.13.2' + testImplementation 'com.facebook.react:react-android' testImplementation 'org.robolectric:robolectric:4.16.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt index 95423844c1d2c..6884d720dc6cd 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt @@ -158,5 +158,16 @@ internal class MlKitSpeechClient(context: Context, locale: Locale, mode: Int) : } } + suspend fun transcribeLive(capture: LivePcmCapture, pipe: PcmPipe): String = pipe.use { + val request = speechRecognizerRequest { audioSource = pipe.source } + try { + collectGoogleVoiceTranscript( + livePcmResponses(capture, pipe) { recognizer.startRecognition(request) } + ) + } finally { + withContext(NonCancellable) { runCatching { recognizer.stopRecognition() } } + } + } + override fun close() = recognizer.close() } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt new file mode 100644 index 0000000000000..bd728283d8ada --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt @@ -0,0 +1,216 @@ +package expo.modules.t3voicetranscription + +import java.io.File +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.time.TimeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +internal interface PcmCaptureSource { + val capacityBytes: Int + fun start() + suspend fun read(buffer: ByteArray, blocking: Boolean): Int + fun stop() + fun release() +} + +internal class LivePcmCapture( + private val source: PcmCaptureSource, + val file: File, + private val output: PcmOutput, + private val clock: TimeSource = TimeSource.Monotonic +) { + @Volatile private var worker: Job? = null + private val completion = CompletableDeferred>() + private val microphoneStopped = CompletableDeferred>() + + @Volatile private var stopping = false + private var sourceStopped = false + private var sourceReleased = false + + @Volatile var bytes = 0L + private set + + @Volatile var metering = -160.0 + private set + val isFinished get() = completion.isCompleted + + @Volatile var inputExhausted = false + private set + val hasStarted get() = worker != null + val isRecording get() = hasStarted && !microphoneStopped.isCompleted + + fun start(scope: CoroutineScope, seconds: Double, onFinished: (Throwable?) -> Unit) { + check(worker == null && !sourceReleased) { "The microphone is not prepared." } + worker = scope.launch { + var result = runCatching { + source.start() + writeCapture(seconds, onFinished) + requireCompleteAudio(bytes > 0 && file.length() == bytes) { + "The microphone did not finish its captured audio." + } + } + result = finishSource(result) + inputExhausted = result.isSuccess + result = runCatching { output.finish() }.fold( + onSuccess = { result }, + onFailure = { failure -> mergeFailure(result, failure) } + ) + if (microphoneStopped.complete(result)) onFinished(result.exceptionOrNull()) + completion.complete(result) + }.also { job -> + job.invokeOnCompletion { error -> + if (!completion.isCompleted) { + val result = + finishSource(Result.failure(error ?: CancellationException("Capture stopped."))) + completion.complete(result) + microphoneStopped.complete(result) + runCatching { output.finish() } + } + } + } + } + + private suspend fun writeCapture( + seconds: Double, + onFinished: (Throwable?) -> Unit + ) = coroutineScope { + val started = clock.markNow() + val chunks = Channel(100) + var sent = 0L + val sender = launch { + var deadline = 0L + for (chunk in chunks) { + deadline = maxOf( + deadline + (chunk.size * 1000L + 31999) / 32000, + started.elapsedNow().inWholeMilliseconds + ) + delay((deadline - started.elapsedNow().inWholeMilliseconds).coerceAtLeast(0)) + output.write(chunk, chunk.size) + deadline = started.elapsedNow().inWholeMilliseconds + sent += chunk.size + } + } + val result = finishSource(runCatching { captureChunks(seconds, chunks) }) + microphoneStopped.complete(result) + if (result.exceptionOrNull() !is CancellationException) onFinished(result.exceptionOrNull()) + chunks.close() + result.getOrThrow() + sender.join() + requireCompleteAudio(sent == bytes) { "Speech recognition did not receive all captured audio." } + } + + private suspend fun captureChunks(seconds: Double, chunks: Channel) { + val buffer = ByteArray(3200) + var drained = 0 + file.outputStream().use { stream -> + while (true) { + currentCoroutineContext().ensureActive() + val draining = stopping || bytes >= seconds * 32000 + val count = source.read(buffer, !draining) + requireCompleteAudio(count in 0..buffer.size && count % 2 == 0) { + "The microphone returned incomplete audio." + } + if (count == 0) { + requireCompleteAudio(draining) { "The microphone stopped supplying audio." } + break + } + if (draining) { + drained += count + requireCompleteAudio(drained <= source.capacityBytes + buffer.size) { + "The microphone audio queue did not drain." + } + } + stream.write(buffer, 0, count) + // Keep draining the microphone while inference starts or applies backpressure. + if (!chunks.trySend(buffer.copyOf(count)).isSuccess) { + throw VoiceTranscriptionException( + "audio-overflow", + "Speech recognition fell behind capture." + ) + } + bytes += count + var peak = 0 + for (i in 0 until count step 2) { + val sample = ((buffer[i].toInt() and 255) or (buffer[i + 1].toInt() shl 8)).toShort() + peak = maxOf(peak, abs(sample.toInt())) + } + metering = if (peak == 0) -160.0 else 20 * log10(peak / 32768.0) + } + } + } + + suspend fun stop() { + stopping = true + if (worker == null) { + finishSource(Result.success(Unit)).getOrThrow() + output.finish() + return + } + try { + withTimeout(2000) { microphoneStopped.await().getOrThrow() } + } catch (error: TimeoutCancellationException) { + cancel() + throw VoiceTranscriptionException("incomplete-audio", "The microphone did not stop.", error) + } + } + + suspend fun awaitFinished() = completion.await().getOrThrow() + + fun abort() { + stopping = true + worker?.cancel() + runCatching { stopSource() } + runCatching { output.finish() } + if (worker == null) { + val result = finishSource(Result.failure(CancellationException("Capture was cancelled."))) + completion.complete(result) + microphoneStopped.complete(result) + } + } + + suspend fun cancel() = withContext(NonCancellable) { + abort() + worker?.join() + finishSource(Result.success(Unit)) + } + + @Synchronized private fun stopSource() { + if (sourceStopped) return + sourceStopped = true + source.stop() + } + + @Synchronized private fun finishSource(result: Result): Result { + if (sourceReleased) return result + sourceReleased = true + var outcome = runCatching { stopSource() }.fold( + onSuccess = { result }, + onFailure = { mergeFailure(result, it) } + ) + outcome = runCatching { source.release() }.fold( + onSuccess = { outcome }, + onFailure = { mergeFailure(outcome, it) } + ) + return outcome + } +} + +private fun mergeFailure(result: Result, failure: Throwable): Result { + val original = result.exceptionOrNull() ?: return Result.failure(failure) + if (original !== failure) original.addSuppressed(failure) + return result +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt new file mode 100644 index 0000000000000..b5032b587cb57 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt @@ -0,0 +1,198 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.MediaRecorder +import android.net.Uri +import androidx.annotation.RequiresApi +import expo.modules.audio.RecordingInputRoute +import expo.modules.audio.getMapFromDeviceInfo +import expo.modules.audio.recordingInputs +import expo.modules.audio.recordingInputUid +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.sharedobjects.SharedObject +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job + +@RequiresApi(31) +internal class LiveVoiceRecorder( + private val context: Context, + appContext: AppContext, + private val scope: CoroutineScope, + private val onRelease: (String) -> Unit +) : SharedObject(appContext) { + val id = UUID.randomUUID().toString() + private val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val route = RecordingInputRoute(manager) + private var audio: AudioRecord? = null + private var pipe: PcmPipe? = null + private var capture: LivePcmCapture? = null + private var inputSelection: Job? = null + private var inputCanceled = false + private var inferenceStarted = false + val uri: String? get() = capture?.file?.let { Uri.fromFile(it).toString() } + + @Suppress("TooGenericExceptionCaught") // Release native handles for every setup failure. + fun prepare() { + check(capture?.isRecording != true) { "Another voice recording is active." } + capture?.abort() + pipe?.close() + inputCanceled = false + inferenceStarted = false + val capacity = maxOf( + 32000, + AudioRecord.getMinBufferSize( + 16000, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + ) + val recorder = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + .setAudioFormat( + AudioFormat.Builder().setSampleRate(16000) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT).build() + ) + .setBufferSizeInBytes(capacity) + .build() + try { + check(recorder.state == AudioRecord.STATE_INITIALIZED) { "The microphone could not prepare." } + val output = PcmPipe.open() + try { + val file = File.createTempFile("dictation-live-", ".pcm", context.cacheDir) + capture = LivePcmCapture( + object : PcmCaptureSource { + override val capacityBytes = capacity + override fun start() { + recorder.startRecording() + check(recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + "The microphone could not start." + } + } + override suspend fun read(buffer: ByteArray, blocking: Boolean) = recorder.read( + buffer, + 0, + buffer.size, + if (blocking) AudioRecord.READ_BLOCKING else AudioRecord.READ_NON_BLOCKING + ) + override fun stop() { + if (recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) recorder.stop() + } + override fun release() { + try { + recorder.release() + } finally { + route.close() + } + } + }, + file, + output + ) + audio = recorder + pipe = output + } catch (error: Throwable) { + output.close() + throw error + } + } catch (error: Throwable) { + recorder.release() + throw error + } + } + + fun record(seconds: Double) { + val recording = requireNotNull(capture) { "The microphone is not prepared." } + recording.start(scope, seconds) { error -> + emit( + "recordingStatusUpdate", + mapOf( + "id" to id, + "isFinished" to true, + "hasError" to (error != null), + "error" to error?.message, + "url" to Uri.fromFile(recording.file).toString() + ) + ) + } + } + + suspend fun stop() { + val recording = capture + val output = pipe + try { + val selection = synchronized(this) { inputSelection } + selection?.cancel() + selection?.join() + recording?.stop() + } finally { + // Recognition can start after Stop and still needs the captured PCM pipe. + if (recording?.hasStarted != true) output?.close() + } + } + + fun status() = mapOf( + "canRecord" to (capture != null), + "isRecording" to (capture?.isRecording == true), + "durationMillis" to (capture?.bytes?.div(32) ?: 0), + "fileSize" to (capture?.file?.length() ?: 0), + "metering" to (capture?.metering ?: -160.0), + "mediaServicesDidReset" to false, + "url" to uri + ) + + fun inputs() = recordingInputs(manager).let { inputs -> + inputs.map { getMapFromDeviceInfo(it, inputs) } + } + + // Release the preferred route on cancellation as well as selection errors. + @Suppress("TooGenericExceptionCaught") + suspend fun setInput(uid: String) { + val job = currentCoroutineContext().job + synchronized(this) { + check(!inputCanceled) { "Microphone selection was cancelled." } + inputSelection = job + } + try { + val inputs = recordingInputs(manager) + val selected = inputs.firstOrNull { recordingInputUid(it, inputs) == uid } + ?: error("The preferred microphone is disconnected.") + route.select(selected) + val routed = recordingInputs(manager) + val source = routed.firstOrNull { it.isSource && recordingInputUid(it, routed) == uid } + ?: error("The preferred microphone is disconnected.") + check(audio?.setPreferredDevice(source) == true) { "Could not select the microphone." } + } catch (error: Throwable) { + route.close() + throw error + } finally { + synchronized(this) { if (inputSelection === job) inputSelection = null } + } + } + + @Synchronized fun cancelInputSelection() { + inputCanceled = true + inputSelection?.cancel() + } + + suspend fun transcribe(uri: String, client: MlKitSpeechClient): String { + check(this.uri == uri && !inferenceStarted) { "The voice recording is no longer available." } + inferenceStarted = true + return client.transcribeLive(requireNotNull(capture), requireNotNull(pipe)) + } + + override fun sharedObjectDidRelease() { + super.sharedObjectDidRelease() + cancelInputSelection() + capture?.abort() + pipe?.close() + onRelease(id) + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt index 8673ce0d790e8..d8a5c9d790674 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt @@ -80,6 +80,31 @@ internal fun pacedRecordedResponses( } } +internal fun livePcmResponses( + capture: LivePcmCapture, + output: PcmOutput, + responses: () -> Flow +): Flow = flow { + try { + withTimeout(330000) { + responses().collect { emit(it) } + requireCompleteAudio(capture.inputExhausted) { + "Speech recognition ended before receiving the complete recording." + } + capture.awaitFinished() + output.verifyConsumed() + } + } catch (e: TimeoutCancellationException) { + throw VoiceTranscriptionException( + "incomplete-audio", + "Speech recognition did not finish the complete recording. No transcript was added. Retry dictation.", + e + ) + } finally { + capture.cancel() + } +} + internal inline fun requireCompleteAudio(condition: Boolean, message: () -> String) { if (!condition) throw VoiceTranscriptionException("incomplete-audio", message()) } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt index b49791bea2b9b..f193926f1ce7d 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt @@ -6,7 +6,9 @@ import expo.modules.kotlin.Promise import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.modules.ModuleDefinitionBuilder import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -22,6 +24,7 @@ class T3VoiceTranscriptionModule : Module() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lock = Any() private var operation: Pair? = null + private val recorders = ConcurrentHashMap() override fun definition() = ModuleDefinition { Name("T3VoiceTranscription") @@ -66,6 +69,27 @@ class T3VoiceTranscriptionModule : Module() { } } + AsyncFunction("startLive") { + id: Int, + uri: String, + language: String, + mode: Int, + promise: Promise + -> + runOperation(id, promise, "transcription-failed") { + val recorder = recorders.values.firstOrNull { it.uri == uri } + ?: error("The voice recording is no longer available.") + val context = requireNotNull(appContext.reactContext) + MlKitSpeechClient(context, Locale.forLanguageTag(language), mode).use { + recorder.transcribe(uri, it) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + defineLiveRecorder() + } + AsyncFunction("cancel") { id: Int, promise: Promise -> scope.launch { val job = synchronized(lock) { operation?.takeIf { it.first == id }?.second } @@ -74,7 +98,37 @@ class T3VoiceTranscriptionModule : Module() { } } - OnDestroy { scope.cancel() } + OnDestroy { + recorders.values.toList().forEach { it.sharedObjectDidRelease() } + scope.cancel() + } + } + + private fun ModuleDefinitionBuilder.defineLiveRecorder() { + Class(LiveVoiceRecorder::class) { + Constructor { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + LiveVoiceRecorder(requireNotNull(appContext.reactContext), appContext, scope) { + recorders.remove(it) + }.also { recorders[it.id] = it } + } + Property("uri") { recorder -> recorder.uri } + AsyncFunction("prepareToRecordAsync") Coroutine { recorder: LiveVoiceRecorder -> + withContext(Dispatchers.IO) { recorder.prepare() } + } + Function("record") { recorder: LiveVoiceRecorder, options: Map -> + recorder.record(options["forDuration"] ?: 300.0) + } + AsyncFunction("stop") Coroutine { recorder: LiveVoiceRecorder -> recorder.stop() } + Function("getStatus") { recorder: LiveVoiceRecorder -> recorder.status() } + Function("getAvailableInputs") { recorder: LiveVoiceRecorder -> recorder.inputs() } + AsyncFunction("setInput") Coroutine { recorder: LiveVoiceRecorder, uid: String -> + recorder.setInput(uid) + } + Function("cancelInputSelection") { recorder: LiveVoiceRecorder -> + recorder.cancelInputSelection() + } + } } private fun runOperation( diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt new file mode 100644 index 0000000000000..b47c47c37eb1b --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt @@ -0,0 +1,184 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import java.io.ByteArrayOutputStream +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LivePcmCaptureTest { + private class Source : PcmCaptureSource { + override val capacityBytes = 32000 + val chunks = Channel(Channel.UNLIMITED) + val reading = Channel(Channel.UNLIMITED) + var released = false + override fun start() = Unit + override suspend fun read(buffer: ByteArray, blocking: Boolean): Int { + reading.send(Unit) + val chunk = if (blocking) chunks.receive() else chunks.tryReceive().getOrNull() ?: return 0 + chunk.copyInto(buffer) + return chunk.size + } + override fun stop() = Unit + override fun release() { + released = true + } + } + + private class Output(private val gate: CompletableDeferred? = null) : PcmOutput { + val bytes = ByteArrayOutputStream() + val first = CompletableDeferred() + val writing = CompletableDeferred() + val ended = CompletableDeferred() + var unread = false + override suspend fun write(buffer: ByteArray, count: Int) { + writing.complete(Unit) + gate?.await() + bytes.write(buffer, 0, count) + first.complete(Unit) + } + override fun finish() { + ended.complete(Unit) + } + override fun verifyConsumed() = requireCompleteAudio(!unread) { "Unread captured audio." } + override fun close() = Unit + } + + @Test fun transcribesDuringCaptureAndDrainsTheShortTailAfterStop() = runTest { + val source = Source() + val output = Output() + val file = File.createTempFile("live-capture-test-", ".pcm") + val first = ByteArray(3200) { (it % 251).toByte() } + val tail = byteArrayOf(1, 2, 3, 4, 5, 6) + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + capture.start(this, 300.0) {} + source.chunks.send(first) + output.first.await() + assertTrue(capture.isRecording) + assertFalse(output.ended.isCompleted) + assertArrayEquals(first, output.bytes.toByteArray()) + source.reading.receive() + source.reading.receive() + + val stopped = async(start = CoroutineStart.UNDISPATCHED) { capture.stop() } + source.chunks.send(tail) + stopped.await() + assertTrue(source.released) + assertFalse(capture.isRecording) + val text = collectGoogleVoiceTranscript( + livePcmResponses(capture, output) { + flow { + output.ended.await() + emit( + SpeechRecognizerResponse.FinalTextResponse("Opening. Repeat. Repeat.", emptyList()) + ) + emit(SpeechRecognizerResponse.FinalTextResponse(" 中間。", emptyList())) + emit(SpeechRecognizerResponse.PartialTextResponse(" An unfinished ending")) + emit(SpeechRecognizerResponse.PartialTextResponse(" End.")) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + ) + assertEquals("Opening. Repeat. Repeat. 中間。 End.", text) + assertArrayEquals(first + tail, output.bytes.toByteArray()) + assertArrayEquals(first + tail, file.readBytes()) + } finally { + capture.cancel() + file.delete() + } + } + + @Test fun rejectsIncompleteRecognitionAndUnreadCapturedAudio() = runTest { + for (failure in listOf("early", "unread", "timeout")) { + val early = failure == "early" + val source = Source() + val output = Output().apply { unread = failure == "unread" } + val file = File.createTempFile("live-capture-test-", ".pcm") + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + capture.start(this, if (early) 300.0 else 0.1) {} + source.chunks.send(ByteArray(3200)) + val result = runCatching { + collectGoogleVoiceTranscript( + livePcmResponses(capture, output) { + flow { + if (!early) output.ended.await() + if (failure == "timeout") awaitCancellation() + emit(SpeechRecognizerResponse.FinalTextResponse("Incomplete.", emptyList())) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + ) + } + assertEquals( + "incomplete-audio", + (result.exceptionOrNull() as VoiceTranscriptionException).code + ) + assertTrue(source.released) + } finally { + capture.cancel() + file.delete() + } + } + } + + @Test fun durationLimitDrainsQueuedPcmBeforeReleasingTheMicrophone() = runTest { + val source = Source() + val output = Output() + val file = File.createTempFile("live-capture-test-", ".pcm") + val expected = ByteArray(6400) { (it % 251).toByte() } + byteArrayOf(42, 0) + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + val stopped = CompletableDeferred() + try { + source.chunks.send(expected.copyOfRange(0, 3200)) + source.chunks.send(expected.copyOfRange(3200, 6400)) + source.chunks.send(expected.copyOfRange(6400, expected.size)) + capture.start(this, 0.1) { stopped.complete(Unit) } + stopped.await() + assertTrue(source.released) + assertEquals(expected.size.toLong(), file.length()) + capture.awaitFinished() + assertArrayEquals(expected, output.bytes.toByteArray()) + } finally { + capture.cancel() + file.delete() + } + } + + @Test fun cancellationWaitsForCaptureAndFeederCleanup() = runTest { + for (phase in listOf("prepared", "reading", "writing")) { + val source = Source() + val output = Output(if (phase == "writing") CompletableDeferred() else null) + val file = File.createTempFile("live-capture-test-", ".pcm") + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + if (phase != "prepared") { + capture.start(this, 300.0) {} + source.reading.receive() + if (phase == "writing") { + source.chunks.send(ByteArray(3200)) + output.writing.await() + } + } + capture.cancel() + assertTrue(source.released) + assertTrue(output.ended.isCompleted) + assertTrue(capture.isFinished) + assertTrue(runCatching { capture.awaitFinished() }.isFailure) + } finally { + file.delete() + } + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt new file mode 100644 index 0000000000000..6c78053e529e8 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt @@ -0,0 +1,123 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import android.media.AudioRecord +import android.os.ParcelFileDescriptor +import com.facebook.react.bridge.BridgeReactContext +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.ModulesProvider +import expo.modules.kotlin.modules.Module +import java.io.File +import java.lang.ref.WeakReference +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [31]) +class LiveVoiceRecorderTest { + private class Source(private val failStop: Boolean = false) : PcmCaptureSource { + override val capacityBytes = 32000 + val chunks = Channel(Channel.UNLIMITED) + val reading = Channel(Channel.UNLIMITED) + override fun start() = Unit + override suspend fun read(buffer: ByteArray, blocking: Boolean): Int { + reading.send(Unit) + val chunk = if (blocking) chunks.receive() else chunks.tryReceive().getOrNull() ?: return 0 + chunk.copyInto(buffer) + return chunk.size + } + override fun stop() { + check(!failStop) { "The microphone could not stop." } + } + override fun release() = Unit + } + + @Test fun preparingAgainReleasesTheUnusedMicrophone() = runTest { + withAppContext { context, appContext -> + val recorder = LiveVoiceRecorder(context, appContext, this) {} + try { + recorder.prepare() + val previous = ReflectionHelpers.getField(recorder, "audio") + assertEquals(AudioRecord.STATE_INITIALIZED, previous.state) + recorder.prepare() + assertEquals(AudioRecord.STATE_UNINITIALIZED, previous.state) + } finally { + recorder.sharedObjectDidRelease() + } + } + } + + @Test fun stoppingBeforeInferencePreservesQueuedAudioAndClosesUnusedPipes() = runTest { + withAppContext { context, appContext -> + for (phase in listOf("prepared", "recording", "draining", "prepared-failure")) { + val started = phase == "recording" || phase == "draining" + val source = Source(phase == "prepared-failure") + val pipe = PcmPipe.open() + val reader = ReflectionHelpers.getField( + pipe, + "reader" + ) + val file = File.createTempFile("recorder-stop-test-", ".pcm") + val capture = LivePcmCapture(source, file, pipe, testScheduler.timeSource) + val recorder = LiveVoiceRecorder(context, appContext, this) {} + ReflectionHelpers.setField(recorder, "capture", capture) + ReflectionHelpers.setField(recorder, "pipe", pipe) + try { + assertTrue(reader.fd.valid()) + if (started) { + capture.start(this, 300.0) {} + source.reading.receive() + source.chunks.send(ByteArray(3200)) + source.reading.receive() + val stopped = async(start = CoroutineStart.UNDISPATCHED) { + if (phase == "draining") capture.stop() else recorder.stop() + } + source.chunks.send(byteArrayOf(1, 2)) + stopped.await() + if (phase == "draining") recorder.stop() + assertFalse(capture.isFinished) + } else { + assertEquals(phase == "prepared-failure", runCatching { recorder.stop() }.isFailure) + } + assertEquals(started, reader.fd.valid()) + } finally { + capture.cancel() + recorder.sharedObjectDidRelease() + file.delete() + } + } + } + } + + private suspend fun withAppContext(block: suspend (Context, AppContext) -> Unit) { + val context = RuntimeEnvironment.getApplication() + val reactContext = BridgeReactContext(context) + val appContext = AppContext( + object : ModulesProvider { + override fun getModulesMap() = emptyMap, String?>() + }, + expo.modules.core.ModuleRegistry(emptyList(), emptyList()), + WeakReference(reactContext) + ) + try { + block(context, appContext) + } finally { + appContext.javaClass.declaredMethods.first { + it.name.startsWith("onDestroy") && it.parameterCount == 0 + } + .invoke(appContext) + reactContext.destroy() + } + } +} diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index eab40efd4905b..ace4f2594a73f 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { AudioModule, RecordingPresets, type RecordingStatus } from "expo-audio"; +import { RecordingPresets, type RecordingStatus } from "expo-audio"; import { File } from "expo-file-system"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSyncExternalStore } from "react"; @@ -14,6 +14,7 @@ import { rememberMicrophones, } from "../../lib/microphonePriority"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { createVoiceRecorder } from "../../native/voiceRecorder"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; import { @@ -87,7 +88,7 @@ const voiceInputAtom = Atom.make((get) => { void session.controller.appMovedToBackground(); }); const recorder = createLazyVoiceRecorder({ - create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS), + create: () => createVoiceRecorder(VOICE_RECORDING_OPTIONS), onStatus: (status: RecordingStatus) => { session.controller.handleRecorderStatus({ isFinished: status.isFinished, diff --git a/apps/mobile/src/native/voiceRecorder.android.ts b/apps/mobile/src/native/voiceRecorder.android.ts new file mode 100644 index 0000000000000..4ac0497d341db --- /dev/null +++ b/apps/mobile/src/native/voiceRecorder.android.ts @@ -0,0 +1,8 @@ +import { AudioModule } from "expo-audio"; +import { createLiveVoiceRecorder } from "./voiceTranscription.android"; + +export function createVoiceRecorder( + options: ConstructorParameters[0], +) { + return createLiveVoiceRecorder() ?? new AudioModule.AudioRecorder(options); +} diff --git a/apps/mobile/src/native/voiceRecorder.ts b/apps/mobile/src/native/voiceRecorder.ts new file mode 100644 index 0000000000000..c9f274b3f0b7a --- /dev/null +++ b/apps/mobile/src/native/voiceRecorder.ts @@ -0,0 +1,7 @@ +import { AudioModule } from "expo-audio"; + +export function createVoiceRecorder( + options: ConstructorParameters[0], +) { + return new AudioModule.AudioRecorder(options); +} diff --git a/apps/mobile/src/native/voiceTranscription.android.test.ts b/apps/mobile/src/native/voiceTranscription.android.test.ts index 7a2e39e4304e8..e171cf9cb500a 100644 --- a/apps/mobile/src/native/voiceTranscription.android.test.ts +++ b/apps/mobile/src/native/voiceTranscription.android.test.ts @@ -5,6 +5,17 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn<(id: number) => Promise<{ locale: string; mode: number }>>(), transcribe: vi.fn<(id: number, uri: string, locale: string, mode: number) => Promise>(), cancel: vi.fn<(id: number) => Promise>(), + LiveVoiceRecorder: class { + readonly uri = "file:///live.pcm"; + }, +})); + +vi.mock("react-native", () => ({ + Platform: { + get Version() { + return 31; + }, + }, })); vi.mock("expo", () => ({ @@ -15,8 +26,17 @@ import { VoiceInputController, resetVoiceInputGlobalsForTests, } from "../../../../packages/client-runtime/src/voice-input/controller"; +import { Platform } from "react-native"; + +import { createLiveVoiceRecorder, getLocalVoiceTranscriber } from "./voiceTranscription.android"; -import { getLocalVoiceTranscriber } from "./voiceTranscription.android"; +it.each([ + [30, null], + [31, "file:///live.pcm"], +] as const)("offers live recording only on supported Android versions (%s)", (version, uri) => { + using _version = vi.spyOn(Platform, "Version", "get").mockReturnValue(version); + expect(createLiveVoiceRecorder()?.uri ?? null).toBe(uri); +}); it.each([false, true])( "waits for device support before offering a transcriber (%s)", diff --git a/apps/mobile/src/native/voiceTranscription.android.ts b/apps/mobile/src/native/voiceTranscription.android.ts index 3749b6d2a9726..78cc33d47b031 100644 --- a/apps/mobile/src/native/voiceTranscription.android.ts +++ b/apps/mobile/src/native/voiceTranscription.android.ts @@ -1,4 +1,7 @@ import { requireOptionalNativeModule } from "expo"; +import type { RecorderState, RecordingStatus } from "expo-audio"; +import { Platform } from "react-native"; +import type { NativeVoiceRecorder } from "../features/voice-input/lazyVoiceRecorder"; import { VoiceTranscriptionError, @@ -13,6 +16,8 @@ type GoogleTranscriptionModule = { prepare(id: number): Promise; transcribe(id: number, uri: string, locale: string, mode: number): Promise; cancel(id: number): Promise; + readonly LiveVoiceRecorder?: new () => NativeVoiceRecorder; + startLive?(id: number, uri: string, locale: string, mode: number): Promise; }; const native = requireOptionalNativeModule("T3VoiceTranscription"); @@ -33,6 +38,12 @@ const nativeErrorMessages: Readonly> = { let nextOperationId = 0; +export function createLiveVoiceRecorder() { + return Number(Platform.Version) >= 31 && native?.LiveVoiceRecorder + ? new native.LiveVoiceRecorder() + : null; +} + export async function getLocalVoiceTranscriber(): Promise { if (!native || !(await native.checkAvailability())) return null; const module = native; @@ -41,8 +52,17 @@ export async function getLocalVoiceTranscriber(): Promise module.prepare(id), ); + const startLive = module.startLive?.bind(module); return { locale: prepared.locale, + ...(module.LiveVoiceRecorder && startLive + ? { + start: (uri: string, { signal }: { signal: AbortSignal }) => + runOperation(module, signal, "transcription-failed", (id) => + startLive(id, uri, prepared.locale, prepared.mode), + ), + } + : {}), transcribe: (uri, { signal }) => runOperation(module, signal, "transcription-failed", (id) => module.transcribe(id, uri, prepared.locale, prepared.mode), diff --git a/docs/user/composer.md b/docs/user/composer.md index d1b0562203fb8..86dff30a79c15 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -198,8 +198,8 @@ voice input isn't available. On Android, keep T3 Code open while recording and transcribing. The first use may download a speech model and needs a network connection. -Later transcription works offline for that language. On Android, transcription -processes the full recording at roughly its original duration. Recordings can be up to five +Later transcription works offline for that language. Android processes audio while +you record, then finishes the transcript after you stop. Recordings can be up to five minutes long. Canceling or an audio interruption discards the recording and preserves your existing draft. Navigating to another screen keeps dictation active; use the floating controls to finish or cancel. While recording, the screen stays diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts index 52069d1bd67b9..a09bfe6f58378 100644 --- a/packages/client-runtime/src/voice-input/controller.test.ts +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -243,6 +243,13 @@ describe("VoiceInputController", () => { expect(harness.recorder.record).toHaveBeenCalledWith({ forDuration: VOICE_RECORDING_LIMIT_SECONDS, }); + await harness.controller.handleRecorderStatus({ + isFinished: true, + hasError: false, + error: null, + url: "file:///previous-recording.m4a", + }); + expect(harness.controller.currentState.phase).toBe("recording"); const stopping = harness.controller.stop(); harness.controller.handleRecorderStatus({ @@ -342,6 +349,92 @@ describe("VoiceInputController", () => { expect(events).toEqual(["released", "transcribed"]); }); + it("starts streaming transcription during recording and commits only after stopping", async () => { + const transcript = deferred(); + const start = vi.fn(() => transcript.promise); + const transcribe = vi.fn(async () => "replayed audio"); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => ({ locale: "en-US", start, transcribe }), + }), + }); + await harness.controller.start(); + expect(start).toHaveBeenCalledWith("file:///voice.m4a", { + signal: expect.any(AbortSignal), + }); + expect(harness.controller.currentState.phase).toBe("recording"); + expect(harness.commits).toEqual([]); + + const stopping = harness.controller.stop(); + transcript.resolve("opening middle and final words"); + await stopping; + expect(harness.commits[0]?.text).toBe("hello opening middle and final words"); + expect(transcribe).not.toHaveBeenCalled(); + expect(harness.deleted).toEqual(["file:///voice.m4a"]); + }); + + it("waits for canceled live inference before deleting its recording or starting again", async () => { + const transcript = deferred(); + const released = deferred(); + let signal: AbortSignal | undefined; + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => ({ + ...preparedTranscription(), + start: (_uri: string, options: { signal: AbortSignal }) => { + signal = options.signal; + return transcript.promise; + }, + }), + }), + deleteRecording: () => released.resolve(undefined), + }); + await harness.controller.start(); + expect(signal).toBeDefined(); + harness.controller.cancel(); + expect(signal?.aborted).toBe(true); + const next = createHarness(); + const nextStart = next.controller.start(); + expect(next.recorder.record).not.toHaveBeenCalled(); + + transcript.resolve("discard this"); + await released.promise; + await nextStart; + expect(harness.commits).toEqual([]); + expect(next.controller.currentState.phase).toBe("recording"); + await next.controller.interruptRecording(); + }); + + it("stops recording after a live inference failure and preserves the draft for retry", async () => { + const transcript = deferred(); + const released = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => ({ ...preparedTranscription(), start: () => transcript.promise }), + }), + deleteRecording: () => released.resolve(undefined), + }); + await harness.controller.start(); + transcript.reject( + new VoiceTranscriptionError({ + code: "transcription-failed", + message: "Speech quota reached.", + }), + ); + await released.promise; + expect(harness.controller.currentState).toMatchObject({ + phase: "error", + error: "Speech quota reached.", + errorAction: "retry", + }); + expect(harness.recorder.stop).toHaveBeenCalled(); + expect(harness.commits).toEqual([]); + const next = createHarness(); + await next.controller.start(); + expect(next.controller.currentState.phase).toBe("recording"); + await next.controller.interruptRecording(); + }); + it("ignores a late transcript after the draft owner changes", async () => { const transcription = deferred(); const transcriptionEntered = deferred(); diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index 5b057c61fab23..733e37c8a3c4d 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -194,6 +194,7 @@ export class VoiceInputController { private operationToken = 0; private sessionToken: VoiceInputSession | null = null; private transcription: PreparedVoiceTranscription | null = null; + private liveTranscription: Promise<{ text: string } | { error: unknown }> | null = null; private transcriptionAbortController: AbortController | null = null; private capturedDraft: VoiceDraftSnapshot | null = null; private recordingUri: string | null = null; @@ -278,6 +279,21 @@ export class VoiceInputController { return; } this.capturedDraft = capturedDraft; + if (this.transcription.start && this.recordingUri) { + const start = this.transcription.start; + const uri = this.recordingUri; + this.liveTranscription = runTranscriptionOperation(() => + start(uri, { signal: abortController.signal }), + ).then( + (text) => ({ text }), + (error: unknown) => { + if (this.isCurrent(operationToken) && this.state.phase === "recording") { + void this.discardRecording(transcriptionErrorMessage(error)); + } + return { error }; + }, + ); + } this.dependencies.recorder.record({ forDuration: VOICE_RECORDING_LIMIT_SECONDS }); this.setState({ phase: "recording", error: null, errorAction: null }); } catch { @@ -341,6 +357,7 @@ export class VoiceInputController { handleRecorderStatus(status: VoiceRecorderStatus): Promise | void { if (this.state.phase !== "recording") return; + if (status.url && this.recordingUri && status.url !== this.recordingUri) return; if (status.hasError) { return this.interruptRecording( status.error ?? "Voice recording was interrupted.", @@ -402,9 +419,15 @@ export class VoiceInputController { const capturedDraft = this.capturedDraft; let transcript: string; try { - transcript = await runTranscriptionOperation(() => - transcription.transcribe(recordingUri, { signal }), - ); + if (this.liveTranscription) { + const result = await this.liveTranscription; + if ("error" in result) throw result.error; + transcript = result.text; + } else { + transcript = await runTranscriptionOperation(() => + transcription.transcribe(recordingUri, { signal }), + ); + } } catch (error) { if (this.isCurrent(operationToken)) { this.setError(transcriptionErrorMessage(error), "retry"); @@ -454,6 +477,7 @@ export class VoiceInputController { } private async releaseResources(): Promise { + if (this.liveTranscription) this.transcriptionAbortController?.abort(); this.rememberRecordingUri(this.recordingUri); try { this.rememberRecordingUri(this.dependencies.recorder.uri); @@ -463,6 +487,8 @@ export class VoiceInputController { await this.dependencies.recorder.stop(); } catch {} } + await this.liveTranscription; + this.liveTranscription = null; this.recordingUri = null; for (const uri of this.ownedRecordingUris) { try { diff --git a/packages/client-runtime/src/voice-input/transcription.ts b/packages/client-runtime/src/voice-input/transcription.ts index 4a8b1ab4e28e9..12bfb06e0cd67 100644 --- a/packages/client-runtime/src/voice-input/transcription.ts +++ b/packages/client-runtime/src/voice-input/transcription.ts @@ -7,6 +7,7 @@ export type VoiceTranscriptionOptions = { export type PreparedVoiceTranscription = { readonly locale: string; + readonly start?: (uri: string, options: VoiceTranscriptionOptions) => Promise; readonly transcribe: (uri: string, options: VoiceTranscriptionOptions) => Promise; }; diff --git a/patches/expo-audio@58.0.9.patch b/patches/expo-audio@58.0.9.patch index 8249a399b3bad..c241902a1c700 100644 --- a/patches/expo-audio@58.0.9.patch +++ b/patches/expo-audio@58.0.9.patch @@ -339,7 +339,7 @@ index 81819c1d771bdeec093bb35a65a5e1e1af0cf9d7..cd0c7624b4cd8a4ebf7ed3b3390cfc76 return map } -+internal fun recordingInputs(manager: AudioManager): List { ++fun recordingInputs(manager: AudioManager): List { + val inputs = manager.getDevices(AudioManager.GET_DEVICES_INPUTS).filter { + recordingInputType(it.type) != null + } @@ -367,7 +367,7 @@ index 81819c1d771bdeec093bb35a65a5e1e1af0cf9d7..cd0c7624b4cd8a4ebf7ed3b3390cfc76 + else -> null +} + -+internal fun recordingInputUid(device: AudioDeviceInfo, inputs: List = emptyList()): String { ++fun recordingInputUid(device: AudioDeviceInfo, inputs: List = emptyList()): String { + val base = recordingInputBaseUid(device) + val ambiguous = !isBluetoothInput(device.type) && inputs.any { + it.id != device.id && recordingInputBaseUid(it) == base @@ -401,7 +401,7 @@ index 0000000000000000000000000000000000000000..cdba9724318cf06afd21b4df6b895b38 +import kotlinx.coroutines.withTimeout + +@RequiresApi(31) -+internal class RecordingInputRoute(private val manager: AudioManager) : Closeable { ++class RecordingInputRoute(private val manager: AudioManager) : Closeable { + private var previousMode: Int? = null + + suspend fun select(input: AudioDeviceInfo) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71ad239cc0d3a..d1af39291d734 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,7 +370,7 @@ patchedDependencies: alchemy@2.0.0-beta.85: 07cc8c7e14b3b9af143ddd40a059f117c27e55139fbf20bd5dde2eb30509cedd app-builder-lib@26.16.1: 0fbe9ae51783f5768ebe197d12d1fdb7d477a08c92e5db2f6356bcce62613c53 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 - expo-audio@58.0.9: b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba + expo-audio@58.0.9: cb873a17fbd9aca52ddcecbde4c3d899566f7e265ab49a992963a6276b29bf52 expo-modules-core@58.0.22: f0b0fd100550ed76e5c25b4d13eb7c9ff62825f8f197c53edf7bf447c50c9fb7 expo-network@58.0.3: cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237 expo-sharing@58.0.19: 321a86e1471e34c53f1f2937fbb4304ea3f7b7a78ad9e4d35febc2b9e73e9688 @@ -619,7 +619,7 @@ importers: version: 58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) expo-audio: specifier: 58.0.9 - version: 58.0.9(patch_hash=b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba)(expo-asset@58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.9(patch_hash=cb873a17fbd9aca52ddcecbde4c3d899566f7e265ab49a992963a6276b29bf52)(expo-asset@58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) expo-auth-session: specifier: ~58.0.13 version: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) @@ -18132,7 +18132,7 @@ snapshots: - supports-color - typescript - expo-audio@58.0.9(patch_hash=b391b150cf67acdf23a9d924b791a63f8c4c84ec7a7b18cc82f815f7933fe4ba)(expo-asset@58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): + expo-audio@58.0.9(patch_hash=cb873a17fbd9aca52ddcecbde4c3d899566f7e265ab49a992963a6276b29bf52)(expo-asset@58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: expo: 58.0.14(ecd3689b2bda1172f8915f190f5c7383) expo-asset: 58.0.16(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) From bf5485c2e9e69adff803b20725df60a94f5f4aea Mon Sep 17 00:00:00 2001 From: Sebastian Pugliese <73218979+RealDoc06@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:19:15 +0100 Subject: [PATCH 023/200] test(web): linked-environment subagents keep opening in their own thread (#26492) Co-authored-by: Claude Co-authored-by: maria-rcks --- .../chat/useThreadLifecycle.browser.test.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx index 6c02319d25a63..76f9434eedf76 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx @@ -1,10 +1,12 @@ import * as Cause from "effect/Cause"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; const state = vi.hoisted(() => ({ + childThreadInputs: [] as EnvironmentThreadShell[], switchRef: (async () => undefined) as (input: unknown) => Promise, })); @@ -32,7 +34,7 @@ vi.mock(import("../../hooks/useThreadActions"), async (importOriginal) => ({ vi.mock(import("../../state/entities"), async (importOriginal) => ({ ...(await importOriginal()), - useChildThreadInputs: (() => []) as never, + useChildThreadInputs: (() => state.childThreadInputs) as never, })); import { render } from "../../../test/render"; @@ -41,6 +43,7 @@ import { useThreadLifecycle } from "./useThreadLifecycle"; const environmentId = EnvironmentId.make("environment:test"); function setup() { + state.childThreadInputs = []; state.switchRef = async () => undefined; } @@ -60,6 +63,41 @@ function threadInput(threadId: string, branch: string) { type ThreadInput = ReturnType; +it.each([ + { remote: false, approval: false, link: "Open question" }, + { remote: true, approval: false, link: "Open question" }, + { remote: true, approval: false, link: "Child" }, + { remote: true, approval: true, link: "Open approval" }, +])("opens the child in its environment ($remote, $link)", async ({ remote, approval, link }) => { + setup(); + const parent = threadInput("parent", "main"); + const childEnvironmentId = remote ? EnvironmentId.make("remote") : environmentId; + state.childThreadInputs = [ + { + id: ThreadId.make("child"), + environmentId: childEnvironmentId, + title: "Child", + hasPendingApprovals: approval, + lineage: { sideChat: false }, + } as EnvironmentThreadShell, + ]; + await using screen = await render( + undefined} + focusComposer={() => undefined} + />, + { router: "/environment:test/parent" }, + ); + await screen.getByRole("button", { name: link, exact: true }).click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe( + `/${encodeURIComponent(childEnvironmentId)}/child`, + ), + ); +}); + function RestoreBranchHarness(props: { readonly thread: ThreadInput; readonly currentRouteThreadKeyRef: { readonly current: string | null }; @@ -97,6 +135,8 @@ function RestoreBranchHarness(props: { }); return ( <> + {lifecycle.childInputNow?.description} + {lifecycle.childInputNow?.actions} {lifecycle.workNow?.actions} +
+ ); +} + /** Permissions change rarely, so the footer names the mode and nests the choice in a menu. */ function PermissionsMenu(props: { runtimeMode: RuntimeMode; @@ -647,6 +681,7 @@ export function ComposerPickerFooter(props: { return (
{props.traits ? : null} + {props.traits ? : null} ; -// Ultracode changes the workflow, not only intensity, so it keeps its name like Ultrathink. +// Older servers offer Ultracode as a level. It changes the workflow, so it keeps its name. // Max is named too, so the full scale stops short of it. const NAMED_LEVELS = new Set(["ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index a965e89efd728..5b5f8b3477420 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -50,16 +50,16 @@ export function SubagentTooltipContent(props: { result?: string | null | undefined; progress?: string | null | undefined; }) { - const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); - const preview = subagentDetailPreview(props); - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); const childSelection = props.modelSelection ?? (props.origin === "app_owned" ? props.childThread?.modelSelection : undefined); + const { + modelLabel, + modelSlug, + workspace: metadata, + } = resolveSubagentMetadata({ ...props, modelSelection: childSelection }); + const preview = subagentDetailPreview(props); + const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); const childModel = props.provider ? (resolveSelectableModel( props.provider.driver, @@ -69,7 +69,7 @@ export function SubagentTooltipContent(props: { : childSelection?.model.trim(); const provider = props.provider; const matchingSelection = - childModel === (modelSlug ?? model) && childSelection?.instanceId === props.providerInstanceId + childModel === modelSlug && childSelection?.instanceId === props.providerInstanceId ? childSelection : undefined; const effort = REASONING_EFFORT_OPTION_IDS.map((id) => diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 9d31ec35761e9..4cfb23350e912 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -1,6 +1,7 @@ -import type { - SubagentWorkflowGroup, - RuntimeSubagent, +import { + workflowMemberModelSelection, + type SubagentWorkflowGroup, + type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import { ChevronDownIcon, SquareIcon } from "lucide-react"; @@ -101,6 +102,7 @@ function WorkflowMemberRow({ model={member.model} providerInstanceId={providerInstanceId} origin="provider_native" + modelSelection={workflowMemberModelSelection(member, providerInstanceId)} provider={provider} providers={providers} driver={driver} diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx index 3ab51f077e060..c6be9690e9940 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isTerminalSubagentStatus, + workflowMemberModelSelection, workflowOpeningHeadline, type RuntimeSubagent, type WorkflowOpening, @@ -183,6 +184,7 @@ function WorkflowMemberRow(props: { model={member.model} providerInstanceId={props.providerInstanceId} origin="provider_native" + modelSelection={workflowMemberModelSelection(member, props.providerInstanceId)} provider={props.providers.find( (provider) => provider.instanceId === props.providerInstanceId, )} diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index b05f9226defb3..9a3dc06a15173 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -120,10 +120,12 @@ export function buildTraitsTriggerDisplay(input: { label: string; /** "Fast" or "Ultrafast" when a speed option is on. */ speedLabel: string | null; + ultracode: boolean; } { let fastModeFallbackLabel: string | null = null; let speedLabel: string | null = null; let reasoningLabelIndex = -1; + let ultracodeLabelIndex = -1; const labels: Array = []; for (const descriptor of input.descriptors) { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { @@ -131,6 +133,13 @@ export function buildTraitsTriggerDisplay(input: { fastModeFallbackLabel = speedLabel ?? "Normal"; continue; } + if (descriptor.id === "ultracode" && descriptor.type === "boolean") { + if (descriptor.currentValue === true) { + ultracodeLabelIndex = labels.length; + labels.push(descriptor.label); + } + continue; + } if ( input.provider === "codex" && descriptor.id === "serviceTier" && @@ -173,8 +182,9 @@ export function buildTraitsTriggerDisplay(input: { } } + const ultracode = ultracodeLabelIndex >= 0; if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel, speedLabel }; + return { label: fastModeFallbackLabel, speedLabel, ultracode }; } if (speedLabel) { if (reasoningLabelIndex >= 0) { @@ -183,5 +193,5 @@ export function buildTraitsTriggerDisplay(input: { labels.push(speedLabel); } } - return { label: labels.join(" · "), speedLabel }; + return { label: labels.join(" · "), speedLabel, ultracode }; } diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 124c45e264f79..c0763fa4c2f41 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,5 @@ import type { + ModelSelection, OrchestrationV2TurnItemStatus, OrchestrationV2ThreadShell, OrchestrationProjectShell, @@ -50,6 +51,7 @@ export function formatSubagentDisplayTitle(title: string): string { export function resolveSubagentMetadata(input: { readonly model: string | null; + readonly modelSelection?: Pick | undefined; readonly provider?: Pick | null | undefined; readonly parentThread?: | Pick @@ -65,7 +67,7 @@ export function resolveSubagentMetadata(input: { | null | undefined; }) { - const model = input.model?.trim(); + const model = input.model?.trim() || input.modelSelection?.model.trim(); const slug = input.provider ? resolveSelectableModel(input.provider.driver, model, input.provider.models) : model; @@ -108,7 +110,7 @@ export function resolveSubagentMetadata(input: { ] : []), ]; - return { modelLabel, workspace }; + return { modelLabel, modelSlug: slug ?? model ?? null, workspace }; } export function subagentDetailPreview(input: { diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index c40c9fe42d81e..93d15b016d755 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -13,7 +13,10 @@ import { isWorkflowScriptMessage, projectedSubagentsToRuntime, resolveWorkflowOpening, + workflowMemberModelSelection, } from "./subagentRuntime.ts"; +import { resolveSubagentMetadata } from "./subagentDisplay.ts"; +import { formatSubagentEffort } from "./threadExecution.ts"; const at = (iso: string) => DateTime.makeUnsafe(iso); @@ -82,6 +85,42 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(settled?.speed).toBe("standard"); expect(settled?.progress).toBe("Reply with exactly: A1"); expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); + const instanceId = ProviderInstanceId.make("claudeAgent"); + expect(workflowMemberModelSelection(settled!, instanceId)).toEqual({ + instanceId, + model: "claude-opus-5[1m]", + options: [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: false }, + ], + }); + expect(workflowMemberModelSelection({ model: null, effort: "xhigh" }, instanceId)).toBe( + undefined, + ); + const provider = { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { slug: "claude-opus-5", name: "Claude Opus 5", isCustom: false, capabilities: null }, + ], + }; + const modelSelection = workflowMemberModelSelection(settled!, instanceId); + const { modelSlug } = resolveSubagentMetadata({ + model: settled!.model, + modelSelection, + provider, + }); + expect(formatSubagentEffort({ modelSlug, modelSelection, provider })).toBe("xhigh"); + }); + + it("names a workflow by the model it runs on when the provider reports none", () => { + const [workflow] = projectedSubagentsToRuntime([subagent({ model: null })]); + expect( + resolveSubagentMetadata({ + model: workflow!.model, + modelSelection: { model: "claude-opus-5-5" }, + }).modelLabel, + ).toBe("Claude Opus 5.5"); + expect(resolveSubagentMetadata({ model: workflow!.model }).modelLabel).toBe("Not reported"); }); it.each(["completed", "cancelled", "failed"] as const)( diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e3807362fbdf6..fac58a029ce62 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -1,6 +1,7 @@ import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; import type { + ModelSelection, OrchestrationV2AppThread, OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow, @@ -175,6 +176,21 @@ function workflowMembersToRuntime( }); } +export function workflowMemberModelSelection( + member: Pick, + instanceId: ModelSelection["instanceId"], +): ModelSelection | undefined { + if (!member.model) return undefined; + return { + instanceId, + model: member.model, + options: [ + ...(member.effort ? [{ id: "effort", value: member.effort }] : []), + ...(member.speed ? [{ id: "fastMode", value: member.speed === "fast" }] : []), + ], + }; +} + export function projectedSubagentsToRuntime( subagents: ReadonlyArray<{ readonly id: string; diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index ed60963ce7ef2..fd03b06b32017 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -14,6 +14,7 @@ import { type OrchestrationV2RunStatus, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { usageLimitBlockedRun } from "@t3tools/shared/orchestrationV2ThreadError"; import { describe, expect, it } from "vite-plus/test"; @@ -26,6 +27,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + formatModelSelectionEffort, isBackgroundWorkStopPending, presentPendingBackgroundWork, presentProviderGoal, @@ -473,6 +475,39 @@ describe("provider-reported model selection", () => { }); }); +describe("formatModelSelectionEffort", () => { + it("labels an old Ultracode effort by the effort it runs at", () => { + const models = [ + { + slug: "claude-opus-5-5", + name: "Claude Opus 5.5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }, + { id: "ultracode", label: "Ultracode", type: "boolean" }, + ], + }), + }, + ]; + const selection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5-5", + [{ id: "effort", value: "ultracode" }], + ); + + expect(formatModelSelectionEffort(selection, models)).toBe("Extra High"); + }); +}); + describe("isBackgroundWorkStopPending", () => { const tasks = [{ taskId: "dev-server" }]; const sent = { threadKey: "env:thread", taskIds: ["dev-server"], retryAt: null }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index b6151c7c875be..f4335256e98fb 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -13,6 +13,7 @@ import { type OrchestrationV2NotificationSource, type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderGoal, + type ServerProvider, type ServerProviderModel, type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, @@ -24,7 +25,13 @@ import { backgroundWorkHoldsCompletion, derivePendingBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { getProviderOptionCurrentLabel, getProviderOptionStatedValue } from "@t3tools/shared/model"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentLabel, + getProviderOptionStatedValue, + resolveSelectableModel, + withLegacyUltracodeEffort, +} from "@t3tools/shared/model"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; @@ -164,16 +171,18 @@ export function formatModelSelectionEffort( ): string | null { const caps = models.find((model) => model.slug === selection.model)?.capabilities; if (!caps) return null; + const options = withLegacyUltracodeEffort(caps.optionDescriptors, selection.options); + const current = options ? { ...selection, options } : selection; for (const id of REASONING_EFFORT_OPTION_IDS) { const descriptor = caps.optionDescriptors?.find((candidate) => candidate.id === id); if (descriptor?.type !== "select") continue; - const value = getProviderOptionStatedValue(id, selection, reportedSelection); + const value = getProviderOptionStatedValue(id, current, reportedSelection); if (value === undefined && id !== "variant") continue; if (typeof value === "boolean") continue; // Bypass picker normalization so an obsolete choice cannot become a catalog default. const label = getProviderOptionCurrentLabel( { ...descriptor, currentValue: value }, - selection, + current, reportedSelection, ); if (label) return label; @@ -181,6 +190,28 @@ export function formatModelSelectionEffort( return null; } +/** A reported model the picker catalog does not know, such as `claude-opus-5[1m]`, keeps its raw effort. */ +export function formatSubagentEffort(input: { + readonly modelSlug: string | null; + readonly modelSelection: ModelSelection | undefined; + readonly provider: Pick | undefined; +}): string | null { + const { modelSlug, modelSelection: selection, provider } = input; + if (!provider || !selection || !modelSlug) return null; + const selectionSlug = + resolveSelectableModel(provider.driver, selection.model, provider.models) ?? + selection.model.trim(); + if (selectionSlug !== modelSlug) return null; + if (provider.models.some((model) => model.slug === modelSlug)) { + return formatModelSelectionEffort({ ...selection, model: modelSlug }, provider.models); + } + return ( + REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(selection, id)).find( + Boolean, + ) ?? null + ); +} + const SUBAGENT_STATUS_LABELS: Record = { idle: "Idle", pending: "Working", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 396d4d9c86c26..af6af0254a94d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -50,6 +50,32 @@ describe("descriptor helpers", () => { ).toEqual([{ id: "fastMode", value: true }]); }); + it("dispatches an old Ultracode effort as Extra High with the Ultracode toggle", () => { + const legacy = [{ id: "effort", value: "ultracode" }]; + const descriptors = getProviderOptionDescriptors({ + caps: createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }, + { id: "ultracode", label: "Ultracode", type: "boolean" }, + ], + }), + selections: legacy, + }); + + expect(buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, legacy)).toEqual([ + { id: "effort", value: "xhigh" }, + { id: "ultracode", value: true }, + ]); + }); + it("compares complete model selections independent of option ordering", () => { const left = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "high" }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 51cc1430321d8..0fdb2bc71ec4f 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -196,12 +196,40 @@ function withDescriptorCurrentValue( }; } +// Ultracode was a Claude effort level before it became its own toggle; that level ran at xhigh. +export function withLegacyUltracodeEffort< + Selections extends ReadonlyArray | null | undefined, +>( + descriptors: ReadonlyArray | null | undefined, + selections: Selections, +): Selections | Array { + if ( + getRawSelectionValueById(selections, "effort") !== "ultracode" || + !descriptors?.some( + (descriptor) => descriptor.id === "ultracode" && descriptor.type === "boolean", + ) + ) { + return selections; + } + return [ + ...(selections ?? []).map((selection) => + selection.id === "effort" && selection.value === "ultracode" + ? { id: "effort", value: "xhigh" } + : selection, + ), + ...(getRawSelectionValueById(selections, "ultracode") === undefined + ? [{ id: "ultracode", value: true }] + : []), + ]; +} + export function getProviderOptionDescriptors(input: { caps: ModelCapabilities; selections?: ReadonlyArray | null | undefined; }): ReadonlyArray { - const { caps, selections } = input; + const { caps } = input; const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); + const selections = withLegacyUltracodeEffort(baseDescriptors, input.selections); return baseDescriptors.map((descriptor) => withDescriptorCurrentValue( @@ -312,7 +340,9 @@ export function buildExplicitProviderOptionSelectionsFromDescriptors( if (!selections || selections.length === 0) { return undefined; } - const explicitIds = new Set(selections.map((selection) => selection.id)); + const explicitIds = new Set( + withLegacyUltracodeEffort(descriptors, selections).map(({ id }) => id), + ); const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter( (selection) => explicitIds.has(selection.id), ); From 0ea3c0243fb5b3c07bb8c2bad01c1d3d0f5fde35 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:53:37 +0300 Subject: [PATCH 037/200] perf(pr): pull requests paint from data already held, on web, desktop and mobile (#27798) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../layout/AdaptiveWorkspaceLayout.tsx | 2 + .../PullRequestOverviewLoading.tsx | 11 +- .../pull-requests/PullRequestRouteScreen.tsx | 16 ++- .../pull-requests/PullRequestStackCard.tsx | 5 +- .../pull-requests/usePullRequestDiff.ts | 9 +- .../workspace/workspace-tab-navigation.ts | 10 +- apps/mobile/src/native/useOpenPullRequest.ts | 6 +- .../src/state/thread-pr-presentation.ts | 59 +++++++- apps/mobile/src/state/use-thread-pr.test.ts | 51 ++++++- apps/mobile/src/state/use-thread-pr.ts | 18 +++ .../pullRequest/PullRequestService.test.ts | 67 +++++++++ .../src/pullRequest/PullRequestService.ts | 19 ++- .../PullRequestLinkPreview.browser.test.tsx | 92 ++++++++++++ .../pullRequest/PullRequestLinkPreview.tsx | 51 ++++--- .../pullRequest/PullRequestListGroups.tsx | 36 ++++- .../components/pullRequest/PullRequestRow.tsx | 11 ++ .../pullRequest/PullRequestsPage.tsx | 16 ++- .../pullRequest/usePullRequestDetail.ts | 16 +++ ...PullRequestIntentPrefetch.browser.test.tsx | 86 +++++++++++ .../usePullRequestIntentPrefetch.ts | 51 +++++++ .../src/connection/storage.browser.test.ts | 72 ++++++++++ apps/web/src/connection/storage.ts | 135 +++++++++++++++++- .../src/state/pullRequestStatsPersistence.ts | 6 +- .../client-runtime/src/state/pullRequests.ts | 3 +- .../client-runtime/src/state/runtime.test.ts | 32 ++++- packages/client-runtime/src/state/runtime.ts | 6 +- .../src/server/GitHubPullRequestApi.test.ts | 85 ++++++++++- .../src/server/GitHubPullRequestApi.ts | 83 +++++++---- 28 files changed, 958 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts create mode 100644 apps/web/src/connection/storage.browser.test.ts diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 354d6413549fe..02c18801445a1 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -58,6 +58,7 @@ import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, @@ -589,6 +590,7 @@ function AdaptiveWorkspaceLayoutContent( columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, + seedPullRequest: withKnownPullRequestSummary, }, ); if (layout.variant === "medium") { diff --git a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx index 647aa26e46373..2494dd93ea0fb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx @@ -1,6 +1,6 @@ import { Chip } from "../../components/ui/Chip"; import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; -import type { PullRequestListEntry } from "@t3tools/contracts"; +import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; import { View } from "react-native"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { PullRequestStackCard } from "./PullRequestStackCard"; @@ -14,11 +14,14 @@ export function PullRequestOverviewLoading({ params, stack, }: { - readonly summary: PullRequestListEntry; + readonly summary: PullRequestListEntry | PullRequestSummary; readonly params: PullRequestParams; readonly stack: PullRequestStackState; }) { - const review = pullRequestListReviewStatus(summary); + const review = pullRequestListReviewStatus({ + reviewDecision: summary.reviewDecision ?? undefined, + viewerReviewRequested: "viewerReviewRequested" in summary && summary.viewerReviewRequested, + }); const conflicting = summary.mergeability === "conflicting"; const clean = summary.mergeability === "mergeable"; return ( @@ -89,7 +92,7 @@ export function PullRequestOverviewLoading({ - {summary.labels.length ? ( + {"labels" in summary && summary.labels.length ? ( {summary.labels.map((label) => ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 522a5eaf9a745..306864ecd237e 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -47,7 +47,11 @@ import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-na import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; -import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; +import { + type PullRequestDetail, + type PullRequestListEntry, + type PullRequestSummary, +} from "@t3tools/contracts"; import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; @@ -180,11 +184,11 @@ function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityF } function PullRequestIdentity(props: { - readonly pr: PullRequestDetail | PullRequestListEntry; + readonly pr: PullRequestDetail | PullRequestListEntry | PullRequestSummary; readonly params: PullRequestParams; }) { const { pr } = props; - const status = pullRequestStatus(pr); + const status = pullRequestStatus({ state: pr.state, isDraft: pr.isDraft === true }); return ( @@ -196,8 +200,8 @@ function PullRequestIdentity(props: { {status.label} )} - - {"changedFiles" in pr ? ( + + {"changedFiles" in pr && pr.changedFiles !== undefined ? ( {pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"} @@ -209,7 +213,7 @@ function PullRequestIdentity(props: { {pr.title} - + {pr.author?.login ?? "Unknown author"} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index cbb751c74c4c7..bdf4399f1e6cb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -9,6 +9,7 @@ import { pullRequestStatus } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; export function PullRequestStackCard(props: { readonly params: PullRequestParams; @@ -67,7 +68,9 @@ export function PullRequestStackCard(props: { selected ? undefined : () => { - const params = pullRequestLayerParams(props.params, layer.number); + const params = withKnownPullRequestSummary( + pullRequestLayerParams(props.params, layer.number), + ); if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 92e36f4486944..6f5180d8ab6bc 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,5 +1,10 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; -import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + type PullRequestListEntry, + type PullRequestSummary, +} from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; import { useLayoutEffect, useMemo, useState } from "react"; @@ -18,7 +23,7 @@ export type PullRequestParams = { readonly repository: string; readonly number: number; readonly host?: string; - readonly initialSummary?: PullRequestListEntry; + readonly initialSummary?: PullRequestListEntry | PullRequestSummary; }; export type PullRequestDiffParams = PullRequestParams & { readonly commit?: string }; diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts index 3674c52972e03..bee1efc005fdc 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts @@ -150,6 +150,7 @@ function detailRoute( tab: "threads" | "pullRequests", selections: WorkspaceSelections, check: WorkspaceSelectionCheck, + seedPullRequest?: (identity: Omit) => object, ): RouteLike | undefined { if (tab === "threads") { const draft = selections.threadsDraft; @@ -184,7 +185,11 @@ function detailRoute( (route) => route.name === "PullRequest" && samePullRequest(pullRequestSelection(route.params), pullRequest), - ) ?? { key: pullRequest.key, name: "PullRequest", params: identityOf(pullRequest) } + ) ?? { + key: pullRequest.key, + name: "PullRequest", + params: seedPullRequest?.(identityOf(pullRequest)) ?? identityOf(pullRequest), + } ); } @@ -204,6 +209,7 @@ export function workspaceTabNavigationState< readonly columns: boolean; readonly check: WorkspaceSelectionCheck; readonly overlays?: readonly RouteLike[]; + readonly seedPullRequest?: (identity: Omit) => object; }, ) { const overlays = options.overlays ?? []; @@ -221,7 +227,7 @@ export function workspaceTabNavigationState< const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; const detail = options.columns && tab !== "usage" - ? detailRoute(state, tab, selections, options.check) + ? detailRoute(state, tab, selections, options.check, options.seedPullRequest) : undefined; const routes = [...[home, ...(detail ? [detail] : [])].map(keyed), ...overlays]; return { ...state, index: routes.length - 1, routes }; diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 755f0b965ee09..fa7d437155b5e 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -7,6 +7,7 @@ import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; import { appAtomRegistry } from "../state/atom-registry"; import { environmentServerConfigsAtom } from "../state/server"; +import { withKnownPullRequestSummary } from "../state/use-thread-pr"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -19,10 +20,11 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean return async (url: string, context: PullRequestContext | null) => { // Read at press time so a config push does not hand every link a new handler. const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); - const params = nativePullRequestDestination(url, context, projects, (environmentId) => + const destination = nativePullRequestDestination(url, context, projects, (environmentId) => serverConfigs.has(environmentId), ); - if (!params) return tryOpenExternalUrl(url, "pull-request"); + if (!destination) return tryOpenExternalUrl(url, "pull-request"); + const params = withKnownPullRequestSummary(destination); // Android shows one page at a time; iOS opens it over the current page, so Back returns. if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 2c151676f5c6c..a44f410b54ca3 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,13 +1,20 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import type { ThreadPullRequestLink, VcsStatusResult } from "@t3tools/contracts"; +import type { + PullRequestSummary, + ThreadPullRequestKey, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; import { sourceControlClients, type ChangeRequestTerminology, } from "@t3tools/client-runtime/source-control-clients"; import { + legacyThreadPullRequestKey, resolveThreadCurrentPullRequestLink, resolveThreadPullRequestBadge, + threadPullRequestKeyOf, } from "@t3tools/client-runtime/thread-pull-requests"; export type ThreadPr = NonNullable; @@ -142,3 +149,53 @@ export function rememberBounded( } return next; } + +/** The newest summary held for `pullRequest`, matched strictly by host, repository and number. */ +export function knownPullRequestSummary( + pullRequest: ThreadPullRequestKey, + threads: Iterable>, + summaries: Iterable, +): PullRequestSummary | undefined { + const key = threadPullRequestKeyOf(pullRequest); + let newest: + | { readonly summary: PullRequestSummary; readonly updatedAt: number; readonly readAt: number } + | undefined; + // Checks and reviews change without moving `updatedAt`, so equal versions prefer the later read. + const consider = (summary: PullRequestSummary, readAt = Number.NEGATIVE_INFINITY) => { + const updatedAt = Date.parse(summary.updatedAt); + if ( + newest === undefined || + updatedAt > newest.updatedAt || + (updatedAt === newest.updatedAt && readAt > newest.readAt) + ) { + newest = { summary, updatedAt, readAt }; + } + }; + for (const summary of summaries) { + if (threadPullRequestKeyOf(legacyThreadPullRequestKey(summary)) === key) { + consider(summary, summary.observedAt); + } + } + for (const thread of threads) { + for (const link of thread.pullRequests) { + const snapshot = link.snapshot; + if (snapshot?.updatedAt == null || threadPullRequestKeyOf(link) !== key) continue; + consider( + { + ...snapshot, + updatedAt: snapshot.updatedAt, + provider: ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? + sourceControlClients.get(undefined) + ).kind, + projectId: thread.projectId, + repository: link.repository, + number: link.number, + url: link.url, + }, + Date.parse(snapshot.syncedAt), + ); + } + } + return newest?.summary; +} diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 309953ddbdc3b..4edbb903dbebf 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,13 @@ -import { ProjectId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { + ProjectId, + type PullRequestSummary, + SourceControlProviderKind, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + knownPullRequestSummary, presentThreadLinkedPullRequests, rememberBounded, resolveThreadPrSource, @@ -113,6 +119,49 @@ describe("resolveThreadPrSource compatibility", () => { }); }); +describe("knownPullRequestSummary", () => { + it("seeds from the newest summary of the same pull request, never another's", () => { + const projectId = ProjectId.make("project"); + const link = linkedPr(7); + const threads = [ + { + projectId, + pullRequests: [ + { ...link, snapshot: { ...link.snapshot!, updatedAt: "2026-09-08T00:00:00.000Z" } }, + ], + }, + ]; + const listed: PullRequestSummary = { + provider: SourceControlProviderKind.make("github"), + projectId, + repository: "t3tools/t3code", + number: 7, + title: "Listed title", + url: "https://github.com/t3tools/t3code/pull/7", + state: "open", + headBranch: "change-7", + baseBranch: "main", + updatedAt: "2026-09-09T00:00:00.000Z", + }; + const lookup = (host: string, number: number, summaries = [listed]) => + knownPullRequestSummary({ host, repository: "t3tools/t3code", number }, threads, summaries); + + expect(lookup("github.com", 7, [])?.title).toBe("Change 7"); + expect(lookup("github.com", 7)?.title).toBe("Listed title"); + expect(lookup("github.com", 8)).toBeUndefined(); + expect(lookup("gitlab.com", 7)).toBeUndefined(); + + const sameVersion = { ...listed, updatedAt: "2026-09-08T00:00:00.000Z" }; + const syncedAt = Date.parse(link.snapshot!.syncedAt); + expect(lookup("github.com", 7, [{ ...sameVersion, observedAt: syncedAt - 1 }])?.title).toBe( + "Change 7", + ); + expect(lookup("github.com", 7, [{ ...sameVersion, observedAt: syncedAt + 1 }])?.title).toBe( + "Listed title", + ); + }); +}); + describe("rememberBounded", () => { it("moves a re-answered entry to the newest slot and evicts the oldest past the limit", () => { let snapshots: ReadonlyMap = new Map(); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index af14ce3827b4e..6c67e9f18f6b5 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -10,10 +10,13 @@ import { Atom } from "effect/reactivity"; import { use, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { serverEnvironment } from "./server"; +import { environmentThreadShells } from "./threads"; import { useEnvironmentQuery } from "./query"; import { ThreadPrDemandContext } from "./thread-pr-demand"; import { + knownPullRequestSummary, rememberBounded, resolveThreadPrSource, presentThreadPr, @@ -59,6 +62,21 @@ const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily( export { type ThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; +/** Seeds a pull request page with a summary already in memory, never another pull request's. */ +export function withKnownPullRequestSummary< + Params extends { readonly host?: string; readonly repository: string; readonly number: number }, +>(params: Params) { + const initialSummary = + params.host === undefined + ? undefined + : knownPullRequestSummary( + { host: params.host, repository: params.repository, number: params.number }, + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + appAtomRegistry.get(pullRequestSnapshotsAtom).values(), + ); + return initialSummary === undefined ? params : { ...params, initialSummary }; +} + export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { const demand = use(ThreadPrDemandContext); const supportsLinks = useAtomValue( diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 93aa60cd2c97c..50581506ea1e3 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -202,6 +202,73 @@ it.effect("does not wait for an in-flight detail read to display a preview", () }), ); +it.effect("shows a listed pull request's preview without asking the host again", () => + Effect.gen(function* () { + let previewReads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }), + getChangeRequestPreview: () => + Effect.sync(() => { + previewReads++; + return { ...changeRequest(1, "2026-07-02T00:00:00Z"), title: "Renamed" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.list({ state: "open" }); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + assert.strictEqual(previewReads, 0); + yield* service.refreshAfterTurn(ref.projectId); + assert.strictEqual((yield* service.preview(ref)).title, "Renamed"); + assert.strictEqual(previewReads, 1); + yield* service.list({ state: "open", limit: 1 }); + assert.strictEqual((yield* service.preview(ref)).title, "Renamed"); + }), +); + +it.effect("a list read under a pinned account leaves the shared preview to the host", () => + Effect.gen(function* () { + let previewReads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), + withVerifiedCredential: (_, use) => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: "pinned" }), + listChangeRequests: () => + Effect.succeed({ + items: [{ ...changeRequest(1, "2026-07-02T00:00:00Z"), title: "Pinned view" }], + truncated: false, + continues: false, + }), + getChangeRequestPreview: () => + Effect.sync(() => { + previewReads++; + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.withRoutingCredential( + { ...ref, host: "github.com", expectedAccountId: "101" }, + service.list({ state: "open" }), + ); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + assert.strictEqual(previewReads, 1); + }), +); + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e77434c1697b3..304ce2d3fa52b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1262,6 +1262,7 @@ export const make = Effect.gen(function* () { "PullRequestService.listUncached", )(function* (input) { const involvement = input.involvement ?? "all"; + const epochAtStart = epochCounter; const continuation = yield* decodeCursors(input.cursors); const { supported: projects, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); const projectCounts = new Map(); @@ -1549,13 +1550,25 @@ export const make = Effect.gen(function* () { for (const batch of batches) { if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; } + const entries = batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + // Hover cards on listed rows read from here. A pinned account or a mid-read refresh is another + // scope; half the cache stays free for previews read directly. + if ((yield* routingCredential) === null && epochCounter === epochAtStart) { + const seeds = entries + .slice(0, DETAIL_CACHE_CAPACITY / 2) + .map((entry) => [refCacheKey(entry), previewFields(entry)] as const) + .toReversed(); + for (const [key, value] of seeds) { + if (!(yield* Cache.has(previewCache, key))) yield* Cache.set(previewCache, key, value); + } + } return { viewers: viewers as PullRequestListResult["viewers"], providers, - entries: batches - .flatMap((batch) => batch.entries) - .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + entries, errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], truncated: batches.some((batch) => batch.truncated), nextCursors, diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx new file mode 100644 index 0000000000000..29f0211eaef83 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx @@ -0,0 +1,92 @@ +import { EnvironmentId, ProjectId, type PullRequestSummary } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +const known = vi.hoisted( + () => + ({ + provider: "github", + projectId: "project", + repository: "owner/repo", + number: 1, + title: "Known title", + url: "https://github.com/owner/repo/pull/1", + state: "open", + headBranch: "feature", + baseBranch: "main", + updatedAt: "2026-09-01T00:00:00Z", + }) as PullRequestSummary, +); +const state = vi.hoisted(() => ({ restoredPreview: null as unknown })); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + // The host has not answered the preview read yet. + useEnvironmentQuery: ((atom: unknown) => ({ + data: state.restoredPreview, + dataUpdatedAt: 0, + error: null, + failure: null, + isPending: atom !== null, + isSuccess: false, + refresh: () => undefined, + })) as never, +})); +vi.mock(import("~/state/pullRequests"), async (original) => ({ + ...(await original()), + useSharedPullRequestSummary: (() => known) as never, +})); + +import { render } from "../../../test/render"; +import { PullRequestLinkPreview } from "./PullRequestLinkPreview"; + +function renderPreview() { + return render( + #1} + originalUrl={known.url} + target={{ + environmentId: EnvironmentId.make("environment"), + input: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + }} + />, + ); +} + +it("keeps a refreshing preview's state and title over an older known summary", async () => { + state.restoredPreview = { + projectId: "project", + repository: "owner/repo", + number: 1, + title: "Renamed title", + url: known.url, + author: { login: "octocat" }, + state: "closed", + isDraft: false, + createdAt: "2026-08-01T00:00:00Z", + }; + await using screen = await renderPreview(); + + await userEvent.hover(screen.getByRole("link")); + + await expect.element(page.getByText("Renamed title")).toBeVisible(); + await expect.element(page.getByText("Closed", { exact: true })).toBeVisible(); + expect(page.getByText("Known title").query()).toBeNull(); +}); + +it("shows the known summary while the preview loads", async () => { + state.restoredPreview = null; + await using screen = await render( + #1} + originalUrl={known.url} + target={{ + environmentId: EnvironmentId.make("environment"), + input: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + }} + />, + ); + + await userEvent.hover(screen.getByRole("link")); + + await expect.element(page.getByText("Known title")).toBeVisible(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index f5ed4ea6553bf..8e3ee75602342 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -12,7 +12,7 @@ import { } from "react"; import { RelativeTime } from "~/components/RelativeTime"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useEnvironmentQuery } from "~/state/query"; @@ -57,6 +57,10 @@ export function PullRequestLinkPreview({ }) : null, ); + const summary = useSharedPullRequestSummary( + open ? target.environmentId : null, + open ? target.input : null, + ); const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { reportFailure: false, reportDefect: false, @@ -85,18 +89,19 @@ export function PullRequestLinkPreview({ }) : link; const preview = previewQuery.data; - const showCard = preview !== null || (previewQuery.error !== null && fallback !== undefined); + const card = preview ?? summary; + const showCard = card !== null || (previewQuery.error !== null && fallback !== undefined); const showUrlTooltip = open && previewQuery.error !== null && !showCard; const state = - preview === null + card === null ? null - : resolvePullRequestState({ state: preview.state, isDraft: preview.isDraft }); + : resolvePullRequestState({ state: card.state, isDraft: card.isDraft ?? false }); const authorLabel = - preview?.author === null + card?.author === null ? "ghost" - : preview?.author.name && preview.author.name !== preview.author.login - ? `${preview.author.name} (@${preview.author.login})` - : (preview?.author.login ?? null); + : card?.author?.name && card.author.name !== card.author.login + ? `${card.author.name} (@${card.author.login})` + : (card?.author?.login ?? null); return ( @@ -116,13 +121,13 @@ export function PullRequestLinkPreview({ {showCard ? (
- {preview === null ? ( + {card === null ? ( fallback ) : (
- {preview.repository} - #{preview.number} + {card.repository} + #{card.number} · {state === null ? null : ( @@ -132,16 +137,22 @@ export function PullRequestLinkPreview({ )}

- {preview.title} + {card.title}

-
- - {authorLabel} - · - - opened - -
+ {card.author === undefined ? null : ( +
+ + {authorLabel} + {preview === null ? null : ( + <> + · + + opened + + + )} +
+ )}
)}
diff --git a/apps/web/src/components/pullRequest/PullRequestListGroups.tsx b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx index 7e8c4e3a26f10..4eb994f7c6627 100644 --- a/apps/web/src/components/pullRequest/PullRequestListGroups.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, PullRequestAction } from "@t3tools/contracts"; import { EyeIcon, LayersIcon, PenLineIcon, UsersIcon, type LucideIcon } from "lucide-react"; -import type { RefCallback } from "react"; +import { useDeferredValue, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { Separator } from "../ui/separator"; @@ -23,18 +23,32 @@ const GROUP_ICONS: Record = { }; const MATCHED_ELSEWHERE_SCORE = 10; +const FIRST_PAINT_ROWS = 24; + +function firstPaintGroups }>( + groups: ReadonlyArray, +): ReadonlyArray { + let left = FIRST_PAINT_ROWS; + return groups.flatMap((group) => { + const entries = group.entries.slice(0, left); + left -= entries.length; + return entries.length === 0 ? [] : [{ ...group, entries }]; + }); +} function PullRequestGroupHeader({ group, + count, }: { - group: { key: string; label: string; entries: ReadonlyArray }; + group: { key: string; label: string }; + count: number; }) { const Icon = GROUP_ICONS[group.key] ?? LayersIcon; return (

{group.label}

- {group.entries.length} + {count}
); @@ -48,6 +62,7 @@ export function PullRequestListGroups({ showProvider, statsRef, onSelect, + onIntent, hostActionsOf, onActed, closingKeys, @@ -67,18 +82,28 @@ export function PullRequestListGroups({ showProvider: boolean; statsRef: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + onIntent: (entry: PullRequestRowTarget | null) => void; hostActionsOf: (entry: EnvironmentPullRequestEntry) => ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closingKeys: ReadonlySet; sweepingKeys: ReadonlySet; onCloseSweepStart: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { + // Every row mounts tooltips and actions; painting a first screenful before the rest keeps + // opening the page from blocking on the whole list. + const shownGroups = useDeferredValue(groups, firstPaintGroups(groups)); + const counts = new Map(groups.map((group) => [group.key, group.entries.length])); return (
0 && "**:pointer-events-none")}> - {groups.map((group) => ( + {shownGroups.map((group) => (
- {group.label ? : null} + {group.label ? ( + + ) : null} {group.entries.map((entry) => { const entryKey = pullRequestEntryKey(entry); const environmentLabel = environmentLabels?.get(entry.environmentId); @@ -102,6 +127,7 @@ export function PullRequestListGroups({ selected.number === entry.number } onSelect={onSelect} + onIntent={onIntent} hostActions={hostActionsOf(entry)} onActed={onActed} closing={closingKeys.has(entryKey)} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 3b4d1f84c01d9..71fbf1100d554 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -78,6 +78,7 @@ function PullRequestRowImpl({ statsKey, statsRef, onSelect, + onIntent, hostActions, onActed, closing = false, @@ -94,6 +95,8 @@ function PullRequestRowImpl({ statsKey?: string; statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + /** The row the pointer or focus rests on, or null when it leaves. */ + onIntent?: (entry: PullRequestRowTarget | null) => void; hostActions: ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closing?: boolean; @@ -115,6 +118,14 @@ function PullRequestRowImpl({ type="button" aria-current={selected ? "true" : undefined} onClick={() => onSelect(entry)} + onPointerEnter={() => onIntent?.(entry)} + onPointerLeave={(event) => { + if (event.currentTarget !== document.activeElement) onIntent?.(null); + }} + onFocus={() => onIntent?.(entry)} + onBlur={(event) => { + if (!event.currentTarget.matches(":hover")) onIntent?.(null); + }} className={cn( PULL_REQUEST_ROW_CLASS, PAGE_ROW_CLASS, diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 1b3d1bc74c186..0fc32bce3257a 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -64,6 +64,8 @@ import { PullRequestCompactFilterMenu } from "./PullRequestCompactFilterMenu"; import { INVOLVEMENT_TABS, SORT_OPTIONS, STATE_TABS } from "./pullRequestListOptions"; import { PullRequestsColumn } from "./PullRequestsColumn"; import { PullRequestListGroups } from "./PullRequestListGroups"; +import { pullRequestPanelReference } from "./usePullRequestDetail"; +import { usePullRequestIntentPrefetch } from "./usePullRequestIntentPrefetch"; import { usePullRequestDisplayGroups, usePullRequestQueryFilters, @@ -1080,6 +1082,9 @@ export function PullRequestsPage() { ), ); + const prefetchEntry = usePullRequestIntentPrefetch((entry) => + listedPullRequestsBySurface.has(pullRequestListEntryId(entry)), + ); const selectEntry = (entry: PullRequestRowTarget) => { if (rightPanelRef === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); @@ -1184,6 +1189,7 @@ export function PullRequestsPage() { showProvider={showProvider} statsRef={registerStatsRow} onSelect={selectEntry} + onIntent={prefetchEntry} hostActionsOf={hostActionsOf} onActed={onSpeedAction} closingKeys={closingKeys} @@ -1528,14 +1534,10 @@ export function PullRequestsPage() { selectedEnvironmentId: panelEnvironmentId, }); }} - reference={{ + reference={pullRequestPanelReference({ + ...renderedPullRequestSurface, projectId: renderedPullRequestSurface.projectId as ProjectId, - repository: renderedPullRequestSurface.repository, - number: renderedPullRequestSurface.number, - ...(renderedPullRequestSurface.host - ? { host: renderedPullRequestSurface.host } - : {}), - }} + })} listEntry={ listedPullRequestsBySurface.get( pullRequestListEntryId(renderedPullRequestSurface), diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7451ee38c5fee..7d6ab0c7a6154 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestListEntry, type PullRequestRef, + type ProjectId, } from "@t3tools/contracts"; import { useState } from "react"; @@ -22,6 +23,21 @@ import { resolveDisplayedPullRequestDetail, } from "./pullRequestDetail.logic"; +// Query atoms key on JSON order, so the panel and the row prefetch must build the ref here. +export function pullRequestPanelReference(target: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly host?: string | undefined; +}): PullRequestRef { + return { + projectId: target.projectId, + repository: target.repository, + number: target.number, + ...(target.host ? { host: target.host } : {}), + }; +} + export function pullRequestRefKey(reference: PullRequestRef) { return `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; } diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx new file mode 100644 index 0000000000000..366fb4cfe76e4 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx @@ -0,0 +1,86 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ reads: [] as Array })); +vi.mock(import("~/state/pullRequests"), async (original) => { + const actual = await original(); + return { + ...actual, + pullRequestEnvironment: { + ...actual.pullRequestEnvironment, + detail: ((target: unknown) => target) as never, + }, + }; +}); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: ((target: unknown) => { + if (target !== null || state.reads.length > 0) state.reads.push(target); + return {}; + }) as never, +})); + +import { act, fakeTimers, render } from "../../../test/render"; +import { usePullRequestIntentPrefetch } from "./usePullRequestIntentPrefetch"; + +const environmentId = EnvironmentId.make("environment"); +const row = { + environmentId, + projectId: ProjectId.make("project"), + host: "GitHub.com", + repository: "owner/repo", + number: 7, +}; + +function Rows() { + const prefetch = usePullRequestIntentPrefetch(() => true); + return ( + <> + + + + ); +} + +it("reads the panel's detail once intent rests on a row for 150 ms", async () => { + state.reads = []; + await using screen = await render(, { + // A bare hook probe; nothing here navigates. + router: false, + }); + using _timers = fakeTimers({ now: 0 }); + const rest = screen.getByRole("button", { name: "rest" }).element() as HTMLElement; + const leave = screen.getByRole("button", { name: "leave" }).element() as HTMLElement; + + await act(() => { + rest.click(); + leave.click(); + }); + await act(() => vi.advanceTimersByTime(1_000)); + expect(state.reads).toEqual([]); + + await act(() => rest.click()); + await act(() => vi.advanceTimersByTime(149)); + expect(state.reads).toEqual([]); + await act(() => vi.advanceTimersByTime(1)); + // Query atoms key on JSON order: this is the order the PRs page panel reads. + expect(JSON.stringify(state.reads.at(-1))).toBe( + JSON.stringify({ + environmentId, + input: { + projectId: row.projectId, + repository: "owner/repo", + number: 7, + host: "github.com", + allowStale: false, + }, + }), + ); + + await act(() => leave.click()); + expect(state.reads.at(-1)).toBeNull(); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts new file mode 100644 index 0000000000000..a078ce2c94ff7 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useRef, useState } from "react"; + +import { useStableCallback } from "~/hooks/useStableCallback"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { pullRequestPanelReference } from "./usePullRequestDetail"; + +const INTENT_DELAY_MS = 150; + +interface PullRequestIntentTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly host: string; + readonly repository: string; + readonly number: number; +} + +/** + * Reads the detail a row's panel will ask for once the pointer or focus rests on it, so the + * click paints from it. Passing over rows asks the host nothing, and leaving a row stops reading it. + */ +export function usePullRequestIntentPrefetch( + isListed: (target: PullRequestIntentTarget) => boolean, +) { + const [target, setTarget] = useState(null); + const timer = useRef | undefined>(undefined); + // A removed row fires no leave event. + const listed = target !== null && isListed(target); + if (target !== null && !listed) setTarget(null); + useEnvironmentQuery( + target === null || !listed + ? null + : pullRequestEnvironment.detail({ + environmentId: target.environmentId, + input: { + ...pullRequestPanelReference({ ...target, host: target.host.toLowerCase() }), + allowStale: false, + }, + }), + ); + return useStableCallback((next: PullRequestIntentTarget | null) => { + clearTimeout(timer.current); + if (next === null) { + setTarget(null); + return; + } + timer.current = setTimeout(() => setTarget(next), INTENT_DELAY_MS); + }); +} diff --git a/apps/web/src/connection/storage.browser.test.ts b/apps/web/src/connection/storage.browser.test.ts new file mode 100644 index 0000000000000..2b36f9827c84f --- /dev/null +++ b/apps/web/src/connection/storage.browser.test.ts @@ -0,0 +1,72 @@ +import { Persistence } from "@t3tools/client-runtime/platform"; +import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; + +import * as ConnectionStorage from "./storage"; + +describe("IndexedDB query cache", () => { + it.effect("round trips per environment and clears with the environment", () => + Effect.gen(function* () { + const store = (yield* EnvironmentQueryStore)!; + const first = EnvironmentId.make("query-first"); + yield* store.save(first, "pr", "cached"); + expect(yield* store.load(first, "pr")).toEqual(Option.some("cached")); + expect(yield* store.load(EnvironmentId.make("query-second"), "pr")).toEqual(Option.none()); + yield* (yield* Persistence.EnvironmentCacheStore).clear(first); + expect(yield* store.load(first, "pr")).toEqual(Option.none()); + }).pipe(Effect.provide(ConnectionStorage.layer)), + ); + + it.effect("two tabs' bursts of saves end within 256 rows and 32 MiB", () => + Effect.gen(function* () { + const openTab = Layer.build(Layer.fresh(ConnectionStorage.layer)).pipe( + Effect.map((context) => Context.get(context, EnvironmentQueryStore)!), + ); + const tabs = [yield* openTab, yield* openTab]; + const environmentId = EnvironmentId.make("query-bounded"); + const saveAll = (keys: ReadonlyArray, payload: (key: string) => string) => + Effect.forEach(keys, (key, index) => + tabs[index % 2]!.save(environmentId, key, payload(key)).pipe( + Effect.andThen(TestClock.adjust("1 millis")), + ), + ); + const loadAll = (keys: ReadonlyArray) => + Effect.forEach(keys, (key) => tabs[0]!.load(environmentId, key)).pipe( + Effect.map((loaded) => loaded.filter(Option.isSome).map((stored) => stored.value)), + ); + + const rows = Array.from({ length: 300 }, (_, index) => `row-${index}`); + yield* saveAll(rows, (key) => key); + const keptRows = yield* loadAll(rows); + expect(keptRows.length).toBeLessThanOrEqual(256); + expect(keptRows).not.toContain("row-0"); + expect(keptRows).toContain("row-299"); + + const answer = "x".repeat(4 * 1024 * 1024); + const large = Array.from({ length: 9 }, (_, index) => `large-${index}`); + yield* saveAll(large, () => answer); + const keptBytes = (yield* loadAll([...rows, ...large])).reduce( + (total, payload) => total + payload.length, + 0, + ); + expect(keptBytes).toBeLessThanOrEqual(32 * 1024 * 1024); + expect(yield* tabs[1]!.load(environmentId, "large-8")).toEqual(Option.some(answer)); + }).pipe(Effect.scoped), + ); + + it.effect("drops the older copy when an answer grows too large to keep", () => + Effect.gen(function* () { + const store = (yield* EnvironmentQueryStore)!; + const environmentId = EnvironmentId.make("query-oversize"); + yield* store.save(environmentId, "diff", "small"); + yield* store.save(environmentId, "diff", "x".repeat(4 * 1024 * 1024 + 1)); + expect(yield* store.load(environmentId, "diff")).toEqual(Option.none()); + }).pipe(Effect.provide(ConnectionStorage.layer)), + ); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 7d9db4afdc494..78b01fc6d4695 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -28,7 +28,9 @@ import { pullRequestRoutingConnectionKey, pullRequestRoutingPermissionFor, } from "@t3tools/client-runtime/connection"; +import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -41,12 +43,23 @@ import * as Stream from "effect/Stream"; import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; -const DATABASE_VERSION = 4; +const DATABASE_VERSION = 5; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; +const QUERY_STORE_NAME = "query"; +const QUERY_UPDATED_INDEX_NAME = "environment-updated"; +const QUERY_MAX_ROWS = 256; +const QUERY_MAX_BYTES = 32 * 1024 * 1024; +const QUERY_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024; +const QUERY_MAX_AGE_MS = 30 * 24 * 60 * 60_000; +// A stats answer saves one row per listed PR, so the bounds index is walked once per batch of +// saves. Each walk trims a batch below the caps, so the caps hold between walks. The batch count +// lives in the database, so tabs sharing it share one count. +const QUERY_PRUNE_BATCH_ROWS = 32; +const QUERY_PRUNE_BATCH_BYTES = 1024 * 1024; const CATALOG_KEY = "document"; const StoredShellSnapshot = StoredOrchestrationShellSnapshot; const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); @@ -65,6 +78,10 @@ const StoredVcsRefs = Schema.Struct({ refs: VcsListRefsResult, }); const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); +const isStoredQuery = Schema.is( + Schema.Struct({ environmentId: Schema.String, payload: Schema.String, updatedAt: Schema.Number }), +); +const isQueryBatch = Schema.is(Schema.Struct({ rows: Schema.Number, bytes: Schema.Number })); const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); @@ -137,6 +154,11 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { request.result.createObjectStore(VCS_REFS_STORE_NAME); } + if (!request.result.objectStoreNames.contains(QUERY_STORE_NAME)) { + request.result + .createObjectStore(QUERY_STORE_NAME) + .createIndex(QUERY_UPDATED_INDEX_NAME, ["environmentId", "updatedAt", "bytes"]); + } }); request.addEventListener("error", () => { resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); @@ -326,6 +348,75 @@ function removeDatabaseValuesInRangeOnConnection( }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); } +// Mirrors mobile's pull-request cache bounds, per environment. +function saveQueryOnConnection( + database: IDBDatabase, + environmentId: EnvironmentId, + key: string, + payload: string, + updatedAt: number, +) { + return Effect.callback((resume) => { + try { + const bytes = new TextEncoder().encode(payload).length; + const transaction = database.transaction(QUERY_STORE_NAME, "readwrite"); + transaction.addEventListener("abort", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const store = transaction.objectStore(QUERY_STORE_NAME); + // An answer too large to keep must not leave its older copy to paint on the next start. + if (bytes > QUERY_MAX_PAYLOAD_BYTES) { + store.delete(queryCacheKey(environmentId, key)); + return; + } + store.put({ environmentId, payload, updatedAt, bytes }, queryCacheKey(environmentId, key)); + const batchKey = queryBatchKey(environmentId); + const batchRequest = store.get(batchKey); + batchRequest.addEventListener("success", () => { + const batch: unknown = batchRequest.result; + const rows = (isQueryBatch(batch) ? batch.rows : 0) + 1; + const written = (isQueryBatch(batch) ? batch.bytes : 0) + bytes; + if ( + isQueryBatch(batch) && + rows < QUERY_PRUNE_BATCH_ROWS && + written < QUERY_PRUNE_BATCH_BYTES + ) { + store.put({ rows, bytes: written }, batchKey); + return; + } + store.put({ rows: 0, bytes: 0 }, batchKey); + const request = store + .index(QUERY_UPDATED_INDEX_NAME) + .openKeyCursor(IDBKeyRange.bound([environmentId], [environmentId, []]), "prev"); + let kept = 0; + let totalBytes = 0; + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) return; + const [, rowUpdatedAt, rowBytes] = cursor.key as [string, number, number]; + kept += 1; + totalBytes += rowBytes; + if ( + kept > QUERY_MAX_ROWS - QUERY_PRUNE_BATCH_ROWS || + totalBytes > QUERY_MAX_BYTES - QUERY_PRUNE_BATCH_BYTES || + rowUpdatedAt < updatedAt - QUERY_MAX_AGE_MS + ) { + store.delete(cursor.primaryKey); + } + cursor.continue(); + }); + }); + } catch (cause) { + resume(Effect.fail(catalogError("write", cause))); + } + }).pipe(Effect.withSpan("web.connectionStorage.saveQuery")); +} + function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { return withDatabase(database, (opened) => readDatabaseValueOnConnection(opened, storeName, key)); } @@ -361,6 +452,14 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } +function queryCacheKey(environmentId: EnvironmentId, key: string) { + return `${environmentId}:v1:${key}`; +} + +function queryBatchKey(environmentId: EnvironmentId) { + return `${environmentId}:batch`; +} + function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { return `${environmentId}:${cwd}`; } @@ -955,12 +1054,45 @@ export const layer = Layer.effectContext( VCS_REFS_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + removeDatabaseValuesInRange( + database, + QUERY_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), ], { concurrency: "unbounded", discard: true }, ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))); }), }); + const queryStore: NonNullable = { + load: Effect.fn("EnvironmentQueryStore.load")( + function* (environmentId, key) { + const stored = yield* readDatabaseValue( + database, + QUERY_STORE_NAME, + queryCacheKey(environmentId, key), + ); + const now = yield* Clock.currentTimeMillis; + return isStoredQuery(stored) && + stored.environmentId === environmentId && + stored.updatedAt >= now - QUERY_MAX_AGE_MS + ? Option.some(stored.payload) + : Option.none(); + }, + Effect.catch(() => Effect.succeedNone), + ), + save: (environmentId, key, payload) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + withDatabase(database, (opened) => + saveQueryOnConnection(opened, environmentId, key, payload, now), + ), + ), + Effect.catch(() => Effect.logWarning("Offline query database save failed")), + ), + }; + return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( Context.add(PullRequestRoutingPermissions, pullRequestRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), @@ -968,6 +1100,7 @@ export const layer = Layer.effectContext( Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(Persistence.EnvironmentCacheStore, cacheStore), + Context.add(EnvironmentQueryStore, queryStore), ); }), ); diff --git a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts index 2d97ce23d1c7d..75cba63031a2f 100644 --- a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts +++ b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts @@ -19,8 +19,10 @@ export function persistPullRequestStats(): QueryPersistence - row.load(environmentId, statKey(ref)), + const rows = yield* Effect.forEach( + input.refs, + (ref) => row.load(environmentId, statKey(ref)), + { concurrency: "unbounded" }, ); const stats = rows.flatMap((value) => (Option.isSome(value) ? [value.value] : [])); return stats.length ? Option.some({ stats }) : Option.none(); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 64ae5ae3df56e..b92f00c8a3695 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -21,7 +21,7 @@ import { type PullRequestRef, type PullRequestMergeMethod, PullRequestOperationError, - type PullRequestSummary, + PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -147,6 +147,7 @@ export function createLinkedPullRequestSummaryAtomFamily( const routedRequest = createPullRequestRouter(); return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:linked-summary", + persistence: persistQuery(PullRequestSummary), tag: WS_METHODS.pullRequestsSummary, execute: (input) => routedRequest(WS_METHODS.pullRequestsSummary, input), staleTimeMs: 60_000, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 381623f23207a..f79f00cdaa80e 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1370,19 +1370,20 @@ describe("persisted environment queries", () => { Effect.scoped( Effect.gen(function* () { let saved = Option.none(); + const saves = yield* Queue.unbounded(); const persistence: QueryPersistence = { load: () => Effect.sync(() => saved), save: (_environmentId, _key, value) => Effect.sync(() => { saved = Option.some(value); - }), + }).pipe(Effect.andThen(Queue.offer(saves, value))), }; const first = yield* makeEnvironmentQueryHarness(Effect.succeed("first"), { persistence, }); const firstRegistry = yield* mountEnvironmentQuery(first.atom); expect(yield* readEnvironmentQuery(firstRegistry, first.atom)).toBe("first"); - expect(saved).toEqual(Option.some("first")); + expect(yield* Queue.take(saves)).toBe("first"); const restarted = yield* makeEnvironmentQueryHarness(Effect.succeed("updated"), { persistence, @@ -1393,11 +1394,36 @@ describe("persisted environment queries", () => { yield* SubscriptionRef.set(restarted.supervisorState, queryConnectionState()); yield* Effect.yieldNow; expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("updated"); - expect(saved).toEqual(Option.some("updated")); + expect(yield* Queue.take(saves)).toBe("updated"); }), ), ); + it.effect("answers readers while its save is pending, and ends the save with the query", () => + Effect.scoped( + Effect.gen(function* () { + const saveEnded = Latch.makeUnsafe(); + const harness = yield* makeEnvironmentQueryHarness(Effect.succeed("fresh"), { + persistence: { + load: () => Effect.succeedNone, + // The registry's scheduler stops on dispose, so signal synchronously. + save: () => + Effect.never.pipe( + Effect.onInterrupt(() => Effect.sync(() => saveEnded.openUnsafe())), + ), + }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const registry = yield* mountEnvironmentQuery(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); + }), + ); + yield* saveEnded.await; + }), + ), + ); + it.effect("hands each server answer to onAnswer before readers see it", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 58c37070fd8d6..78e0cec6437a3 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -781,8 +781,12 @@ export function createEnvironmentQueryAtomFamily( return cached.value; } if (Option.isNone(previous) && Option.isSome(cached)) yield* emit(cached.value); + // Readers must not wait on the disk: web saves one IndexedDB row per listed PR's stats. + // The save ends with the query, so a removed environment's cache stays cleared. return yield* answer().pipe( - Effect.tap((value) => persistence.save(target.environmentId, cacheKey, value)), + Effect.tap((value) => + persistence.save(target.environmentId, cacheKey, value).pipe(Effect.forkScoped), + ), ); }); }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 5a5c46f28d99a..b676a91f0cab4 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -820,6 +820,74 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("reads commit stats alongside later activity pages", () => + Effect.gen(function* () { + const laterPageStarted = yield* Deferred.make(); + const statsStarted = yield* Deferred.make(); + const activityPage = (head: boolean) => ({ + data: { + repository: { + pullRequest: { + ...(head + ? { + author: null, + commits: { + nodes: [ + { + commit: { + id: "node-regular", + oid: "regular", + messageHeadline: "regular", + committedDate: "2026-07-05T00:00:00Z", + parents: { totalCount: 1 }, + authors: { nodes: [] }, + }, + }, + ], + }, + } + : {}), + comments: { + nodes: [], + pageInfo: head ? { hasNextPage: true, endCursor: "c1" } : lastPage, + }, + reviews: { nodes: [], pageInfo: lastPage }, + }, + }, + }, + }); + mockedExecute.mockImplementation((call) => { + if (matches(call, "PullRequestCommitStats")) { + return Deferred.succeed(statsStarted, undefined).pipe( + Effect.andThen(Deferred.await(laterPageStarted)), + Effect.as( + output( + encodeJson({ + data: { + nodes: [{ __typename: "Commit", oid: "regular", additions: 3, deletions: 1 }], + }, + }), + ), + ), + ); + } + const head = call.kind === "graphql" && call.variables?.["head"] === true; + return head + ? Effect.succeed(output(encodeJson(activityPage(true)))) + : Deferred.succeed(laterPageStarted, undefined).pipe( + Effect.andThen(Deferred.await(statsStarted)), + Effect.as(output(encodeJson(activityPage(false)))), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const activity = yield* cli.getPullRequestActivity(pullRequestInput); + + expect(activity.commits[0]).toMatchObject({ additions: 3, deletions: 1 }); + expect(mockedExecute).toHaveBeenCalledTimes(3); + }), + ); + it.effect("loads the complete hover card with one GraphQL request", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -3299,7 +3367,10 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.succeed(output(reviewThreadsPage([thread("T3", "c3")], null))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const read = cli.listReviewThreadComments(pullRequestInput); + const read = cli.listReviewThreadComments({ + ...pullRequestInput, + repository: `acme/refused-threads-${later}`, + }); const conversation = yield* read; expect( variablesOf("ReviewThreads").map((v) => [v["after"], v["requestedReviewers"]]), @@ -3327,7 +3398,7 @@ layer("GitHubPullRequestApi.layer", (it) => { yield* read; expect(variablesOf("ReviewThreads").at(-1)).toMatchObject({ after: null, - requestedReviewers: 50, + requestedReviewers: 0, }); }), ); @@ -3460,7 +3531,10 @@ layer("GitHubPullRequestApi.layer", (it) => { ), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const read = cli.getPullRequestDetail(pullRequestInput); + const read = cli.getPullRequestDetail({ + ...pullRequestInput, + repository: `acme/refused-checks-${refused}`, + }); const detail = yield* read; expect(detail.reviewRequestLogins).toEqual([]); expect(detail.reviewRequestTeams).toEqual([]); @@ -3480,7 +3554,8 @@ layer("GitHubPullRequestApi.layer", (it) => { if (refused) { mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(coreResponse())))); yield* read; - expect(variablesOf("PullRequestCore").at(-1)).toMatchObject({ requestedReviewers: 100 }); + expect(variablesOf("PullRequestCore").at(-1)).toMatchObject({ requestedReviewers: 0 }); + expect(mockedExecute).toHaveBeenCalledTimes(4); } }), ); @@ -3514,7 +3589,7 @@ layer("GitHubPullRequestApi.layer", (it) => { const error = yield* Effect.flip( cli.getPullRequestDetail({ cwd: "/w", - repository: "acme/web", + repository: `acme/head-changed-${refused}`, host: "github.com", number: 7, }), diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 2defbb6684919..cb96b97d8556c 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1422,6 +1422,31 @@ export const make = Effect.gen(function* () { }; }); + // GitHub refuses a requested-reviewer list holding a team the viewer cannot see. Remember the + // refusal so later reads of that pull request skip straight to the read without reviewers. + const refusedReviewers = yield* Cache.make({ + capacity: 512, + timeToLive: "10 minutes", + lookup: (_key: string) => Effect.void, + }); + const readRequestedReviewers = Effect.fn("GitHubPullRequestApi.readRequestedReviewers")( + function* ( + input: { readonly host: string; readonly repository: string; readonly number: number }, + wanted: number, + read: (requestedReviewers: number) => Effect.Effect, + ) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* api.credential(host); + const key = `${fingerprint}\0${host}\0${input.repository.toLowerCase()}\0${input.number}`; + if (yield* Cache.has(refusedReviewers, key)) return yield* read(0); + return yield* read(wanted).pipe( + Effect.catchIf(isRefusedRequestedReviewersOnly, () => + Cache.set(refusedReviewers, key, undefined).pipe(Effect.andThen(read(0))), + ), + ); + }, + ); + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); const operation = "getPullRequestDetail"; @@ -1436,8 +1461,7 @@ export const make = Effect.gen(function* () { }); return SourceControlRateLimit.Interactive.pipe( Effect.flatMap((allowReserve) => - read(100).pipe( - Effect.catchIf(isRefusedRequestedReviewersOnly, () => read(0)), + readRequestedReviewers(input, 100, read).pipe( graphql({ ...input, operation, allowReserve }), Effect.map(toPullRequestCore), Effect.flatMap(found({ ...input, operation })), @@ -2031,7 +2055,9 @@ export const make = Effect.gen(function* () { let withComments = true; let withReviews = true; const target = { ...input, operation: "getPullRequestActivity" }; - for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { + const readPage = Effect.fn("GitHubPullRequestApi.readActivityPage")(function* ( + page: number, + ) { const read: GitHubPullRequestActivityPage = yield* client .PullRequestActivity({ owner, @@ -2058,16 +2084,28 @@ export const make = Effect.gen(function* () { reviewsAfter = read.nextReviewsCursor; withComments = withComments && commentsAfter !== null; withReviews = withReviews && reviewsAfter !== null; - } + }); + yield* readPage(0); + const laterPages = Effect.gen(function* () { + for (let page = 1; page < REVIEW_THREAD_PAGES; page++) { + if (!withComments && !withReviews) return; + yield* readPage(page); + } + }); const emptyStats = toPullRequestCommitStats({ nodes: [] }); - const commitStats = - nonMergeCommitIds.length === 0 - ? emptyStats - : yield* client.PullRequestCommitStats({ ids: nonMergeCommitIds }).pipe( - graphql(target), - Effect.map(toPullRequestCommitStats), - Effect.orElseSucceed(() => emptyStats), - ); + const [, commitStats] = yield* Effect.all( + [ + laterPages, + nonMergeCommitIds.length === 0 + ? Effect.succeed(emptyStats) + : client.PullRequestCommitStats({ ids: nonMergeCommitIds }).pipe( + graphql(target), + Effect.map(toPullRequestCommitStats), + Effect.orElseSucceed(() => emptyStats), + ), + ], + { concurrency: 2 }, + ); return { author, comments: remarks.toSorted((left, right) => @@ -2157,25 +2195,12 @@ export const make = Effect.gen(function* () { function* (input) { const { owner, name } = parseRepositorySelector(input.repository); const target = { ...input, operation: "listReviewThreadComments" }; - let requestedReviewers = 50; const threadPage = ( after: string | null, - ): Effect.Effect => { - const read = () => - client.ReviewThreads({ owner, name, number: input.number, after, requestedReviewers }); - return read().pipe( - Effect.catchIf( - (error) => requestedReviewers > 0 && isRefusedRequestedReviewersOnly(error), - () => { - requestedReviewers = 0; - return read(); - }, - ), - graphql(target), - Effect.map(toReviewThreadPage), - Effect.flatMap(found(target)), - ); - }; + ): Effect.Effect => + readRequestedReviewers(input, 50, (requestedReviewers) => + client.ReviewThreads({ owner, name, number: input.number, after, requestedReviewers }), + ).pipe(graphql(target), Effect.map(toReviewThreadPage), Effect.flatMap(found(target))); const entries: GitHubReviewThreadEntry[] = []; const avatarsByLogin = new Map(); const botLogins = new Set(); From e71e4d2a020c221312f1f4d175e382f8915a1324 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:42:37 +0530 Subject: [PATCH 038/200] fix(mobile): approval card buttons line up (#31415) Co-authored-by: Claude Opus 5.5 --- .../features/threads/PendingApprovalCard.tsx | 3 +- .../features/threads/ThreadDetailScreen.tsx | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index b5a9d4ed27eb2..f9ec44d5d4ee5 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -80,7 +80,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {warning ? ( {warning} ) : null} - + {options.map((option) => (