From 467ef35f769f06bd50c9364915c9f12eb176b39b Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 21 Oct 2026 09:10:44 -0400 Subject: [PATCH 1/3] feat(contracts): add a continuationPrompt server setting The message T3 Code sends when it continues a turn on its own, defaulting to "Continue where you left off.". It syncs across environments like the other usage-limit settings, and a host can hand it to a cloud machine with an account switch. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/state/sharedSettings.test.ts | 1 + packages/client-runtime/src/state/sharedSettings.ts | 1 + packages/contracts/src/environmentControl.ts | 2 ++ packages/contracts/src/settings.ts | 11 +++++++++++ 4 files changed, 15 insertions(+) diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 6f94fe0f9037..094eeb15cb3d 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -124,6 +124,7 @@ describe("pickSharedServerSettings", () => { Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), ).toEqual([ "autoResumeLimitedThreads", + "continuationPrompt", "continueThreadsAfterServerUpdate", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 549ab5422e40..1e9779a63543 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -16,6 +16,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", "snoozeLimitedThreads", + "continuationPrompt", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/contracts/src/environmentControl.ts b/packages/contracts/src/environmentControl.ts index 4e985f230bc6..0f623ca13706 100644 --- a/packages/contracts/src/environmentControl.ts +++ b/packages/contracts/src/environmentControl.ts @@ -447,6 +447,8 @@ export const GuestAccountSwitchInput = Schema.Struct({ threadId: ThreadId, /** The chat's run that hit a usage limit, continued on the new account. */ continueRunId: Schema.optional(RunId), + /** The host's `continuationPrompt`, so a cloud chat continues as the host's chats do. */ + continuationPrompt: Schema.optional(TrimmedNonEmptyString), }); export type GuestAccountSwitchInput = typeof GuestAccountSwitchInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 35f3e9c8ee6b..6cc3b1851437 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1101,6 +1101,8 @@ export const StorageCleanupSettings = Schema.Struct({ }); export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; +export const DEFAULT_CONTINUATION_PROMPT = "Continue where you left off."; + export const ServerSettings = Schema.Struct({ // Null means no client has migrated this preference yet; an empty value is // authoritative so another device cannot resurrect cleared preferences. @@ -1184,6 +1186,14 @@ export const ServerSettings = Schema.Struct({ * left, and stays on it until it runs out; pickers offer the provider instead of each account. */ autoPickProviderAccounts: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + /** + * What T3 Code sends when it continues a turn on its own: after a restart, at a usage-limit + * reset, or on another account. It stays the message's first word, so a skill invocation such as + * `/review` still reads as one. + */ + continuationPrompt: TrimmedNonEmptyString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CONTINUATION_PROMPT)), + ), sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1508,6 +1518,7 @@ export const ServerSettingsPatch = Schema.Struct({ autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), autoSwitchCloudAccounts: Schema.optionalKey(Schema.Boolean), autoPickProviderAccounts: Schema.optionalKey(Schema.Boolean), + continuationPrompt: Schema.optionalKey(TrimmedNonEmptyString), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ From ff57684a698f5b3e3383a053216d013e3c223600 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 21 Oct 2026 09:10:45 -0400 Subject: [PATCH 2/3] feat(server): continuations send the configured prompt, and say when the account changed Limit resumes, limit switches, restart continuations and cloud account switches now send continuationPrompt. A switch to another account at a usage limit adds a short note that the old account ran out and that subagents and new chats also route to accounts with room. The prompt always comes first so a skill invocation stays the first word. Co-Authored-By: Claude Opus 5.5 --- .../environmentControl/EnvironmentControl.ts | 3 +- .../RestartContinuation.test.ts | 35 ++++++++++++++++--- .../orchestration-v2/RestartContinuation.ts | 10 ++---- .../UsageLimitRecoveryWorker.ts | 19 +++++++--- .../accountSwitchContinuation.ts | 7 ++++ .../src/orchestration-v2/runtimeLayer.test.ts | 13 ++++--- .../provider/ProviderAccountSwitch.test.ts | 16 +++++++-- .../src/provider/ProviderAccountSwitch.ts | 3 +- 8 files changed, 81 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/orchestration-v2/accountSwitchContinuation.ts diff --git a/apps/server/src/environmentControl/EnvironmentControl.ts b/apps/server/src/environmentControl/EnvironmentControl.ts index 5ef53c8e57a9..3d22abb489dc 100644 --- a/apps/server/src/environmentControl/EnvironmentControl.ts +++ b/apps/server/src/environmentControl/EnvironmentControl.ts @@ -3021,7 +3021,8 @@ export const layer = Layer.effect( sendSwitch: async (lease, input) => { if (!lease.remoteAccess) throw new Error("The cloud machine has no remote access."); await serveProxy(lease); - return sendAccountSwitch(lease.remoteAccess, input); + const { continuationPrompt } = await runLogged(settings.getSettings); + return sendAccountSwitch(lease.remoteAccess, { ...input, continuationPrompt }); }, }; const accountRotation = makeAccountRotation({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 80d9aadeced0..74127d0bdb7c 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -913,7 +913,10 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = ( + projection: OrchestrationV2ThreadProjection, + continuationPrompt?: string, +) => Effect.gen(function* () { const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( @@ -927,15 +930,21 @@ const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => return Effect.succeed({} as never); }, }), - ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ServerSettings.layerTest({ + continueThreadsAfterServerUpdate: true, + ...(continuationPrompt === undefined ? {} : { continuationPrompt }), + }), ), ), ); return dispatches; }); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => - continuationDispatches(projection).pipe( +const continuationTexts = ( + projection: OrchestrationV2ThreadProjection, + continuationPrompt?: string, +) => + continuationDispatches(projection, continuationPrompt).pipe( Effect.map((dispatches) => dispatches.map((command) => command.text)), ); @@ -1053,7 +1062,23 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => ); assert.lengthOf(texts, 1); assert.include(texts[0]!, "Background reviewer"); - assert.isTrue(texts[0]!.endsWith("Continue where you left off.")); + assert.isTrue(texts[0]!.startsWith("Continue where you left off.\n\n")); + }), +); + +it.effect("continues with the configured prompt first, and no account note", () => + Effect.gen(function* () { + const prompt = "/poteto-mode Continue where you left off."; + assert.deepEqual(yield* continuationTexts(cutMidTurn(), prompt), [prompt]); + const [withNote] = yield* continuationTexts( + cutMidTurn({ + restartCancelledBackgroundWork: [{ kind: "subagent", label: "Background reviewer" }], + }), + prompt, + ); + assert.isTrue(withNote!.startsWith(`${prompt}\n\n`)); + assert.include(withNote!, "Background reviewer"); + assert.notInclude(withNote!, "usage limit"); }), ); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 1343fed42c58..9ab3214561cb 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -28,8 +28,6 @@ import { restartContinuationNote, } from "./RestartBackgroundNote.ts"; -const CONTINUE_PROMPT = "Continue where you left off."; - const MAX_CONTINUATION_CHAIN = 3; const CONTINUATION_LOOP_WINDOW_MS = 5 * 60 * 1_000; @@ -274,12 +272,10 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const prompt = enabled.continuationPrompt; + // The prompt leads so a skill invocation in it stays the message's first word. const text = - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`; + noteText === undefined ? prompt : note.settled ? noteText : `${prompt}\n\n${noteText}`; yield* threads .dispatch({ type: "message.dispatch", diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index a32a3290c9a3..35c85729f7b4 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -1,5 +1,6 @@ import { CommandId, + DEFAULT_CONTINUATION_PROMPT, MessageId, type OrchestrationV2Command, type OrchestrationV2LimitRecoveryUpdate, @@ -12,6 +13,7 @@ import * as Layer from "effect/Layer"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeAccountRouting } from "./accountRouting.ts"; +import { accountSwitchContinuation } from "./accountSwitchContinuation.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -21,6 +23,7 @@ export function limitRecoveryCommand( autoResume: boolean, nowMs: number, snooze = false, + prompt = DEFAULT_CONTINUATION_PROMPT, ): OrchestrationV2Command | null { if ( thread.status !== "failed" || @@ -66,6 +69,7 @@ export function limitRecoveryCommand( thread.latestRunId, `limit-resume:${identity}:${recovery.requestId ?? "legacy"}`, recovery.requestId, + prompt, ); } @@ -80,6 +84,7 @@ function limitSwitchCommand( readonly commandId: CommandId; readonly limitRecovery: OrchestrationV2LimitRecoveryUpdate; }, + prompt: string, ): OrchestrationV2Command { const { runId, resetAt } = arm.limitRecovery; return { @@ -88,6 +93,7 @@ function limitSwitchCommand( runId, `limit-switch:${thread.id}:${runId}:${Date.parse(resetAt)}`, arm.commandId, + accountSwitchContinuation(prompt), ), modelSelection: thread.modelSelection, }; @@ -98,6 +104,7 @@ const continuation = ( runId: RunId, id: string, requestId: CommandId | undefined, + text: string, ) => ({ type: "message.dispatch", @@ -106,7 +113,7 @@ const continuation = ( threadId, usageLimitContinuationOfRunId: runId, ...(requestId === undefined ? {} : { usageLimitRecoveryRequestId: requestId }), - text: "Continue where you left off.", + text, attachments: [], dispatchMode: { type: "start_immediately" }, createdBy: "user", @@ -134,6 +141,7 @@ export const makeSweep = Effect.gen(function* () { preferences.autoResumeLimitedThreads, nowMs, preferences.snoozeLimitedThreads, + preferences.continuationPrompt, ); if (command === null) continue; yield* Effect.gen(function* () { @@ -157,10 +165,11 @@ export const makeSweep = Effect.gen(function* () { }); if (moves) yield* threads.dispatch( - limitSwitchCommand(thread, { - commandId: command.commandId, - limitRecovery: command.limitRecovery, - }), + limitSwitchCommand( + thread, + { commandId: command.commandId, limitRecovery: command.limitRecovery }, + preferences.continuationPrompt, + ), ); } else { yield* threads.dispatch(command); diff --git a/apps/server/src/orchestration-v2/accountSwitchContinuation.ts b/apps/server/src/orchestration-v2/accountSwitchContinuation.ts new file mode 100644 index 000000000000..d5d30b899801 --- /dev/null +++ b/apps/server/src/orchestration-v2/accountSwitchContinuation.ts @@ -0,0 +1,7 @@ +/** + * A continuation on another account because the last one ran out. Agents otherwise read a limit + * stop as a reason to stop delegating or wait for the reset. The prompt comes first so a skill + * invocation in it stays the message's first word. + */ +export const accountSwitchContinuation = (prompt: string) => + `${prompt}\n\nYour previous account hit its usage limit, so this chat moved to another account with room. Subagents and new chats are also routed to accounts with room, so keep working and delegating as usual.`; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a1acc668a23f..d275e12c760e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7191,6 +7191,7 @@ const limitSwitchAccounts = Effect.map(DateTime.now, (now) => [ ]); const layerLimitSwitchSettings = ServerSettings.layerTest({ autoResumeLimitedThreads: true, + continuationPrompt: "/poteto-mode Continue where you left off.", providerInstances: { [alternateInstanceId]: { driver: "codex", enabled: true } }, projectSettingsOverrides: { [limitSwitchNoRoomProjectId]: { @@ -7317,12 +7318,16 @@ it.layer(layerLimitSwitchTest)("usage-limit account switch", (it) => { const threadId = yield* limitedThread("room", ProjectId.make("project:limit-switch-room")); yield* sweep(); yield* sweep(); - const { runs, thread } = yield* orchestrator.getThreadProjection(threadId); + const { messages, runs, thread } = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual( runs.map((run) => run.modelSelection.instanceId), [modelSelection.instanceId, alternateInstanceId], ); assert.isFalse(thread.limitRecovery?.snooze); + assert.equal( + messages.at(-1)?.text, + "/poteto-mode Continue where you left off.\n\nYour previous account hit its usage limit, so this chat moved to another account with room. Subagents and new chats are also routed to accounts with room, so keep working and delegating as usual.", + ); }), ); @@ -7337,12 +7342,12 @@ it.layer(layerLimitSwitchTest)("usage-limit account switch", (it) => { assert.isTrue(armed.thread.limitRecovery?.autoResume); yield* TestClock.adjust("1 hour"); yield* sweep(); + const resumed = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).runs.map( - (run) => run.modelSelection.instanceId, - ), + resumed.runs.map((run) => run.modelSelection.instanceId), [modelSelection.instanceId, modelSelection.instanceId], ); + assert.equal(resumed.messages.at(-1)?.text, "/poteto-mode Continue where you left off."); }), ); }); diff --git a/apps/server/src/provider/ProviderAccountSwitch.test.ts b/apps/server/src/provider/ProviderAccountSwitch.test.ts index c1db085e77e2..6739348e025e 100644 --- a/apps/server/src/provider/ProviderAccountSwitch.test.ts +++ b/apps/server/src/provider/ProviderAccountSwitch.test.ts @@ -292,7 +292,12 @@ it.layer(NodeServices.layer)("ProviderAccountSwitch", (it) => { variables: [{ name: "CLAUDE_CODE_OAUTH_TOKEN", value: "token-b" }], }, threadId, - ...(continueRunId === undefined ? {} : { continueRunId }), + ...(continueRunId === undefined + ? {} + : { + continueRunId, + continuationPrompt: "/poteto-mode Continue where you left off.", + }), }); yield* saveInstance(claudeInstanceId, { @@ -361,7 +366,10 @@ it.layer(NodeServices.layer)("ProviderAccountSwitch", (it) => { after.runs.map((run) => run.status), ["failed", "running"], ); - assert.equal(after.messages.at(-1)?.text, "Continue where you left off."); + assert.equal( + after.messages.at(-1)?.text, + "/poteto-mode Continue where you left off.\n\nYour previous account hit its usage limit, so this chat moved to another account with room. Subagents and new chats are also routed to accounts with room, so keep working and delegating as usual.", + ); const instance = (yield* settings.getSettings).providerInstances[claudeInstanceId]; assert.equal(instance?.displayName, "Second account"); assert.deepEqual(instance?.config, { accountEmail: "second@example.com" }); @@ -551,6 +559,10 @@ it.layer(NodeServices.layer)("ProviderAccountSwitch", (it) => { const after = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual(switched, { kind: "switched", continued: true }); + assert.include( + after.messages.map((message) => message.text), + "Continue where you left off.\n\nYour previous account hit its usage limit, so this chat moved to another account with room. Subagents and new chats are also routed to accounts with room, so keep working and delegating as usual.", + ); assert.deepEqual( after.runs.map((run) => [run.status, run.ordinal]), [ diff --git a/apps/server/src/provider/ProviderAccountSwitch.ts b/apps/server/src/provider/ProviderAccountSwitch.ts index f2eb2549bd96..56018dacff39 100644 --- a/apps/server/src/provider/ProviderAccountSwitch.ts +++ b/apps/server/src/provider/ProviderAccountSwitch.ts @@ -36,6 +36,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { credentialVariables } from "../environmentControl/ProvisioningProviderProfile.ts"; +import { accountSwitchContinuation } from "../orchestration-v2/accountSwitchContinuation.ts"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerSettingsModule from "../serverSettings.ts"; @@ -262,7 +263,7 @@ const make = Effect.gen(function* () { messageId: MessageId.make(continuation), threadId: input.threadId, manualContinuationOfRunId: input.continueRunId, - text: "Continue where you left off.", + text: accountSwitchContinuation(input.continuationPrompt ?? after.continuationPrompt), attachments: [], dispatchMode: { type: "start_immediately" }, createdBy: "user", From 88d0dd5ab2bd9c33d51904842bb1e678dc693933 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 21 Oct 2026 09:10:46 -0400 Subject: [PATCH 3/3] feat(web): edit the continuation message in Settings, and use it for Resume Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 39 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5258e5a16200..02f0dfe750ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6252,7 +6252,7 @@ export default function ChatView(props: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: "Continue where you left off.", + text: settings.continuationPrompt, attachments: [], }, runtimeMode, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 87558fa1a782..fde6d2350210 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -596,6 +596,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoSwitchCloudAccounts !== DEFAULT_UNIFIED_SETTINGS.autoSwitchCloudAccounts ? ["Switch cloud chats to another account at a usage limit"] : []), + ...(settings.continuationPrompt !== DEFAULT_UNIFIED_SETTINGS.continuationPrompt + ? ["Continuation message"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip @@ -756,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, autoPickProviderAccounts: DEFAULT_UNIFIED_SETTINGS.autoPickProviderAccounts, autoSwitchCloudAccounts: DEFAULT_UNIFIED_SETTINGS.autoSwitchCloudAccounts, + continuationPrompt: DEFAULT_UNIFIED_SETTINGS.continuationPrompt, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2162,6 +2166,7 @@ export function GeneralSettingsPanel() { const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); + const mixedContinuationPrompt = useScopedSettingsMixed(["continuationPrompt"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); const backgroundActivityDescription = backgroundActivityProfileOption === "advanced" @@ -2320,6 +2325,40 @@ export function GeneralSettingsPanel() { } /> ) : null} + + updateSettings({ + continuationPrompt: DEFAULT_UNIFIED_SETTINGS.continuationPrompt, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + continuationPrompt: next.trim() || DEFAULT_UNIFIED_SETTINGS.continuationPrompt, + }) + } + placeholder={ + mixedContinuationPrompt ? "Mixed" : DEFAULT_UNIFIED_SETTINGS.continuationPrompt + } + aria-label="Continuation message" + /> + } + />