From e139768a7e7f53fd36c2ff88b63bc03c71f3fad7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 11:29:01 -0800 Subject: [PATCH 001/187] 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 002/187] 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 003/187] 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 004/187] 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 005/187] 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 006/187] 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 007/187] 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 008/187] 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 009/187] 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 010/187] 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 011/187] 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 025/187] 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 026/187] 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) => (