mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Run option, model, thinking and mode changes in the agent's lifecycle lane
The agent manager owns all sequencing for one agent: applyOptions, setModel, setThinkingOption and setMode now run in the same lane as open, close, archive, labels and metadata, so a provider session never sees two of them at once. Provider-side serialization (the applying chains in the ACP, plugin and OpenCode sessions, and Claude's apply-over-current-options handling) is deleted.
This commit is contained in:
@@ -13449,6 +13449,7 @@ describe("agent options", () => {
|
||||
class OptionsSession extends TestAgentSession {
|
||||
readonly applied: ProviderOptions[] = [];
|
||||
rejectWith: Error | null = null;
|
||||
/** Holds the next apply only; the one after it is answered at once. */
|
||||
applyGate: Promise<void> | null = null;
|
||||
/** What the session says it ended up with after the next apply. */
|
||||
ended: Record<string, unknown> = {};
|
||||
@@ -13456,7 +13457,9 @@ describe("agent options", () => {
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
if (this.rejectWith) throw this.rejectWith;
|
||||
await this.applyGate;
|
||||
const gate = this.applyGate;
|
||||
this.applyGate = null;
|
||||
await gate;
|
||||
this.applied.push(options);
|
||||
return this.ended;
|
||||
}
|
||||
@@ -13681,7 +13684,7 @@ describe("agent options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
|
||||
test("overlapping patches reach the provider one after the other, and both land", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent();
|
||||
let release!: () => void;
|
||||
client.session!.applyGate = new Promise<void>((resolve) => {
|
||||
@@ -13689,11 +13692,14 @@ describe("agent options", () => {
|
||||
});
|
||||
|
||||
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
|
||||
client.session!.applyGate = null;
|
||||
await manager.setAgentOptions(agentId, { web_search: true });
|
||||
const next = manager.setAgentOptions(agentId, { web_search: true });
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
// The second patch waits for the first to be answered: the provider never sees two at once.
|
||||
expect(client.session?.applied).toEqual([]);
|
||||
release();
|
||||
await slow;
|
||||
await Promise.all([slow, next]);
|
||||
|
||||
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }, { web_search: true }]);
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
web_search: true,
|
||||
|
||||
@@ -2363,111 +2363,123 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Mode, model, thinking and options change inside the agent's lifecycle lane, like every other
|
||||
// mutation of one agent. A provider session therefore never has two of `setMode`, `setModel`,
|
||||
// `setThinkingOption` and `applyOptions` in flight at once, and none of them overlaps an open,
|
||||
// reload or close; providers do no sequencing of their own.
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const notice = (await agent.session.setMode(modeId)) ?? null;
|
||||
await this.drainSessionEvents(agentId);
|
||||
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
||||
agent.config.modeId = currentMode ?? undefined;
|
||||
agent.currentModeId = currentMode;
|
||||
// Update runtimeInfo to reflect the new mode
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
|
||||
const notice = (await agent.session.setMode(modeId)) ?? null;
|
||||
await this.drainSessionEvents(agentId);
|
||||
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
||||
agent.config.modeId = currentMode ?? undefined;
|
||||
agent.currentModeId = currentMode;
|
||||
// Update runtimeInfo to reflect the new mode
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
|
||||
const selection = await agent.session.setModel?.(normalizedModelId);
|
||||
await this.drainSessionEvents(agentId);
|
||||
const selection = await agent.session.setModel?.(normalizedModelId);
|
||||
await this.drainSessionEvents(agentId);
|
||||
|
||||
// A reset clears the preference even when the provider retains its active model.
|
||||
let effectiveModelId = normalizedModelId;
|
||||
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
|
||||
// A reset clears the preference even when the provider retains its active model.
|
||||
let effectiveModelId = normalizedModelId;
|
||||
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
|
||||
|
||||
agent.config.model = effectiveModelId ?? undefined;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
agent.config.model = effectiveModelId ?? undefined;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
: null;
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
: null;
|
||||
|
||||
let notice: AgentProviderNotice | null = null;
|
||||
if (agent.session.setThinkingOption) {
|
||||
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
|
||||
}
|
||||
await this.drainSessionEvents(agentId);
|
||||
let notice: AgentProviderNotice | null = null;
|
||||
if (agent.session.setThinkingOption) {
|
||||
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
|
||||
}
|
||||
await this.drainSessionEvents(agentId);
|
||||
|
||||
let effectiveThinkingOptionId = normalizedThinkingOptionId;
|
||||
const runtimeInfo = await agent.session.getRuntimeInfo();
|
||||
if (runtimeInfo.thinkingOptionId !== undefined) {
|
||||
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
|
||||
}
|
||||
let effectiveThinkingOptionId = normalizedThinkingOptionId;
|
||||
const runtimeInfo = await agent.session.getRuntimeInfo();
|
||||
if (runtimeInfo.thinkingOptionId !== undefined) {
|
||||
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
|
||||
}
|
||||
|
||||
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = {
|
||||
...agent.runtimeInfo,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
};
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = {
|
||||
...agent.runtimeInfo,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
};
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
|
||||
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
|
||||
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
|
||||
* one step once the provider accepts, over the options as they are at that moment, so concurrent
|
||||
* writes to one agent resolve by arrival order.
|
||||
* one step once the provider accepts. Patches to one agent run one at a time, in arrival order.
|
||||
*/
|
||||
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const patch = this.convertLegacyOptions(agent.provider, requested);
|
||||
let ended: ProviderOptionsPatch = {};
|
||||
let sent: ProviderOptionsPatch = {};
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// A provider that cannot apply options to a running agent must not store them either.
|
||||
if (!agent.session.applyOptions) {
|
||||
throw new ProviderOptionsValidationError(
|
||||
agent.provider,
|
||||
Object.keys(patch).map((key) => ({
|
||||
path: [key],
|
||||
message: "cannot change on a running session; it applies when the agent is opened",
|
||||
})),
|
||||
);
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
|
||||
const patch = this.convertLegacyOptions(agent.provider, requested);
|
||||
let ended: ProviderOptionsPatch = {};
|
||||
let sent: ProviderOptionsPatch = {};
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// A provider that cannot apply options to a running agent must not store them either.
|
||||
if (!agent.session.applyOptions) {
|
||||
throw new ProviderOptionsValidationError(
|
||||
agent.provider,
|
||||
Object.keys(patch).map((key) => ({
|
||||
path: [key],
|
||||
message: "cannot change on a running session; it applies when the agent is opened",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
||||
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||
// Changes the provider made on its own while applying are older than its final answer.
|
||||
await this.drainSessionEvents(agentId);
|
||||
}
|
||||
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
||||
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||
// Changes the provider made on its own while applying are older than its final answer.
|
||||
await this.drainSessionEvents(agentId);
|
||||
}
|
||||
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
});
|
||||
}
|
||||
|
||||
/** The config as the opened session left it: its own difference from the options it was given. */
|
||||
@@ -3061,22 +3073,25 @@ export class AgentManager {
|
||||
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(agent.session)) {
|
||||
await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
const record = await this.registry?.get(agentId);
|
||||
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
|
||||
const current = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(current.session)) {
|
||||
// Recheck inside the lane: another prompt may already have upgraded it.
|
||||
// Reload validates the directory before releasing readable history.
|
||||
await this.reloadAgentSessionInternal(agentId);
|
||||
}
|
||||
}),
|
||||
await this.runLifecycleMutation(agentId, () =>
|
||||
this.ensureInteractiveSessionUnlocked(agentId),
|
||||
);
|
||||
}
|
||||
return this.requireSessionAgent(agentId);
|
||||
}
|
||||
|
||||
/** Inside the agent's lane: a history session is upgraded before anything is applied to it. */
|
||||
private async ensureInteractiveSessionUnlocked(agentId: string): Promise<ActiveManagedAgent> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(agent.session)) {
|
||||
const record = await this.registry?.get(agentId);
|
||||
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
|
||||
// Reload validates the directory before releasing readable history.
|
||||
await this.trackAgentRegistrationOperation(this.reloadAgentSessionInternal(agentId));
|
||||
}
|
||||
return this.requireSessionAgent(agentId);
|
||||
}
|
||||
|
||||
private async *streamAfterHistoryLoad(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -58,11 +58,6 @@ 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 controlKey(control: { key: string }): string {
|
||||
return control.key;
|
||||
}
|
||||
@@ -1157,10 +1152,7 @@ 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,
|
||||
holdLoudChange,
|
||||
}: { refuseAfterChanging?: boolean; holdLoudChange?: Promise<void> } = {},
|
||||
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
|
||||
) {
|
||||
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
|
||||
voice: "direct",
|
||||
@@ -1259,16 +1251,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
// Going loud returns an unrequested token budget to its default, by leaving it out.
|
||||
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
|
||||
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" } }
|
||||
@@ -1358,32 +1340,6 @@ 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: { providerOptions: { 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 });
|
||||
|
||||
|
||||
@@ -1211,8 +1211,6 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly committedOptions: 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: {
|
||||
@@ -1422,13 +1420,8 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
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>> {
|
||||
/** The agent manager sends one apply at a time per session. */
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
this.assertReplayComplete();
|
||||
const before = this.currentOptions();
|
||||
const { legacySettings } = readPluginOptionState(this.bridge.config);
|
||||
|
||||
@@ -1965,19 +1965,6 @@ 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 it, since the options hold what the session runs", async () => {
|
||||
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||
|
||||
|
||||
@@ -1776,7 +1776,6 @@ 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;
|
||||
@@ -2449,15 +2448,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
* Applies only the options the patch sets; returns the selects that ended up different.
|
||||
* 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.
|
||||
* The agent manager sends one apply at a time per session.
|
||||
*/
|
||||
/** 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>> {
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
assertChangesAreLive({
|
||||
provider: this.config.provider,
|
||||
patch: options,
|
||||
|
||||
@@ -2888,13 +2888,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
|
||||
// this call's keys are applied, over the options as they are now: another patch may have
|
||||
// landed while the runtime was answering.
|
||||
this.config.providerOptions = applyProviderOptionsPatch({
|
||||
options: this.config.providerOptions,
|
||||
patch: options,
|
||||
});
|
||||
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
|
||||
this.config.providerOptions = providerOptions;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
|
||||
@@ -88,47 +88,6 @@ 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({
|
||||
|
||||
@@ -386,14 +386,8 @@ 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 });
|
||||
}
|
||||
/** 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> {
|
||||
/** The agent manager sends one apply at a time, so a failed one restores exactly what it changed. */
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
assertChangesAreLive({
|
||||
provider: "opencode",
|
||||
patch: options,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// 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
|
||||
@@ -30,21 +28,12 @@ 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");
|
||||
|
||||
Reference in New Issue
Block a user