fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none

OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.
This commit is contained in:
Mohamed Boudra
2026-11-01 20:18:18 +01:00
parent d1a1eba23d
commit 9df005f602
4 changed files with 51 additions and 9 deletions
@@ -1014,6 +1014,25 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
});
test("opens with Fast off on a model that has none, so unrelated options still apply", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
providerOptions: { fast_mode: true },
});
expect(session.openOptionsPatch).toEqual({ fast_mode: false });
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
await session.close();
});
test("passes initial fast mode through Claude flag settings", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -2397,6 +2397,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingFreshSessionId: string | null = null;
private recentStderr = "";
private closed = false;
readonly openOptionsPatch: Record<string, unknown> = {};
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
@@ -2440,6 +2441,13 @@ class ClaudeAgentSession implements AgentSession {
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
// Fast cannot run on this model, so the session opens with it off. Off rather than removed
// keeps a `config.json` default of `fast_mode: true` from filling it back in.
if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) {
this.config.providerOptions = { ...config.providerOptions, fast_mode: false };
this.openOptionsPatch = { fast_mode: false };
}
}
get id(): string | null {
@@ -792,6 +792,22 @@ describe("OMP agent client and session", () => {
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
});
test("a Fast change the runtime reports when a turn completes is published", async () => {
const omp = new OmpHarness();
omp.acceptFastOnNextRuntime();
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
omp.runtime().state = { ...omp.runtime().state, fastModeEnabled: false, fastModeActive: false };
const { completion } = await omp.startPromptUntilProviderIdle("hello", "done", {
isStreaming: false,
isCompacting: false,
});
await completion;
await omp.requireSession().getRuntimeInfo();
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
});
test("restores Fast from the initial OMP state on create and resume", async () => {
const created = new OmpHarness();
await created.start({ providerOptions: { fast_mode: true } });
@@ -1095,7 +1095,6 @@ export class OmpAgentSession implements AgentSession {
const old = this.runtimeSession;
const oldHostTools = this.hostTools;
const previousSessionId = this.state.sessionId;
const fastModeBefore = this.state.fastModeEnabled;
const restarted = await this.restartRuntime(
this.config.internal ? null : (this.state.sessionFile ?? null),
modeId,
@@ -1111,12 +1110,11 @@ export class OmpAgentSession implements AgentSession {
this.pendingPromptResults.clear();
this.runtimeSession = next;
this.hostTools = restarted.hostTools;
this.state = state;
this.installState(state);
this.currentModeId = modeId;
this.config.modeId = modeId;
this.runtimeDead = false;
this.attachRuntime(next);
this.publishFastModeChange(fastModeBefore);
this.subagentIndex.clear(old);
await oldHostTools?.close();
await old.close().catch(() => undefined);
@@ -2250,7 +2248,7 @@ export class OmpAgentSession implements AgentSession {
}
try {
const state = await this.runtimeSession.getState();
this.state = state;
this.installState(state);
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
// An interrupt settled this turn while the state check was in flight.
return;
@@ -2267,13 +2265,14 @@ export class OmpAgentSession implements AgentSession {
}
private async refreshState(): Promise<void> {
const before = this.state.fastModeEnabled;
this.state = await this.runtimeSession.getState();
this.publishFastModeChange(before);
this.installState(await this.runtimeSession.getState());
}
// The runtime owns Fast: whatever it accepted is what the session now runs with.
private publishFastModeChange(before: boolean | undefined): void {
// The runtime owns Fast: whatever it reports is what the session now runs with, so every state
// read from the runtime is installed here and a difference is published once.
private installState(state: OmpSessionState): void {
const before = this.state.fastModeEnabled;
this.state = state;
if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") {
this.rememberFastMode(this.state.fastModeEnabled);
this.emit({