fix(server): ACP option applies run one at a time

Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.
This commit is contained in:
Mohamed Boudra
2026-11-01 21:29:07 +01:00
parent 4d76f2176b
commit 4a8ed8f588
3 changed files with 33 additions and 1 deletions
@@ -1925,6 +1925,19 @@ describe("ACPAgentSession options against an ACP agent", () => {
]);
});
test("overlapping applies that both fail still report what the agent moved", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
const first = session.applyOptions({ agent: "Probe Agent" });
const second = session.applyOptions({ agent: null });
await expect(first).rejects.toThrow(/write refused/);
await expect(second).rejects.toThrow(/write refused/);
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]);
});
test("a removal writes the select's empty choice and returns nothing when that took", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
@@ -1761,6 +1761,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
openOptionsPatch: Record<string, unknown> = {};
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
private optionsTransactionDepth = 0;
private applying: Promise<unknown> = Promise.resolve();
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2406,7 +2407,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
* Provider notifications that arrive meanwhile are part of that result. When a write is
* rejected, what the provider moved is reported as an event, since nothing is stored.
*/
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
/** One apply at a time: each reconciles against the state the one before it left. */
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: this.config.provider,
patch: options,
@@ -3,6 +3,8 @@
// answers: { [configId]: value } the agent ends up with instead of the requested value
// onWrite: "notify-first" sends config_option_update before answering the write;
// "notify-then-reject" does the same and then fails the write
// "slow-reject" moves the select and announces it for a non-empty value, then refuses
// after a delay; an empty value is refused without moving anything
// moves: { [configId]: { [id]: value } } writes that also move another select, announced
// by a config_option_update after the write is answered
// adds: { [configId]: option[] } writes that make more selects appear, announced the same way
@@ -28,12 +30,21 @@ export const connection = new AgentSideConnection(
return { sessionId: "session-1", modes: null, models: null, configOptions: options };
},
async setSessionConfigOption({ sessionId, configId, value }) {
if (scenario.onWrite === "slow-reject" && !value) {
await new Promise((resolve) => setTimeout(resolve, 150));
throw new Error("write refused");
}
set(configId, scenario.answers?.[configId] ?? value);
const announce = () =>
agentConnection.sessionUpdate({
sessionId,
update: { sessionUpdate: "config_option_update", configOptions: options },
});
if (scenario.onWrite === "slow-reject") {
await announce();
await new Promise((resolve) => setTimeout(resolve, 100));
throw new Error("write refused");
}
if (scenario.onWrite === "notify-first" || scenario.onWrite === "notify-then-reject") {
await announce();
if (scenario.onWrite === "notify-then-reject") throw new Error("write refused");