mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on
Plugin sessions let two applyOptions overlap. The second finished and re-recorded the settings the plugin held, which included a change the first had already made and not yet reported; when the first then failed, nothing was left to report and the stored options kept the old value while the plugin ran the new one. Applies now queue on the session, the way the ACP adapter's do, so each reconciles against what the one before left. OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the session never said so, so the options the manager keeps did not hold Auto Accept and a reopen turned it off. The session now reports it through openOptionsPatch on create and resume.
This commit is contained in:
@@ -58,6 +58,11 @@ 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 lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||
return inputs.findLast((input) => input.type === "session.configure");
|
||||
}
|
||||
@@ -1008,7 +1013,10 @@ 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 }: { refuseAfterChanging?: boolean } = {},
|
||||
{
|
||||
refuseAfterChanging = false,
|
||||
holdLoudChange,
|
||||
}: { refuseAfterChanging?: boolean; holdLoudChange?: Promise<void> } = {},
|
||||
) {
|
||||
const state = { voice: "direct", loud: false };
|
||||
let openedSessionId = "";
|
||||
@@ -1059,6 +1067,16 @@ describe("PluginAgentClientRegistry", () => {
|
||||
const loud = input.changes.settings?.loud;
|
||||
if (typeof loud === "boolean") state.loud = loud;
|
||||
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" } }
|
||||
@@ -1107,6 +1125,32 @@ 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: { settings: { 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 });
|
||||
|
||||
|
||||
@@ -1209,6 +1209,8 @@ class PluginAgentSession implements AgentSession {
|
||||
private knownSettings: 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: {
|
||||
@@ -1435,7 +1437,13 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
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>> {
|
||||
this.assertReplayComplete();
|
||||
const before = this.currentSettings();
|
||||
// A key the plugin does not report as a setting is one it only reads when the session opens;
|
||||
|
||||
@@ -152,6 +152,48 @@ describe("OpenCode auto_accept feature", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("reports the Auto Accept that legacy full-access turned on, when it opens or resumes", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
const created = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
});
|
||||
expect(created.openOptionsPatch).toEqual({ auto_accept: true });
|
||||
await created.close();
|
||||
|
||||
const resumed = await client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "session-1" },
|
||||
{ cwd: "/tmp/project", modeId: "full-access" },
|
||||
);
|
||||
expect(resumed.openOptionsPatch).toEqual({ auto_accept: true });
|
||||
await resumed.close();
|
||||
});
|
||||
|
||||
test("reports nothing when Auto Accept was already on", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("resolves legacy full-access for provider-driven child creation", () => {
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
|
||||
@@ -237,6 +237,16 @@ function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
/** What opening changed in the options the caller gave: legacy `full-access` turns Auto Accept on. */
|
||||
function openCodeOpenOptionsPatch(
|
||||
given: AgentSessionConfig,
|
||||
opened: OpenCodeAgentConfig,
|
||||
): Record<string, unknown> {
|
||||
return isOpenCodeAutoAcceptEnabled(opened) && !isOpenCodeAutoAcceptEnabled(given)
|
||||
? { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: {};
|
||||
}
|
||||
|
||||
function withOpenCodeAutoAccept(
|
||||
providerOptions: ProviderOptions | undefined,
|
||||
enabled: boolean,
|
||||
@@ -1494,6 +1504,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
false,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
openCodeOpenOptionsPatch(config, openCodeConfig),
|
||||
);
|
||||
} catch (error) {
|
||||
await connection.release();
|
||||
@@ -1549,6 +1560,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
registeredAcquisition !== null,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
openCodeOpenOptionsPatch(config, openCodeConfig),
|
||||
);
|
||||
} catch (error) {
|
||||
await connection.release();
|
||||
@@ -3363,6 +3375,7 @@ interface OpenCodeServerConnection {
|
||||
class OpenCodeAgentSession implements AgentSession {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
readonly openOptionsPatch: Record<string, unknown>;
|
||||
|
||||
private readonly config: OpenCodeAgentConfig;
|
||||
private server: OpenCodeServerConnection;
|
||||
@@ -3450,8 +3463,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly externallyDriven = false,
|
||||
releaseBridge?: () => void,
|
||||
connectServer?: () => Promise<OpenCodeServerConnection>,
|
||||
openOptionsPatch: Record<string, unknown> = {},
|
||||
) {
|
||||
this.config = config;
|
||||
this.openOptionsPatch = openOptionsPatch;
|
||||
this.server = {
|
||||
client,
|
||||
events,
|
||||
|
||||
Reference in New Issue
Block a user