Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/user/providers-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
});
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2850,14 +2850,16 @@ 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"),
...OrchestrationV2CreationFields,
commandId: CommandId,
threadId: ThreadId,
runId: RunId,
modelSelection: Schema.optional(ModelSelection),
}),
Schema.Struct({
type: Schema.Literal("thread.pull-request.link"),
Expand Down