diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 595c083638c6d..8a395284296fa 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -472,6 +472,20 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ))} ) : null} + + write({ freshSessionOnColdReturn: value })} + /> + { } return `/${parts.join("/")}`; }; -const layerWorktreeLocation = (worktreesDir = managedWorktreesDir) => +const layerWorktreeLocation = ( + worktreesDir = managedWorktreesDir, + settings?: Parameters[0], +) => Layer.mergeAll( Path.layer, Layer.mock(ServerConfig.ServerConfig)({ worktreesDir } as ServerConfig.ServerConfig["Service"]), - ServerSettings.layerTest(), + ServerSettings.layerTest(settings), ); function startWithMissingWorktree( @@ -286,6 +290,7 @@ it.each([ }); function makeLocalCommandHarness(input: { + readonly settings?: Parameters[0]; readonly pstack?: PstackPack.PstackPack["Service"]; readonly text: string; readonly knowledgeStore?: Effect.Success; @@ -307,6 +312,8 @@ function makeLocalCommandHarness(input: { readonly ensureThreadFailure?: unknown; readonly historyReadFailureAfterFallback?: unknown; readonly resumeFailure?: ProviderAdapter.ProviderAdapterV2Error; + readonly pendingBackgroundWorkOnResume?: boolean; + readonly pendingBackgroundWorkAfterHandoff?: boolean; readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; @@ -786,7 +793,20 @@ function makeLocalCommandHarness(input: { const nativeStart = vi.fn( () => Effect.void, ); - const fallbackEnsureThread = vi.fn(() => Effect.succeed(providerThread)); + const fallbackEnsureThread = vi.fn(() => + Effect.succeed( + input.recoveryNativeId === undefined + ? providerThread + : { + ...providerThread, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: input.recoveryNativeId, + strength: "strong" as const, + }, + }, + ), + ); const resumeFallbackSession = { driver: providerThread.driver, resumeThread: () => @@ -832,13 +852,19 @@ function makeLocalCommandHarness(input: { forkThread, startTurn, }; + let resumedNativeThread = false; + let preparedRecoveryHandoff = false; const open = vi.fn(() => input.sideChat !== undefined ? Effect.succeed(sideChatSession as never) : input.interruptOpen === true ? Effect.interrupt : "historyReadFailureAfterFallback" in input || "resumeFailure" in input - ? Effect.succeed(resumeFallbackSession as never) + ? Effect.succeed({ + ...resumeFallbackSession, + providerSession, + startTurn: nativeStart, + } as never) : "ensureThreadFailure" in input ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) : "openFailure" in input @@ -870,7 +896,18 @@ function makeLocalCommandHarness(input: { strength: "strong" as const, }, }), - resumeThread: () => Effect.succeed(projection.providerThreads.at(-1)), + resumeThread: () => + Effect.sync(() => { + resumedNativeThread = true; + return projection.providerThreads.at(-1); + }), + hasPendingBackgroundWorkForThread: () => + Effect.sync( + () => + (input.pendingBackgroundWorkOnResume === true && resumedNativeThread) || + (input.pendingBackgroundWorkAfterHandoff === true && + preparedRecoveryHandoff), + ), } as never) : Effect.die("A local command must not open a native session."), ); @@ -1062,7 +1099,13 @@ function makeLocalCommandHarness(input: { summaryText: input.sideChat ? "PARENT_HISTORY" : "Recovered history", createdByProviderInstanceId: input.sideChat ? request.toProviderInstanceId : null, updatedAt: request.createdAt, - } as OrchestrationV2ContextHandoff), + } as OrchestrationV2ContextHandoff).pipe( + Effect.tap(() => + Effect.sync(() => { + preparedRecoveryHandoff = true; + }), + ), + ), }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent, @@ -1076,7 +1119,7 @@ function makeLocalCommandHarness(input: { }), }), IdAllocator.layer, - layerWorktreeLocation(input.worktreesDir), + layerWorktreeLocation(input.worktreesDir, input.settings), input.fileSystem === undefined ? FileSystem.layerNoop({ exists: () => Effect.succeed(true) }) : Layer.succeed(FileSystem.FileSystem, input.fileSystem), @@ -2434,6 +2477,157 @@ effectIt.effect( }), ); +effectIt.effect.each([ + { name: "a long idle thread", enabled: true, usedTokens: 120_000, idleMinutes: 61, fresh: true }, + { name: "the setting off", enabled: false, usedTokens: 120_000, idleMinutes: 61, fresh: false }, + { name: "a recent turn", enabled: true, usedTokens: 120_000, idleMinutes: 59, fresh: false }, + { name: "a small thread", enabled: true, usedTokens: 99_999, idleMinutes: 61, fresh: false }, + { + name: "recent completion after an old token report", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + turnCompletedMinutes: 60, + fresh: false, + }, + { + name: "insufficient room for a fresh handoff", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + modelContextWindow: 16_000, + fresh: false, + }, + { + name: "a native thread held by another client", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + threadInUse: true, + fresh: false, + }, + { + name: "background work discovered while resuming", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + pendingBackgroundWorkOnResume: true, + fresh: false, + }, + { + name: "background work discovered while preparing the handoff", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + pendingBackgroundWorkAfterHandoff: true, + fresh: false, + }, + { + name: "a command message", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + text: "/compact", + fresh: false, + }, + { + name: "a message not from a person", + enabled: true, + usedTokens: 120_000, + idleMinutes: 61, + createdBy: "system" as const, + fresh: false, + }, +] as const)("starting a turn with $name picks fresh session: $fresh", (scenario) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + settings: { freshSessionOnColdReturn: scenario.enabled }, + text: "text" in scenario ? scenario.text : "Keep going on the migration", + previousTurn: { runStatus: "completed", turnStatus: "completed" }, + recoveryNativeId: "fresh-native-conversation", + ...("modelContextWindow" in scenario + ? { modelContextWindow: scenario.modelContextWindow } + : {}), + ...("threadInUse" in scenario + ? { + resumeFailure: new ProviderAdapter.ProviderAdapterThreadInUseError({ + driver: ProviderDriverKind.make("codex"), + providerThreadId: ProviderThreadId.make("new-provider-thread"), + detail: "This conversation is open in another Codex client.", + cause: new Error("thread existing-native-conversation already has an active writer"), + }), + } + : {}), + ...("pendingBackgroundWorkOnResume" in scenario + ? { pendingBackgroundWorkOnResume: scenario.pendingBackgroundWorkOnResume } + : {}), + ...("pendingBackgroundWorkAfterHandoff" in scenario + ? { pendingBackgroundWorkAfterHandoff: scenario.pendingBackgroundWorkAfterHandoff } + : {}), + }); + const projection = harness.projection(); + const previousAt = "2026-09-04T12:00:00.000Z"; + const previousAttemptId = RunAttemptId.make("previous-failed-attempt"); + harness.setProjection({ + ...projection, + messages: projection.messages.map((message) => ({ + ...message, + ...("createdBy" in scenario ? { createdBy: scenario.createdBy } : {}), + })), + attempts: [ + ...projection.attempts, + { + ...projection.attempts[0]!, + id: previousAttemptId, + runId: RunId.make("previous-failed-run"), + rootNodeId: NodeId.make("previous-root"), + nativeThreadId: "existing-native-conversation", + providerThreadId: projection.providerThreads[0]!.id, + status: "completed", + }, + ], + providerTurns: projection.providerTurns.map((turn) => ({ + ...turn, + tokenUsage: { usedTokens: scenario.usedTokens, updatedAt: previousAt }, + ...("turnCompletedMinutes" in scenario + ? { + completedAt: DateTime.makeUnsafe( + Date.parse(previousAt) + scenario.turnCompletedMinutes * 60_000, + ), + } + : {}), + })), + }); + yield* TestClock.setTime(Date.parse(previousAt) + scenario.idleMinutes * 60_000); + + yield* harness.start; + + if ("threadInUse" in scenario) { + expect(harness.fallbackEnsureThread).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); + } + // Commands run without a root run, which also means no fresh session. + const nativeId = + harness.startRootRun.mock.calls[0]?.[0].providerThread.nativeThreadRef?.nativeId; + expect(nativeId === "fresh-native-conversation").toBe(scenario.fresh); + expect(harness.events.some((event) => event.type === "context-handoff.updated")).toBe( + scenario.fresh, + ); + if ( + "pendingBackgroundWorkOnResume" in scenario || + "pendingBackgroundWorkAfterHandoff" in scenario + ) { + expect(nativeId).toBe("existing-native-conversation"); + } + if ("modelContextWindow" in scenario) { + yield* dispatchKnowledge(harness.startRootRun.mock.calls[0]![0]); + expect(nativeId).toBe("existing-native-conversation"); + expect(harness.nativeStart).toHaveBeenCalledOnce(); + } + }), +); + effectIt.effect.each(["prepared", "uncertain"] as const)( "recovers a %s knowledge receipt through the native handoff fallback without resuming the ambiguous conversation", (status) => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 73029a242d6df..22127f89a506a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -136,6 +136,11 @@ export class ProviderTurnStartServiceV2 extends Context.Service< ProviderTurnStartServiceV2Shape >()("t3/orchestration-v2/ProviderTurnStartService/ProviderTurnStartServiceV2") {} +// Past both limits, a replay of real threads shows a fresh session repaying its uncached first +// request: the cache has already expired, and the smaller prompt is cheaper on every later call. +const COLD_RETURN_IDLE_MS = 60 * 60_000; +const COLD_RETURN_MIN_CONTEXT_TOKENS = 100_000; + export const layer: Layer.Layer< ProviderTurnStartServiceV2, never, @@ -159,6 +164,7 @@ export const layer: Layer.Layer< ProviderTurnStartServiceV2, Effect.gen(function* () { const pstack = yield* PstackPack.PstackPack; + const serverSettings = yield* ServerSettings.ServerSettingsService; const eventSink = yield* EventSink.EventSinkV2; const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; @@ -1033,6 +1039,47 @@ export const layer: Layer.Layer< return retryHandoff; }, ); + const isColdReturn = Effect.fn("ProviderTurnStartService.isColdReturn")(function* ( + resumedThread: OrchestrationV2ProviderThread, + ) { + const enabled = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.freshSessionOnColdReturn), + Effect.orElseSucceed(() => false), + ); + if (!enabled) return false; + const measured = latestNativeContextUsage(projection, resumedThread); + if (measured === undefined) return false; + if (measured.usage.usedTokens < COLD_RETURN_MIN_CONTEXT_TOKENS) return false; + const reportedAt = DateTime.make(measured.reportedAt); + if (Option.isNone(reportedAt)) return false; + // Token telemetry can arrive before a long tool run finishes. Count accepted root + // turn activity on this native conversation, including turns without usage reports. + let lastActivityMs = DateTime.toEpochMillis(reportedAt.value); + const attempts = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); + for (const turn of projection.providerTurns) { + if (turn.providerThreadId !== resumedThread.id || turn.runAttemptId === null) continue; + const attempt = attempts.get(turn.runAttemptId); + if ( + attempt === undefined || + attempt.nativeThreadId !== resumedThread.nativeThreadRef?.nativeId || + attempt.providerThreadId !== resumedThread.id || + attempt.rootNodeId !== turn.nodeId + ) + continue; + const activityAt = turn.completedAt ?? turn.startedAt; + if (activityAt !== null) { + lastActivityMs = Math.max(lastActivityMs, DateTime.toEpochMillis(activityAt)); + } + } + const idleMs = DateTime.toEpochMillis(yield* DateTime.now) - lastActivityMs; + if (idleMs < COLD_RETURN_IDLE_MS) return false; + const hasPendingWork = yield* ( + session.hasPendingBackgroundWorkForThread?.(resumedThread, { includeSubagents: true }) ?? + session.hasPendingBackgroundWork ?? + Effect.succeed(false) + ); + return !hasPendingWork; + }); const prepareRecoveryHandoff = Effect.fn("ProviderTurnStartService.prepareRecoveryHandoff")( function* () { const transferId = yield* idAllocator.allocate.contextTransfer({ @@ -1269,6 +1316,13 @@ export const layer: Layer.Layer< runtimePolicy: resolvedRuntimePolicy, }), ); + // Resume checks native ownership and refreshes background work before a fresh session. + const coldReturn = + resumed._tag === "Success" && + message.createdBy === "user" && + !isCompactionCommandMessage(message) && + !goalCommand && + (yield* isColdReturn(resumed.success)); if (resumed._tag === "Success") { const context = handoffContextForThread(resumed.success); context.budget = yield* Effect.cached(context.budget); @@ -1285,7 +1339,7 @@ export const layer: Layer.Layer< Effect.as(true), Effect.catchTags({ ContextHandoffBudgetError: () => Effect.succeed(false) }), ); - if (fits) return resumed.success; + if (fits && !coldReturn) return resumed.success; const freshThread = { ...resumed.success, nativeThreadRef: null }; const freshContext = handoffContextForThread(freshThread); const { handoff } = yield* recoveryHandoff; @@ -1308,6 +1362,7 @@ export const layer: Layer.Layer< Effect.succeed(false) ); if (hasPendingWork) { + if (fits) return resumed.success; return yield* loadFromProvider( Effect.fail( new ProviderAdapter.ProviderAdapterTurnStartError({ @@ -1337,12 +1392,19 @@ export const layer: Layer.Layer< driver: session.driver, providerThreadId: providerThread.id, runId, - reason: uncertainDelivery - ? "uncertain_history_delivery" - : resumed._tag === "Success" - ? "handoff_budget_exhausted" - : "resume_failed", - errorTag: resumed._tag === "Failure" ? resumed.failure._tag : "ContextHandoffBudgetError", + reason: coldReturn + ? "cold_return" + : uncertainDelivery + ? "uncertain_history_delivery" + : resumed._tag === "Success" + ? "handoff_budget_exhausted" + : "resume_failed", + ...(coldReturn + ? {} + : { + errorTag: + resumed._tag === "Failure" ? resumed.failure._tag : "ContextHandoffBudgetError", + }), }); const replacement = yield* loadFromProvider( session.ensureThread({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6ac9ab54c26b8..605fc641f0c77 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -523,6 +523,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Show skills in slash menu"] : []), ...(settings.pstackEnabled !== DEFAULT_UNIFIED_SETTINGS.pstackEnabled ? ["pstack"] : []), + ...(settings.freshSessionOnColdReturn !== DEFAULT_UNIFIED_SETTINGS.freshSessionOnColdReturn + ? ["Fresh session after a long break"] + : []), ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ["Rich text composer"] : []), @@ -645,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, pstackEnabled: DEFAULT_UNIFIED_SETTINGS.pstackEnabled, + freshSessionOnColdReturn: DEFAULT_UNIFIED_SETTINGS.freshSessionOnColdReturn, composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, diff --git a/apps/web/src/components/settings/ZackSettings.tsx b/apps/web/src/components/settings/ZackSettings.tsx index ed41da6afbfee..1b7bcb4295756 100644 --- a/apps/web/src/components/settings/ZackSettings.tsx +++ b/apps/web/src/components/settings/ZackSettings.tsx @@ -27,6 +27,7 @@ export function ZackSettings() { + @@ -155,6 +156,41 @@ function ExternalHistoryRow() { ); } +function FreshSessionOnColdReturnRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ + freshSessionOnColdReturn: DEFAULT_UNIFIED_SETTINGS.freshSessionOnColdReturn, + }) + } + /> + ) : null + } + control={ + + updateSettings({ freshSessionOnColdReturn: Boolean(checked) }) + } + aria-label="Fresh session after a long break" + /> + } + /> + ); +} + function PstackRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 21b130fcd3b8a..fb7aa7c78664e 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -234,6 +234,12 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(fallback.scope, "environment")).toBe(true); expect(isSettingsSearchScopeAvailable(fallback.scope, "all")).toBe(true); expect(isSettingsSearchScopeAvailable(fallback.scope, "project")).toBe(false); + const coldReturn = getSettingsSearchTargetScope("fresh-session-on-cold-return")!; + expect(coldReturn.scope).toBe("environment-defaults"); + expect(isSettingsSearchScopeAvailable(coldReturn.scope, "environment")).toBe(true); + expect(isSettingsSearchScopeAvailable(coldReturn.scope, "all")).toBe(true); + expect(isSettingsSearchScopeAvailable(coldReturn.scope, "project")).toBe(false); + expect(isSettingsSearchScopeAvailable(coldReturn.scope, "checkout")).toBe(false); for (const id of ["response-streaming", "speech-project-dictionary"]) { expect(getSettingsSearchTargetScope(id)?.scope).toBe("project-defaults"); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 9c0f5bd012a83..5217ee97b5a9a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -714,6 +714,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["zack lauren tan skills workflows plugin pack architect setup models"], }, + { + id: "fresh-session-on-cold-return", + title: "Fresh session after a long break", + to: "/settings/labs", + scope: "environment-defaults", + searchTerms: ["zack cached input cost tokens long thread idle context handoff trim"], + }, { id: "composer-rich-text", title: "Rich text composer", diff --git a/docs/user/portable-handoffs.md b/docs/user/portable-handoffs.md index cfefeeab32ea2..856b405537d82 100644 --- a/docs/user/portable-handoffs.md +++ b/docs/user/portable-handoffs.md @@ -21,6 +21,12 @@ to retrieve saved messages and activity, including the remainder of a long item. constraint, you can still repeat it in your next message. Unless the project has conversation summaries turned on, a handoff is a budgeted selection, not a summary. +## Fresh sessions after a long break + +Long threads get more expensive with every message, because the provider re-reads the whole history each time. With **Settings > Labs > Zack > Fresh session after a long break** turned on, a message you send to a thread over about 100,000 tokens, after an hour or more idle, continues in a new provider session. It receives the same handoff described above, and your thread and its history in T3 Code stay as they were. + +The first request costs more because nothing is cached, and the agent may re-read some files, so this suits threads you keep returning to. Runs with background work still in progress are left alone. The setting is off by default. + ## Conversation summaries With conversation summaries on, older history that would not fit in a handoff can arrive as short diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 527b4cb25f7c5..235fa712cffaf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1349,6 +1349,7 @@ export const ServerSettings = Schema.Struct({ enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Offer the bundled pstack skills in every provider composer. */ pstackEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + freshSessionOnColdReturn: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Off by default: turning it on moves skill folders out of the user's Claude and Codex homes. */ skillsLibraryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Off by default: each settled run in an opted-in project costs a text generation call. */ @@ -1808,6 +1809,7 @@ export const ServerSettingsPatch = Schema.Struct({ ), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), pstackEnabled: Schema.optionalKey(Schema.Boolean), + freshSessionOnColdReturn: Schema.optionalKey(Schema.Boolean), skillsLibraryEnabled: Schema.optionalKey(Schema.Boolean), enableAssistedLearning: Schema.optionalKey(Schema.Boolean), enableConversationSummaries: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/testing/providerEnv.ts b/packages/shared/src/testing/providerEnv.ts index ac07867c20473..50db357a45f9e 100644 --- a/packages/shared/src/testing/providerEnv.ts +++ b/packages/shared/src/testing/providerEnv.ts @@ -10,6 +10,8 @@ const KEPT_ENV = new Set([ "T3CODE_TRANSFER_BUDGET_RESULT_PATH", // Opt-in: points the HtmlRender tests at a headless browser, which they skip without. "T3CODE_TEST_HEADLESS_SHELL", + // The mobile test script sets this; tests must see the platform their code was compiled for. + "T3_MOBILE_TEST_PLATFORM", ]); if (process.env.T3CODE_TEST_KEEP_PROVIDER_ENV !== "1") {