From c2fa79dd2c9e7c1bb36a461829fd39c44153b534 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 9 Oct 2026 18:35:28 +0100 Subject: [PATCH] fix: resuming a usage-limited thread uses the newly picked model Co-Authored-By: Claude Opus 5.5 --- .../threads/UsageLimitRecoveryCard.tsx | 8 +- .../src/orchestration-v2/Orchestrator.ts | 3 + .../src/orchestration-v2/runtimeLayer.test.ts | 100 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 6 ++ docs/fork-differences.md | 3 +- docs/user/providers-codex.md | 5 +- .../client-runtime/src/operations/commands.ts | 3 + packages/contracts/src/orchestrationV2.ts | 4 +- 8 files changed, 127 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 3649fab24b82b..9692115db7215 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -54,7 +54,13 @@ export function UsageLimitRecoveryCard({ try { const result = await resumeUsageLimited({ environmentId, - input: { threadId: thread.id, runId, creationSource: "mobile" }, + // The thread carries the composer's model, so a switch away from the limited provider sticks. + input: { + threadId: thread.id, + runId, + modelSelection: thread.modelSelection, + creationSource: "mobile", + }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); } catch (cause) { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 826131cc98a62..bc9bd824943c5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10706,6 +10706,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio manualContinuationOfRunId: command.runId, text: USAGE_LIMIT_CONTINUATION_TEXT, attachments: [], + ...(command.modelSelection === undefined + ? {} + : { modelSelection: command.modelSelection }), dispatchMode: { type: "start_immediately" }, createdBy: command.createdBy, creationSource: command.creationSource, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index f283fa11f94e4..fa26176a97f64 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5220,6 +5220,106 @@ it.layer(layerTest)("usage-limit recovery", (it) => { }), ); + it.effect("resumes a usage-limited run now on the model picked since the limit", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const events = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("resume-now-switch:thread"); + const projectId = ProjectId.make("resume-now-switch:project"); + const now = yield* DateTime.now; + yield* seedProject({ + projectId, + title: "Resume now switch project", + workspaceRoot: process.cwd(), + defaultModelSelection: modelSelection, + createdAt: DateTime.formatIso(now), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("resume-now-switch:create"), + threadId, + projectId, + title: "Limited thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("resume-now-switch:message"), + threadId, + messageId: MessageId.make("resume-now-switch:message"), + text: "Work on this.", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* events.write({ + commandId: CommandId.make("resume-now-switch:failure"), + events: [ + { + id: EventId.make("resume-now-switch:run"), + type: "run.updated", + threadId, + occurredAt: now, + payload: { ...run, status: "failed", completedAt: now }, + }, + { + id: EventId.make("resume-now-switch:error"), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make("resume-now-switch:error"), + type: "error", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Plan limit reached.", + code: "usageLimitExceeded", + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }, + }, + }, + ], + }); + + const switched = { instanceId: alternateInstanceId, model: "gpt-5.5" } as const; + yield* orchestrator.dispatch({ + type: "thread.usage-limit.resume-now", + commandId: CommandId.make("resume-now-switch:resume"), + threadId, + runId: run.id, + modelSelection: switched, + createdBy: "user", + creationSource: "web", + }); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(after.runs, 2); + assert.equal(after.runs[1]?.providerInstanceId, alternateInstanceId); + assert.deepEqual(after.runs[1]?.modelSelection, switched); + }), + ); + it.effect.each([ "resume", "queued-resume", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a3b11baf46549..84985b044b75a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8132,11 +8132,14 @@ export default function ChatView(props: ChatViewProps) { onResumeNow: serverConfig?.environment.capabilities.usageLimitResumeNow === true ? async () => { + const modelSelection = + composerRef.current?.getSendContext()?.selectedModelSelection; const result = await resumeUsageLimitedThread({ environmentId, input: { threadId: activeThreadShell.id, runId: activeThreadShell.latestRun!.runId, + ...(modelSelection === undefined ? {} : { modelSelection }), }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); @@ -9281,6 +9284,7 @@ export default function ChatView(props: ChatViewProps) { return resumeThreadQueue({ environmentId, input: { threadId } }); } const createdAt = new Date().toISOString(); + const modelSelection = composerRef.current?.getSendContext()?.selectedModelSelection; const settingsResult = await persistThreadSettingsForNextTurn({ threadId, createdAt, @@ -9302,6 +9306,8 @@ export default function ChatView(props: ChatViewProps) { text: "Continue where you left off.", attachments: [], }, + // A model picked after the limit continues there, not on the limited provider. + ...(modelSelection === undefined ? {} : { modelSelection }), runtimeMode, interactionMode, dispatchMode: "start", diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 37f27751adf6e..e9f75ba6e73e7 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -1327,7 +1327,8 @@ Upstream shows a recovery banner when Codex or Claude ends a turn on a usage lim reset**, **Cancel auto-resume**, and **Snooze until reset**. The fork adds: - **Resume now**, on web, desktop, and mobile, which sends the same "Continue where you left off." - continuation into the same session at once. It is the `thread.usage-limit.resume-now` command, so + continuation at once, on the composer's model, so picking another provider first continues there + instead of into the same limit. It is the `thread.usage-limit.resume-now` command, so the server owns the message and any client or future agent tool can send it. Servers advertise it with the `usageLimitResumeNow` capability; clients hide the button without it. - **Continue in new tab** on web and desktop, which forks the chat onto another ready account or diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 7b385695ddeaf..35c288008e67e 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -109,8 +109,9 @@ The thread keeps its work and offers a way to pick it back up: reset, even if no app is open. **Cancel auto-resume** turns it off again. It appears only when Codex reported a reset time. **Snooze until reset** hides the thread until then. -- **Resume now** continues the same session straight away, for example after - your workspace added credits. +- **Resume now** continues straight away, for example after your workspace + added credits. Pick another model in the composer first to continue there + instead of on the limited account. - **Continue in new tab** (web and desktop) forks the chat into a new tab on another account or model, carrying this conversation over. See [Switch accounts in an existing thread](#switch-accounts-in-an-existing-thread). diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a3f71c53ea4be..0f7362ac8a84b 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -124,6 +124,8 @@ export type MarkThreadUnreadInput = ThreadCommandInput; export interface ResumeUsageLimitedThreadInput extends ThreadCommandInput { /** The run stopped on the usage limit. */ readonly runId: RunId; + /** The composer's model, when the user switched away from the limited one. */ + readonly modelSelection?: ModelSelection; } export interface UpdateThreadMetadataInput extends ThreadCommandInput { @@ -606,6 +608,7 @@ export const resumeUsageLimitedThread = Effect.fn("EnvironmentCommands.resumeUsa commandId: yield* allocateCommandId(input), threadId: input.threadId, runId: input.runId, + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), createdBy: "user", creationSource: input.creationSource ?? "web", }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 550a903d1123d..0b9ca01d95c22 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2850,7 +2850,8 @@ export const OrchestrationV2Command = Schema.Union([ /** * Sends the usage-limit continuation into the same session now, without * waiting for the reset. Rejected unless `runId` is still the run stopped on - * the usage limit. + * the usage limit. `modelSelection` continues on the model the user picked + * since, so the continuation can leave the limited provider. */ Schema.Struct({ type: Schema.Literal("thread.usage-limit.resume-now"), @@ -2858,6 +2859,7 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, runId: RunId, + modelSelection: Schema.optional(ModelSelection), }), Schema.Struct({ type: Schema.Literal("thread.pull-request.link"),