mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed
Draft controls and commands convert the config they hand to the provider, so inspecting a legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the session opened with.
This commit is contained in:
@@ -1258,7 +1258,7 @@ export class AgentManager {
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const options = this.effectiveOptionsFor(
|
||||
normalizedConfig.provider,
|
||||
this.convertLegacyOptions(normalizedConfig.provider, config.providerOptions),
|
||||
normalizedConfig.providerOptions,
|
||||
);
|
||||
if (!normalizedConfig.model && !client.listControls) {
|
||||
return { controls: [], options };
|
||||
@@ -5617,7 +5617,7 @@ export class AgentManager {
|
||||
config: AgentSessionConfig,
|
||||
options: NormalizeConfigOptions = {},
|
||||
): Promise<AgentSessionConfig> {
|
||||
const normalized: AgentSessionConfig = { ...config };
|
||||
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) };
|
||||
|
||||
// Always resolve cwd to absolute path for consistent history file lookup
|
||||
if (normalized.cwd) {
|
||||
@@ -5722,13 +5722,10 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(
|
||||
this.withConvertedOptions(stripInternalPaseoMcpServer(config)),
|
||||
{
|
||||
env: options.env,
|
||||
purpose: options.purpose,
|
||||
},
|
||||
);
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
|
||||
env: options.env,
|
||||
purpose: options.purpose,
|
||||
});
|
||||
const paseoToolPolicy = this.paseoToolsEnabled
|
||||
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
||||
: { enabled: false };
|
||||
|
||||
@@ -152,7 +152,10 @@ export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
|
||||
source: "stored" | "client write";
|
||||
}): Omit<T, "featureValues"> {
|
||||
const { profile, stored, source } = input;
|
||||
const { featureValues, ...rest } = profile;
|
||||
const { featureValues: legacyValues, ...rest } = profile;
|
||||
// The app reads a profile's speed from its controls, so the old key is converted here for the
|
||||
// provider id the profile names; a profile on a custom provider is converted when it is applied.
|
||||
const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues });
|
||||
if (source === "stored") {
|
||||
return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest;
|
||||
}
|
||||
|
||||
@@ -873,6 +873,23 @@ describe("Codex app-server provider controls", () => {
|
||||
await runTurn(agentId, "default");
|
||||
});
|
||||
|
||||
test.each([CODEX_PROVIDER, "my-codex"])(
|
||||
"draft controls for %s accept a legacy fast_mode",
|
||||
async (providerId) => {
|
||||
const { manager } = createLegacyFastHarness({}, providerId);
|
||||
|
||||
const draft = await manager.listDraftControls({
|
||||
provider: providerId,
|
||||
cwd: tmpdir(),
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(draft.options).toEqual({ service_tier: "fast" });
|
||||
expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" }));
|
||||
},
|
||||
);
|
||||
|
||||
test("an older client turning fast_mode off selects Normal", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create({ service_tier: "priority" });
|
||||
|
||||
@@ -767,6 +767,19 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("a replacement runtime starts with the Fast the session opened with, not the one it was asked for", async () => {
|
||||
const omp = new OmpHarness();
|
||||
// The fake runtime answers the open-time Fast request with Fast off.
|
||||
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
|
||||
expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false });
|
||||
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||
|
||||
await omp.setMode("ask");
|
||||
|
||||
expect(omp.runtimeLaunches()).toHaveLength(2);
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("restores Fast from the initial OMP state on create and resume", async () => {
|
||||
const created = new OmpHarness();
|
||||
await created.start({ providerOptions: { fast_mode: true } });
|
||||
|
||||
@@ -755,6 +755,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.config = options.config;
|
||||
this.state = options.initialState;
|
||||
this.openOptionsPatch = this.fastModeDifference(options.config.providerOptions?.fast_mode);
|
||||
// A replacement runtime starts from the Fast this one opened with, not the one it was asked for.
|
||||
if (typeof this.openOptionsPatch.fast_mode === "boolean")
|
||||
this.rememberFastMode(this.openOptionsPatch.fast_mode);
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.logger = options.logger;
|
||||
this.live = options.live ?? true;
|
||||
|
||||
@@ -88,6 +88,47 @@ 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({
|
||||
|
||||
@@ -362,7 +362,14 @@ 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 });
|
||||
}
|
||||
async applyOptions(options: ProviderOptions) {
|
||||
/** 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> {
|
||||
assertChangesAreLive({
|
||||
provider: "opencode",
|
||||
patch: options,
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("DaemonConfigStore", () => {
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", fast_mode: false },
|
||||
options: { sandbox_mode: "read-only", plan_mode: false },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe("DaemonConfigStore", () => {
|
||||
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{ id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } },
|
||||
{ id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -288,7 +288,7 @@ describe("DaemonConfigStore", () => {
|
||||
id: "p",
|
||||
name: "Renamed",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", fast_mode: true },
|
||||
options: { sandbox_mode: "read-only", plan_mode: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -303,7 +303,7 @@ describe("DaemonConfigStore", () => {
|
||||
id: "p",
|
||||
name: "Renamed",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", fast_mode: false },
|
||||
options: { sandbox_mode: "read-only", plan_mode: false },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -340,7 +340,7 @@ describe("DaemonConfigStore", () => {
|
||||
name: "P",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "workspace-write" },
|
||||
featureValues: { fast_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -350,7 +350,26 @@ describe("DaemonConfigStore", () => {
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "workspace-write", fast_mode: true },
|
||||
options: { sandbox_mode: "workspace-write", plan_mode: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts a Codex profile's legacy fast_mode to service_tier", async () => {
|
||||
const store = storeWithProfile();
|
||||
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -360,7 +379,7 @@ describe("DaemonConfigStore", () => {
|
||||
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } },
|
||||
{ id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user