diff --git a/apps/web/src/components/chat/UsageLimitResumeBanner.tsx b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
new file mode 100644
index 000000000000..376dceae68cf
--- /dev/null
+++ b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
@@ -0,0 +1,56 @@
+import type { ClientSettings, EnvironmentId, ThreadId, ThreadUsageLimit } from "@t3tools/contracts";
+import { AlarmClockIcon } from "lucide-react";
+import { memo } from "react";
+
+import { useClientSettings } from "../../hooks/useSettings";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { formatUpcomingTimestamp } from "../../timestampFormat";
+import { Alert, AlertAction, AlertDescription } from "../ui/alert";
+import { Button } from "../ui/button";
+
+const selectTimestampFormat = (settings: ClientSettings) => settings.timestampFormat;
+
+/** Offers, or shows, the server-side resume of a thread stopped by a usage limit. */
+export const UsageLimitResumeBanner = memo(function UsageLimitResumeBanner({
+ environmentId,
+ threadId,
+ usageLimit,
+}: {
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+ usageLimit: ThreadUsageLimit | null | undefined;
+}) {
+ const timestampFormat = useClientSettings(selectTimestampFormat);
+ const setAutoResume = useAtomCommand(threadEnvironment.setAutoResume);
+ if (!usageLimit?.resetsAt) return null;
+ const upcoming = formatUpcomingTimestamp(usageLimit.resetsAt, timestampFormat);
+ const resetTime = upcoming.startsWith("tomorrow") ? upcoming : `at ${upcoming}`;
+ const scheduled = usageLimit.resumeScheduled;
+ return (
+
+
+
+
+ {scheduled
+ ? `Resumes automatically ${resetTime}.`
+ : `The usage limit resets ${resetTime}.`}
+
+
+
+
+
+
+ );
+});
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f37c9fb631c0..c5fc86d12ffd 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -605,6 +605,12 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
? ["Continue threads after restarts"]
: []),
+ ...(settings.autoResumeAfterUsageLimit !==
+ DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit ||
+ settings.autoResumeMessage !== DEFAULT_UNIFIED_SETTINGS.autoResumeMessage ||
+ settings.autoResumeDisablesFastMode !== DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode
+ ? ["Resume after usage limits"]
+ : []),
...(isBackgroundActivityDirty ? ["Background activity"] : []),
...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
? ["New thread mode"]
@@ -676,6 +682,9 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.responseStreamingMode,
settings.enableProviderUpdateChecks,
settings.continueThreadsAfterServerUpdate,
+ settings.autoResumeAfterUsageLimit,
+ settings.autoResumeMessage,
+ settings.autoResumeDisablesFastMode,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
settings.sidebarProjectGroupingMode,
@@ -782,6 +791,9 @@ export function useSettingsRestore(onRestored?: () => void) {
responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
+ autoResumeAfterUsageLimit: DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit,
+ autoResumeMessage: DEFAULT_UNIFIED_SETTINGS.autoResumeMessage,
+ autoResumeDisablesFastMode: DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
@@ -2804,6 +2816,93 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ autoResumeAfterUsageLimit: DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ autoResumeAfterUsageLimit: Boolean(checked) })
+ }
+ aria-label="Resume after usage limits"
+ />
+ }
+ />
+
+
+ updateSettings({ autoResumeMessage: DEFAULT_UNIFIED_SETTINGS.autoResumeMessage })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ autoResumeMessage: next })}
+ placeholder={DEFAULT_UNIFIED_SETTINGS.autoResumeMessage}
+ aria-label="Resume message"
+ />
+ }
+ />
+
+
+ updateSettings({
+ autoResumeDisablesFastMode: DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ autoResumeDisablesFastMode: Boolean(checked) })
+ }
+ aria-label="Turn off fast mode when resuming"
+ />
+ }
+ />
+
;
export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
export type SnoozeThreadInput = CommandInput<"thread.snooze">;
export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
+export type SetThreadAutoResumeInput = CommandInput<"thread.auto-resume.set">;
export type PinThreadInput = CommandInput<"thread.pin">;
export type UnpinThreadInput = CommandInput<"thread.unpin">;
export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
@@ -206,6 +207,16 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff
});
});
+export const setThreadAutoResume: (input: SetThreadAutoResumeInput) => CommandEffect = Effect.fn(
+ "EnvironmentCommands.setThreadAutoResume",
+)(function* (input) {
+ return yield* dispatch({
+ ...input,
+ type: "thread.auto-resume.set",
+ commandId: yield* commandId(input),
+ });
+});
+
export const pinThread: (input: PinThreadInput) => CommandEffect = Effect.fn(
"EnvironmentCommands.pinThread",
)(function* (input) {
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index 93e22cfd0c70..5fbb786a03d7 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -24,6 +24,7 @@ import {
type RespondToThreadUserInputInput,
type DismissThreadUserInputInput,
type RevertThreadCheckpointInput,
+ type SetThreadAutoResumeInput,
type SetThreadInteractionModeInput,
type SetThreadRuntimeModeInput,
type PinThreadInput,
@@ -48,6 +49,7 @@ import {
respondToThreadUserInput,
dismissThreadUserInput,
revertThreadCheckpoint,
+ setThreadAutoResume,
setThreadInteractionMode,
setThreadRuntimeMode,
pinThread,
@@ -76,6 +78,7 @@ export type {
RespondToThreadUserInputInput,
DismissThreadUserInputInput,
RevertThreadCheckpointInput,
+ SetThreadAutoResumeInput,
SetThreadInteractionModeInput,
SetThreadRuntimeModeInput,
PinThreadInput,
@@ -152,6 +155,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ setAutoResume: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:set-auto-resume",
+ execute: (input: SetThreadAutoResumeInput) => setThreadAutoResume(input),
+ scheduler,
+ concurrency,
+ }),
pin: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:pin",
execute: (input: PinThreadInput) => pinThread(input),
diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts
index 379985b71243..35b8f109197b 100644
--- a/packages/client-runtime/src/state/threadDetail.ts
+++ b/packages/client-runtime/src/state/threadDetail.ts
@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(
activeOrderKey: shell.activeOrderKey,
snoozedUntil: shell.snoozedUntil,
snoozedAt: shell.snoozedAt,
+ usageLimit: shell.usageLimit,
pinnedAt: shell.pinnedAt,
pinOrderKey: shell.pinOrderKey,
session: shell.session,
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 101bb34fba91..3d4ad2c7245d 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -133,6 +133,7 @@ export function applyThreadDetailEvent(
activeOrderKey: null,
snoozedUntil: null,
snoozedAt: null,
+ usageLimit: null,
deletedAt: null,
pullRequests: [],
messages: [],
@@ -215,6 +216,23 @@ export function applyThreadDetailEvent(
},
};
+ case "thread.usage-limit-set":
+ return {
+ kind: "updated",
+ thread: { ...thread, usageLimit: event.payload.usageLimit },
+ };
+
+ case "thread.auto-resume-set":
+ return thread.usageLimit
+ ? {
+ kind: "updated",
+ thread: {
+ ...thread,
+ usageLimit: { ...thread.usageLimit, resumeScheduled: event.payload.scheduled },
+ },
+ }
+ : { kind: "unchanged" };
+
case "thread.pinned":
return {
kind: "updated",
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 3e323e4964d5..de7322ed677a 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -790,6 +790,19 @@ export const ThreadPullRequestLink = Schema.Struct({
});
export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
+/**
+ * A provider usage limit stopped this thread's latest turn. `resetsAt` is null
+ * when the provider reported no reset time; such a stop cannot be resumed on a
+ * timer. `resumeScheduled` asks the server to send the configured resume
+ * message once `resetsAt` passes. Cleared by the next turn start.
+ */
+export const ThreadUsageLimit = Schema.Struct({
+ reachedAt: IsoDateTime,
+ resetsAt: Schema.NullOr(IsoDateTime),
+ resumeScheduled: Schema.Boolean,
+});
+export type ThreadUsageLimit = typeof ThreadUsageLimit.Type;
+
export const OrchestrationThread = Schema.Struct({
id: ThreadId,
projectId: ProjectId,
@@ -826,6 +839,8 @@ export const OrchestrationThread = Schema.Struct({
// Optional so payloads from pre-snooze servers still decode.
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ // Optional so payloads from pre-auto-resume servers still decode.
+ usageLimit: Schema.optional(Schema.NullOr(ThreadUsageLimit)),
// Active pinned threads render in the pinned block. Settled and snoozed
// threads remain in their respective shelves even when pinned.
// Optional so payloads from pre-pinning servers still decode.
@@ -905,6 +920,7 @@ export const OrchestrationThreadShell = Schema.Struct({
unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ usageLimit: Schema.optional(Schema.NullOr(ThreadUsageLimit)),
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
@@ -1191,6 +1207,13 @@ const ThreadUnsnoozeCommand = Schema.Struct({
reason: Schema.Literal("user"),
});
+const ThreadAutoResumeSetCommand = Schema.Struct({
+ type: Schema.Literal("thread.auto-resume.set"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ scheduled: Schema.Boolean,
+});
+
const ThreadPinCommand = Schema.Struct({
type: Schema.Literal("thread.pin"),
commandId: CommandId,
@@ -1423,6 +1446,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
+ ThreadAutoResumeSetCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
@@ -1456,6 +1480,7 @@ export const ClientOrchestrationCommand = Schema.Union([
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
+ ThreadAutoResumeSetCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
@@ -1643,7 +1668,19 @@ const ThreadPullRequestLinkSyncCommand = Schema.Struct({
stack: Schema.NullOr(ThreadPullRequestStack),
});
+const ThreadUsageLimitSetCommand = Schema.Struct({
+ type: Schema.Literal("thread.usage-limit.set"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ // Null records that the limit no longer blocks the thread.
+ usageLimit: Schema.NullOr(
+ Schema.Struct({ reachedAt: IsoDateTime, resetsAt: Schema.NullOr(IsoDateTime) }),
+ ),
+ createdAt: IsoDateTime,
+});
+
const InternalOrchestrationCommand = Schema.Union([
+ ThreadUsageLimitSetCommand,
ThreadAutoSettleCommand,
ThreadPullRequestSyncCommand,
ThreadPullRequestLinkSyncCommand,
@@ -1684,6 +1721,8 @@ export const OrchestrationEventType = Schema.Literals([
"thread.unsettled",
"thread.snoozed",
"thread.unsnoozed",
+ "thread.usage-limit-set",
+ "thread.auto-resume-set",
"thread.pinned",
"thread.unpinned",
"thread.pin-reordered",
@@ -1805,6 +1844,16 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
updatedAt: IsoDateTime,
});
+export const ThreadUsageLimitSetPayload = Schema.Struct({
+ threadId: ThreadId,
+ usageLimit: Schema.NullOr(ThreadUsageLimit),
+});
+
+export const ThreadAutoResumeSetPayload = Schema.Struct({
+ threadId: ThreadId,
+ scheduled: Schema.Boolean,
+});
+
export const ThreadPinnedPayload = Schema.Struct({
threadId: ThreadId,
pinnedAt: IsoDateTime,
@@ -2074,6 +2123,16 @@ export const OrchestrationEvent = Schema.Union([
type: Schema.Literal("thread.unsnoozed"),
payload: ThreadUnsnoozedPayload,
}),
+ Schema.Struct({
+ ...EventBaseFields,
+ type: Schema.Literal("thread.usage-limit-set"),
+ payload: ThreadUsageLimitSetPayload,
+ }),
+ Schema.Struct({
+ ...EventBaseFields,
+ type: Schema.Literal("thread.auto-resume-set"),
+ payload: ThreadAutoResumeSetPayload,
+ }),
Schema.Struct({
...EventBaseFields,
type: Schema.Literal("thread.pinned"),
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index 309b61935485..e86db5142ad3 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -196,6 +196,7 @@ const ConfigWarningType = Schema.Literal("config.warning");
const DeprecationNoticeType = Schema.Literal("deprecation.notice");
const FilesPersistedType = Schema.Literal("files.persisted");
const ToolDeniedType = Schema.Literal("tool.denied");
+const TurnUsageLimitedType = Schema.Literal("turn.usage-limited");
const RuntimeWarningType = Schema.Literal("runtime.warning");
const RuntimeErrorType = Schema.Literal("runtime.error");
@@ -801,6 +802,16 @@ const ToolDeniedPayload = Schema.Struct({
});
export type ToolDeniedPayload = typeof ToolDeniedPayload.Type;
+/**
+ * A provider usage limit stopped or parked the turn. Emitted alongside the
+ * adapter's own warning or failure text; `resetsAt` is omitted when the
+ * provider did not say when the limit resets.
+ */
+const TurnUsageLimitedPayload = Schema.Struct({
+ resetsAt: Schema.optional(IsoDateTime),
+});
+export type TurnUsageLimitedPayload = typeof TurnUsageLimitedPayload.Type;
+
const RuntimeWarningPayload = Schema.Struct({
message: TrimmedNonEmptyStringSchema,
detail: Schema.optional(Schema.Unknown),
@@ -1160,6 +1171,13 @@ const ProviderRuntimeToolDeniedEvent = Schema.Struct({
});
export type ProviderRuntimeToolDeniedEvent = typeof ProviderRuntimeToolDeniedEvent.Type;
+const ProviderRuntimeTurnUsageLimitedEvent = Schema.Struct({
+ ...ProviderRuntimeEventBase.fields,
+ type: TurnUsageLimitedType,
+ payload: TurnUsageLimitedPayload,
+});
+export type ProviderRuntimeTurnUsageLimitedEvent = typeof ProviderRuntimeTurnUsageLimitedEvent.Type;
+
const ProviderRuntimeWarningEvent = Schema.Struct({
...ProviderRuntimeEventBase.fields,
type: RuntimeWarningType,
@@ -1222,6 +1240,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([
ProviderRuntimeDeprecationNoticeEvent,
ProviderRuntimeFilesPersistedEvent,
ProviderRuntimeToolDeniedEvent,
+ ProviderRuntimeTurnUsageLimitedEvent,
ProviderRuntimeWarningEvent,
ProviderRuntimeErrorEvent,
]);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 65aa113228fe..b2099e22c7ea 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1107,6 +1107,14 @@ export const ServerSettings = Schema.Struct({
continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
+ /**
+ * Resume a thread stopped by a provider usage limit once the limit resets,
+ * by sending `autoResumeMessage`. Threads can also opt in one stop at a time.
+ */
+ autoResumeAfterUsageLimit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ autoResumeMessage: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("go on"))),
+ /** Send the resume without fast mode; nobody is waiting on it. */
+ autoResumeDisablesFastMode: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
/**
* Whether agents may drive the in-app preview browser. Turning this off
* withholds the MCP credential, so the `t3-code` server (and with it every
@@ -1479,6 +1487,9 @@ export const ServerSettingsPatch = Schema.Struct({
responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
+ autoResumeAfterUsageLimit: Schema.optionalKey(Schema.Boolean),
+ autoResumeMessage: Schema.optionalKey(Schema.String),
+ autoResumeDisablesFastMode: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
projectAgentBrowserAccessOverrides: Schema.optionalKey(
Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),