fix(server): resume with last live mode, not creation-time mode (#5140)

* fix(server): resume with last live mode, not creation-time mode

Provider-initiated mode switches (ACP current_mode_update, in-session
commands like /bypass, permission-driven plan->acceptEdits transitions)
update currentModeId/lastModeId but never reach config.modeId. On
resume, buildConfigOverrides restored the stale creation-time mode and
applyConfiguredOverrides pushed it back via set_mode — silently
downgrading the session (e.g. bypass -> acceptEdits after restart).

- buildConfigOverrides + buildStoredAgentConfig prefer lastModeId
  (the last observed live mode) over config.modeId
- applyObservedMode syncs config.modeId on every provider-reported
  mode change (mode_changed, model_changed runtimeInfo, and the
  refreshSessionState poll) so in-memory reloadAgentSession carries
  the live mode as well

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(server): assert config.modeId sync on mode_changed/model_changed events

Extends the config-drift stream test with a registry so both the
in-memory agent.config.modeId and the persisted record.config.modeId
are verified — covering the event paths Greptile flagged as untested.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Petr Plenkov
2026-10-04 16:12:33 +02:00
committed by GitHub
co-authored by Devin
parent 30fb66c8cb
commit 5c2c85ebc8
4 changed files with 66 additions and 7 deletions
@@ -5034,6 +5034,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous
test("session config drift events update state through the stream channel", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
class ConfigEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
@@ -5046,6 +5047,7 @@ test("session config drift events update state through the stream channel", asyn
clients: {
codex: new ConfigEventClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000133",
});
@@ -5100,6 +5102,7 @@ test("session config drift events update state through the stream channel", asyn
const agent = manager.getAgent(snapshot.id);
expect(agent?.currentModeId).toBe("build");
expect(agent?.config.modeId).toBe("build");
expect(agent?.config.thinkingOptionId).toBe("high");
expect(agent?.availableModes).toEqual([
{ id: "plan", label: "Plan" },
@@ -5111,6 +5114,10 @@ test("session config drift events update state through the stream channel", asyn
thinkingOptionId: "high",
});
expect(streams.map((event) => event.type)).toEqual([]);
const persisted = await storage.get(snapshot.id);
expect(persisted?.lastModeId).toBe("build");
expect(persisted?.config?.modeId).toBe("build");
});
test("setLabels merges and persists labels", async () => {
@@ -9287,6 +9294,7 @@ test("respondToPermission updates currentModeId after plan approval", async () =
class PlanModeTestClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
resumedModeId: string | null | undefined;
async isAvailable(): Promise<boolean> {
return true;
@@ -9296,14 +9304,19 @@ test("respondToPermission updates currentModeId after plan approval", async () =
return new PlanModeTestSession();
}
async resumeSession(): Promise<AgentSession> {
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumedModeId = overrides?.modeId;
return new PlanModeTestSession();
}
}
const client = new PlanModeTestClient();
const manager = new AgentManager({
clients: {
codex: new PlanModeTestClient(),
codex: client,
},
registry: storage,
logger,
@@ -9347,6 +9360,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
await manager.flush();
const persisted = await storage.get(snapshot.id);
expect(persisted?.lastModeId).toBe("acceptEdits");
// The provider-managed switch must also reach config.modeId — otherwise a
// reload resumes the session with the stale creation-time mode ("plan").
expect(persisted?.config?.modeId).toBe("acceptEdits");
const reloaded = await manager.reloadAgentSession(snapshot.id);
expect(reloaded.config.modeId).toBe("acceptEdits");
expect(client.resumedModeId).toBe("acceptEdits");
});
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
@@ -190,10 +190,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
provider: record.provider,
cwd: record.cwd,
};
// lastModeId is the last live mode — it also covers provider-side switches
// that never reach record.config.modeId.
const modeId = record.lastModeId ?? record.config?.modeId;
if (modeId != null) config.modeId = modeId;
if (!record.config) {
return config;
}
if (record.config.modeId != null) config.modeId = record.config.modeId;
if (record.config.model != null) config.model = record.config.model;
if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId;
@@ -3914,6 +3917,19 @@ export class AgentManager {
return this.registry;
}
/**
* Provider-side mode switches (ACP current_mode_update, in-session
* commands, permission-driven transitions) must land in config.modeId too —
* reloadAgentSession and the persisted record derive the resumed session's
* mode from it, so leaving it stale silently downgrades the mode on resume.
*/
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
agent.currentModeId = modeId;
if (modeId != null) {
agent.config.modeId = modeId;
}
}
private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
@@ -3926,7 +3942,7 @@ export class AgentManager {
}
try {
agent.currentModeId = await agent.session.getCurrentMode();
this.applyObservedMode(agent, await agent.session.getCurrentMode());
} catch {
agent.currentModeId = null;
}
@@ -4306,7 +4322,7 @@ export class AgentManager {
this.emitState(agent);
return undefined;
case "mode_changed":
agent.currentModeId = event.currentModeId;
this.applyObservedMode(agent, event.currentModeId);
agent.availableModes = event.availableModes;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
@@ -4322,7 +4338,7 @@ export class AgentManager {
agent.cwd,
);
}
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
@@ -27,9 +27,28 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
}
describe("persistence hooks", () => {
test("buildConfigOverrides prefers the last live mode over the creation-time mode", () => {
const record = createRecord({
lastModeId: "bypass",
config: { modeId: "acceptEdits" },
});
expect(buildConfigOverrides(record).modeId).toBe("bypass");
});
test("buildConfigOverrides falls back to the configured mode when no live mode was recorded", () => {
const record = createRecord({
lastModeId: null,
config: { modeId: "acceptEdits" },
});
expect(buildConfigOverrides(record).modeId).toBe("acceptEdits");
});
test("buildConfigOverrides preserves the complete private launch config", () => {
const record = createRecord({
title: "Voice agent (current)",
lastModeId: "default",
config: {
modeId: "default",
model: "gpt-5.4-mini",
@@ -79,6 +98,7 @@ describe("persistence hooks", () => {
const record = createRecord({
provider: "codex",
title: "Renamed title",
lastModeId: null,
config: {
model: "gpt-5.4-mini",
systemPrompt: "Confirm and speak first.",
@@ -67,7 +67,10 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
return stripInternalPaseoMcpServer({
provider: record.provider,
cwd: record.cwd,
modeId: record.config?.modeId ?? undefined,
// lastModeId is the last live mode — it also covers provider-side switches
// (ACP current_mode_update, in-session commands) that never reach
// record.config.modeId.
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,