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
3 changes: 2 additions & 1 deletion apps/server/src/environmentControl/EnvironmentControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
35 changes: 30 additions & 5 deletions apps/server/src/orchestration-v2/RestartContinuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Extract<OrchestrationV2Command, { type: "message.dispatch" }>> = [];
yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe(
Expand All @@ -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)),
);

Expand Down Expand Up @@ -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");
}),
);

Expand Down
10 changes: 3 additions & 7 deletions apps/server/src/orchestration-v2/RestartContinuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CommandId,
DEFAULT_CONTINUATION_PROMPT,
MessageId,
type OrchestrationV2Command,
type OrchestrationV2LimitRecoveryUpdate,
Expand All @@ -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";
Expand All @@ -21,6 +23,7 @@ export function limitRecoveryCommand(
autoResume: boolean,
nowMs: number,
snooze = false,
prompt = DEFAULT_CONTINUATION_PROMPT,
): OrchestrationV2Command | null {
if (
thread.status !== "failed" ||
Expand Down Expand Up @@ -66,6 +69,7 @@ export function limitRecoveryCommand(
thread.latestRunId,
`limit-resume:${identity}:${recovery.requestId ?? "legacy"}`,
recovery.requestId,
prompt,
);
}

Expand All @@ -80,6 +84,7 @@ function limitSwitchCommand(
readonly commandId: CommandId;
readonly limitRecovery: OrchestrationV2LimitRecoveryUpdate;
},
prompt: string,
): OrchestrationV2Command {
const { runId, resetAt } = arm.limitRecovery;
return {
Expand All @@ -88,6 +93,7 @@ function limitSwitchCommand(
runId,
`limit-switch:${thread.id}:${runId}:${Date.parse(resetAt)}`,
arm.commandId,
accountSwitchContinuation(prompt),
),
modelSelection: thread.modelSelection,
};
Expand All @@ -98,6 +104,7 @@ const continuation = (
runId: RunId,
id: string,
requestId: CommandId | undefined,
text: string,
) =>
({
type: "message.dispatch",
Expand All @@ -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",
Expand Down Expand Up @@ -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* () {
Expand All @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/orchestration-v2/accountSwitchContinuation.ts
Original file line number Diff line number Diff line change
@@ -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.`;
13 changes: 9 additions & 4 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand Down Expand Up @@ -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.",
);
}),
);

Expand All @@ -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.");
}),
);
});
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/provider/ProviderAccountSwitch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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]),
[
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/ProviderAccountSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -2320,6 +2325,40 @@ export function GeneralSettingsPanel() {
}
/>
) : null}
<SettingsRow
serverScoped
{...searchableSetting("continuation-prompt")}
description="Sent when T3 Code continues a turn on its own, after a restart or a usage limit. Start it with a skill, such as /review, to run that skill on every continuation."
settingKeys={["continuationPrompt"]}
resetAction={
settings.continuationPrompt !== DEFAULT_UNIFIED_SETTINGS.continuationPrompt ? (
<SettingResetButton
label="continuation message"
onClick={() =>
updateSettings({
continuationPrompt: DEFAULT_UNIFIED_SETTINGS.continuationPrompt,
})
}
/>
) : null
}
control={
<DraftInput
size="sm"
className="w-full sm:w-72"
value={mixedContinuationPrompt ? "" : settings.continuationPrompt}
onCommit={(next) =>
updateSettings({
continuationPrompt: next.trim() || DEFAULT_UNIFIED_SETTINGS.continuationPrompt,
})
}
placeholder={
mixedContinuationPrompt ? "Mixed" : DEFAULT_UNIFIED_SETTINGS.continuationPrompt
}
aria-label="Continuation message"
/>
}
/>

<SettingsRow
{...searchableSetting("working-shelf")}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["usage quota rate limit rotate account cloud machine continue"],
environmentControlOnly: true,
},
{
id: "continuation-prompt",
title: "Continuation message",
to: "/settings/general",
searchTerms: ["continue resume prompt restart usage limit account switch skill slash command"],
},
{
id: "working-shelf",
title: "Working section (beta)",
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/sharedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("pickSharedServerSettings", () => {
Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
).toEqual([
"autoResumeLimitedThreads",
"continuationPrompt",
"continueThreadsAfterServerUpdate",
"newWorktreesStartFromOrigin",
"sidebarAutoSettleAfterDays",
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/sharedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const SHARED_SERVER_SETTING_KEYS = [
"sidebarAutoSettleOnMerge",
"autoResumeLimitedThreads",
"snoozeLimitedThreads",
"continuationPrompt",
"newWorktreesStartFromOrigin",
"sourceControlWritingStyle",
"textGenerationModelSelection",
Expand Down
Loading
Loading