Run option, model, thinking and mode changes in the agent's lifecycle lane

The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.
This commit is contained in:
Mohamed Boudra
2026-11-08 12:41:49 +01:00
parent f946c0a415
commit 22fadbb217
10 changed files with 126 additions and 239 deletions
@@ -13449,6 +13449,7 @@ describe("agent options", () => {
class OptionsSession extends TestAgentSession {
readonly applied: ProviderOptions[] = [];
rejectWith: Error | null = null;
/** Holds the next apply only; the one after it is answered at once. */
applyGate: Promise<void> | null = null;
/** What the session says it ended up with after the next apply. */
ended: Record<string, unknown> = {};
@@ -13456,7 +13457,9 @@ describe("agent options", () => {
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
if (this.rejectWith) throw this.rejectWith;
await this.applyGate;
const gate = this.applyGate;
this.applyGate = null;
await gate;
this.applied.push(options);
return this.ended;
}
@@ -13681,7 +13684,7 @@ describe("agent options", () => {
});
});
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
test("overlapping patches reach the provider one after the other, and both land", async () => {
const { manager, client, agentId } = await createOptionsAgent();
let release!: () => void;
client.session!.applyGate = new Promise<void>((resolve) => {
@@ -13689,11 +13692,14 @@ describe("agent options", () => {
});
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
client.session!.applyGate = null;
await manager.setAgentOptions(agentId, { web_search: true });
const next = manager.setAgentOptions(agentId, { web_search: true });
await new Promise<void>((resolve) => setTimeout(resolve, 20));
// The second patch waits for the first to be answered: the provider never sees two at once.
expect(client.session?.applied).toEqual([]);
release();
await slow;
await Promise.all([slow, next]);
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }, { web_search: true }]);
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
sandbox_mode: "read-only",
web_search: true,
+106 -91
View File
@@ -2363,111 +2363,123 @@ export class AgentManager {
});
}
// Mode, model, thinking and options change inside the agent's lifecycle lane, like every other
// mutation of one agent. A provider session therefore never has two of `setMode`, `setModel`,
// `setThinkingOption` and `applyOptions` in flight at once, and none of them overlaps an open,
// reload or close; providers do no sequencing of their own.
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = await this.ensureInteractiveSession(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
});
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
});
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
});
}
/**
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
* one step once the provider accepts, over the options as they are at that moment, so concurrent
* writes to one agent resolve by arrival order.
* one step once the provider accepts. Patches to one agent run one at a time, in arrival order.
*/
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const patch = this.convertLegacyOptions(agent.provider, requested);
let ended: ProviderOptionsPatch = {};
let sent: ProviderOptionsPatch = {};
if (Object.keys(patch).length > 0) {
// A provider that cannot apply options to a running agent must not store them either.
if (!agent.session.applyOptions) {
throw new ProviderOptionsValidationError(
agent.provider,
Object.keys(patch).map((key) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
})),
);
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const patch = this.convertLegacyOptions(agent.provider, requested);
let ended: ProviderOptionsPatch = {};
let sent: ProviderOptionsPatch = {};
if (Object.keys(patch).length > 0) {
// A provider that cannot apply options to a running agent must not store them either.
if (!agent.session.applyOptions) {
throw new ProviderOptionsValidationError(
agent.provider,
Object.keys(patch).map((key) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
})),
);
}
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
const resolved = this.effectiveOptionsFor(agent.provider, set);
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
ended = (await agent.session.applyOptions(sent)) ?? {};
// Changes the provider made on its own while applying are older than its final answer.
await this.drainSessionEvents(agentId);
}
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
const resolved = this.effectiveOptionsFor(agent.provider, set);
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
ended = (await agent.session.applyOptions(sent)) ?? {};
// Changes the provider made on its own while applying are older than its final answer.
await this.drainSessionEvents(agentId);
}
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
this.updateEffectiveOptions({ agent, sent, reported: ended });
this.touchUpdatedAt(agent);
this.emitState(agent);
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
this.updateEffectiveOptions({ agent, sent, reported: ended });
this.touchUpdatedAt(agent);
this.emitState(agent);
});
}
/** The config as the opened session left it: its own difference from the options it was given. */
@@ -3061,22 +3073,25 @@ export class AgentManager {
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
const current = this.requireSessionAgent(agentId);
if (this.historySessions.has(current.session)) {
// Recheck inside the lane: another prompt may already have upgraded it.
// Reload validates the directory before releasing readable history.
await this.reloadAgentSessionInternal(agentId);
}
}),
await this.runLifecycleMutation(agentId, () =>
this.ensureInteractiveSessionUnlocked(agentId),
);
}
return this.requireSessionAgent(agentId);
}
/** Inside the agent's lane: a history session is upgraded before anything is applied to it. */
private async ensureInteractiveSessionUnlocked(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
// Reload validates the directory before releasing readable history.
await this.trackAgentRegistrationOperation(this.reloadAgentSessionInternal(agentId));
}
return this.requireSessionAgent(agentId);
}
private async *streamAfterHistoryLoad(
agentId: string,
prompt: AgentPromptInput,
@@ -58,11 +58,6 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
/** Long enough for a request that is free to run to have run. */
function pause(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
function controlKey(control: { key: string }): string {
return control.key;
}
@@ -1157,10 +1152,7 @@ describe("PluginAgentClientRegistry", () => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
{
refuseAfterChanging = false,
holdLoudChange,
}: { refuseAfterChanging?: boolean; holdLoudChange?: Promise<void> } = {},
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
) {
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
voice: "direct",
@@ -1259,16 +1251,6 @@ describe("PluginAgentClientRegistry", () => {
// Going loud returns an unrequested token budget to its default, by leaving it out.
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
emitConfig(emit, input.sessionId);
// A change that has taken effect but whose request is still pending.
if (typeof loud === "boolean" && holdLoudChange) {
await holdLoudChange;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "no" },
});
return true;
}
emit(
refuseAfterChanging
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
@@ -1358,32 +1340,6 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test("overlapping applies resolve one at a time: a request that fails after its change took still reports it", async () => {
let release: () => void = () => undefined;
const holdLoudChange = new Promise<void>((resolve) => {
release = resolve;
});
const { harness, session, events } = await openStubbornVoiceSession({}, { holdLoudChange });
const first = session.applyOptions?.({ loud: true });
const firstFailed = expect(first).rejects.toThrow("no");
await pause();
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { loud: true } },
});
const second = session.applyOptions?.({ voice: "other" });
// The second request does not start until the first has ended, so it cannot settle first.
await pause();
release();
await firstFailed;
await expect(second).resolves.toEqual({ voice: "direct" });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
]);
await session.close();
});
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
@@ -1211,8 +1211,6 @@ class PluginAgentSession implements AgentSession {
private readonly committedOptions: Record<string, JsonValue>;
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
/** One apply at a time: each reconciles against the settings the one before it left. */
private applying: Promise<unknown> = Promise.resolve();
private currentTurnId: string | null = null;
private closed = false;
private replay: {
@@ -1422,13 +1420,8 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, JsonValue>> {
/** The agent manager sends one apply at a time per session. */
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
this.assertReplayComplete();
const before = this.currentOptions();
const { legacySettings } = readPluginOptionState(this.bridge.config);
@@ -1965,19 +1965,6 @@ describe("ACPAgentSession options against an ACP agent", () => {
]);
});
test("overlapping applies that both fail still report what the agent moved", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
const first = session.applyOptions({ agent: "Probe Agent" });
const second = session.applyOptions({ agent: null });
await expect(first).rejects.toThrow(/write refused/);
await expect(second).rejects.toThrow(/write refused/);
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]);
});
test("a removal writes the select's empty choice and returns it, since the options hold what the session runs", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
@@ -1776,7 +1776,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
openOptionsPatch: Record<string, unknown> = {};
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
private optionsTransactionDepth = 0;
private applying: Promise<unknown> = Promise.resolve();
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2449,15 +2448,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
* Applies only the options the patch sets; returns the selects that ended up different.
* Provider notifications that arrive meanwhile are part of that result. When a write is
* rejected, what the provider moved is reported as an event, since nothing is stored.
* The agent manager sends one apply at a time per session.
*/
/** One apply at a time: each reconciles against the state the one before it left. */
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, unknown>> {
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: this.config.provider,
patch: options,
@@ -2888,13 +2888,8 @@ class ClaudeAgentSession implements AgentSession {
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
this.cachedRuntimeInfo = null;
}
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
// this call's keys are applied, over the options as they are now: another patch may have
// landed while the runtime was answering.
this.config.providerOptions = applyProviderOptionsPatch({
options: this.config.providerOptions,
patch: options,
});
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = providerOptions;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -88,47 +88,6 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
test("overlapping auto_accept applies do not undo each other when one fails", async () => {
const harness = new V2Harness();
const permissions = [
{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] },
];
harness.api.permission.list = async () => permissions;
let failFirst!: () => void;
let calls = 0;
harness.api.permission.reply = async () => {
calls += 1;
if (calls > 1) return;
await new Promise<void>((_, reject) => {
failFirst = () => reject(new Error("upstream refused"));
});
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const first = session.applyOptions?.({ auto_accept: true });
const second = session.applyOptions?.({ auto_accept: true });
await expect.poll(() => calls).toBe(1);
failFirst();
await expect(first).rejects.toThrow("upstream refused");
await expect(second).resolves.toBeUndefined();
// The accepted second apply is in force: the next permission is approved, not asked.
const next = { id: "next", sessionID: "session", action: "shell", resources: ["ls"] };
permissions.push(next);
harness.push({ id: "asked", created: 2, type: "permission.asked", data: next });
await expect.poll(() => calls).toBeGreaterThan(2);
const pendingIds = () => session.getPendingPermissions().map(({ id }) => id);
expect(pendingIds()).not.toContain("next");
} finally {
await session.close();
}
});
test("rejects a live permission change the running session cannot apply", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
@@ -386,14 +386,8 @@ export class OpenCodeV2Session implements AgentSession {
this.info = await this.client.session.get({ sessionID: this.id });
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
/** Overlapping applies run one after another, so a failed one restores exactly what it changed. */
private applying: Promise<unknown> = Promise.resolve();
applyOptions(options: ProviderOptions): Promise<void> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(options: ProviderOptions): Promise<void> {
/** The agent manager sends one apply at a time, so a failed one restores exactly what it changed. */
async applyOptions(options: ProviderOptions): Promise<void> {
assertChangesAreLive({
provider: "opencode",
patch: options,
@@ -3,8 +3,6 @@
// answers: { [configId]: value } the agent ends up with instead of the requested value
// onWrite: "notify-first" sends config_option_update before answering the write;
// "notify-then-reject" does the same and then fails the write
// "slow-reject" moves the select and announces it for a non-empty value, then refuses
// after a delay; an empty value is refused without moving anything
// moves: { [configId]: { [id]: value } } writes that also move another select, announced
// by a config_option_update after the write is answered
// adds: { [configId]: option[] } writes that make more selects appear, announced the same way
@@ -30,21 +28,12 @@ export const connection = new AgentSideConnection(
return { sessionId: "session-1", modes: null, models: null, configOptions: options };
},
async setSessionConfigOption({ sessionId, configId, value }) {
if (scenario.onWrite === "slow-reject" && !value) {
await new Promise((resolve) => setTimeout(resolve, 150));
throw new Error("write refused");
}
set(configId, scenario.answers?.[configId] ?? value);
const announce = () =>
agentConnection.sessionUpdate({
sessionId,
update: { sessionUpdate: "config_option_update", configOptions: options },
});
if (scenario.onWrite === "slow-reject") {
await announce();
await new Promise((resolve) => setTimeout(resolve, 100));
throw new Error("write refused");
}
if (scenario.onWrite === "notify-first" || scenario.onWrite === "notify-then-reject") {
await announce();
if (scenario.onWrite === "notify-then-reject") throw new Error("write refused");