mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user