mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
|
|
const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js");
|
|
|
|
class FakeAdapter {
|
|
constructor(name, options = {}) {
|
|
this.name = name;
|
|
this.options = options;
|
|
this.listeners = new Set();
|
|
this.calls = [];
|
|
this.disposed = false;
|
|
this.snapshotValue = options.snapshot ?? idleSnapshot(name);
|
|
}
|
|
|
|
async start() {
|
|
this.calls.push(["start"]);
|
|
this.emit({ type: "candidate.starting", payload: { name: this.name } });
|
|
if (this.options.startGate) await this.options.startGate.promise;
|
|
if (this.options.startError) throw this.options.startError;
|
|
this.emit({ type: "connection.opened", payload: { name: this.name } });
|
|
}
|
|
|
|
async startThread(params = {}) { return this.record("startThread", params); }
|
|
async newSession(params = {}) { return this.record("newSession", params); }
|
|
async startTurn(params) { return this.record("startTurn", params); }
|
|
async steerTurn(params) { return this.record("steerTurn", params); }
|
|
async updateThreadSettings(params) { return this.record("updateThreadSettings", params); }
|
|
async listSessions(params = {}) { return this.record("listSessions", params); }
|
|
async selectSession(params) { return this.record("selectSession", params); }
|
|
async interruptTurn(params) { return this.record("interruptTurn", params); }
|
|
async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); }
|
|
async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); }
|
|
async respondApproval(requestId, decision, reason, response) {
|
|
return this.record("respondApproval", { requestId, decision, reason, response });
|
|
}
|
|
async denyPending(reason) { this.calls.push(["denyPending", reason]); }
|
|
|
|
async snapshot() {
|
|
this.calls.push(["snapshot"]);
|
|
return structuredClone(this.snapshotValue);
|
|
}
|
|
|
|
onEvent(listener) {
|
|
this.listeners.add(listener);
|
|
return { dispose: () => this.listeners.delete(listener) };
|
|
}
|
|
|
|
emit(event) {
|
|
for (const listener of this.listeners) listener(event);
|
|
}
|
|
|
|
async dispose() {
|
|
this.calls.push(["dispose"]);
|
|
this.disposed = true;
|
|
}
|
|
|
|
record(method, params) {
|
|
this.calls.push([method, params]);
|
|
return { adapter: this.name, method, params };
|
|
}
|
|
}
|
|
|
|
function idleSnapshot(name) {
|
|
return {
|
|
threadId: `${name}-thread`,
|
|
turnId: null,
|
|
state: "idle",
|
|
pendingApprovals: [],
|
|
pendingRequests: [],
|
|
outputTail: "",
|
|
metadata: { adapter: name },
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
|
await adapter.start();
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.metadata.adapter, "sync");
|
|
assert.equal(snapshot.metadata.mode, "sync");
|
|
assert.equal(snapshot.metadata.controlMode, "sync");
|
|
assert.equal(snapshot.metadata.modeEpoch, 0);
|
|
assert.deepEqual(snapshot.metadata.capabilities, {
|
|
followsVscodeRoute: true,
|
|
sessionList: false,
|
|
sessionSelect: false,
|
|
sessionCreate: false,
|
|
threadSettings: true,
|
|
});
|
|
|
|
await assert.rejects(adapter.listSessions(), /unavailable in sync mode/);
|
|
await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/);
|
|
await assert.rejects(adapter.newSession(), /unavailable in sync mode/);
|
|
await assert.rejects(adapter.startThread(), /unavailable in sync mode/);
|
|
assert.equal((await adapter.sendInput("hello")).adapter, "sync");
|
|
assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("async mode proxies the complete AgentAdapter surface", async () => {
|
|
const independent = new FakeAdapter("async");
|
|
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
|
await adapter.start();
|
|
|
|
await adapter.startThread({ cwd: "/workspace" });
|
|
await adapter.newSession({ model: "codex" });
|
|
await adapter.startTurn({ text: "start" });
|
|
await adapter.steerTurn({ text: "steer" });
|
|
await adapter.updateThreadSettings({ effort: "high" });
|
|
await adapter.listSessions({ limit: 10 });
|
|
await adapter.selectSession({ threadId: "thread-2" });
|
|
await adapter.interruptTurn({ turnId: "turn-1" });
|
|
await adapter.sendInput("input", { source: "web" });
|
|
await adapter.cancel("turn-2", { reason: "user" });
|
|
await adapter.respondApproval(7, "allow", "approved", { decision: "accept" });
|
|
await adapter.denyPending("offline");
|
|
|
|
assert.deepEqual(
|
|
independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)),
|
|
[
|
|
"startThread",
|
|
"newSession",
|
|
"startTurn",
|
|
"steerTurn",
|
|
"updateThreadSettings",
|
|
"listSessions",
|
|
"selectSession",
|
|
"interruptTurn",
|
|
"sendInput",
|
|
"cancel",
|
|
"respondApproval",
|
|
"denyPending",
|
|
],
|
|
);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("session/new falls back to thread/start for a minimal async adapter", async () => {
|
|
const independent = new FakeAdapter("async");
|
|
independent.newSession = undefined;
|
|
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
|
await adapter.start();
|
|
|
|
const result = await adapter.newSession({ cwd: "/workspace" });
|
|
assert.equal(result.method, "startThread");
|
|
assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const gate = deferred();
|
|
const asyncAdapter = new FakeAdapter("async", { startGate: gate });
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
|
});
|
|
const events = [];
|
|
adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`));
|
|
await adapter.start();
|
|
events.length = 0;
|
|
|
|
const switching = adapter.setControlMode({ mode: "async" });
|
|
await Promise.resolve();
|
|
sync.emit({ type: "old.while-current", payload: { name: "sync" } });
|
|
assert.deepEqual(events, ["old.while-current:sync"]);
|
|
await assert.rejects(adapter.sendInput("racing input"), /mode is switching/);
|
|
gate.resolve();
|
|
|
|
const result = await switching;
|
|
assert.deepEqual(result, {
|
|
changed: true,
|
|
controlMode: "async",
|
|
previousControlMode: "sync",
|
|
modeEpoch: 1,
|
|
});
|
|
assert.equal(sync.disposed, true);
|
|
assert.equal(adapter.getControlMode(), "async");
|
|
assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async"));
|
|
assert.ok(events.includes("connection.opened:async"));
|
|
|
|
sync.emit({ type: "old.after-commit", payload: { name: "sync" } });
|
|
asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } });
|
|
assert.equal(events.includes("old.after-commit:sync"), false);
|
|
assert.equal(events.includes("new.after-commit:async"), true);
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.metadata.modeEpoch, 1);
|
|
assert.deepEqual(snapshot.metadata.capabilities, {
|
|
followsVscodeRoute: false,
|
|
sessionList: true,
|
|
sessionSelect: true,
|
|
sessionCreate: true,
|
|
threadSettings: true,
|
|
});
|
|
assert.equal((await adapter.listSessions()).adapter, "async");
|
|
assert.equal((await adapter.newSession()).adapter, "async");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("delegate snapshot events always carry authoritative mode metadata", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const asyncAdapter = new FakeAdapter("async");
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
|
});
|
|
const snapshots = [];
|
|
adapter.onEvent((event) => {
|
|
if (event.type === "session.snapshot") snapshots.push(event.payload);
|
|
});
|
|
await adapter.start();
|
|
|
|
sync.emit({
|
|
type: "session.snapshot",
|
|
threadId: "sync-thread-2",
|
|
payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } },
|
|
});
|
|
assert.deepEqual(snapshots.at(-1).metadata, {
|
|
adapter: "sync",
|
|
route: "/thread/2",
|
|
mode: "sync",
|
|
controlMode: "sync",
|
|
modeEpoch: 0,
|
|
capabilities: {
|
|
followsVscodeRoute: true,
|
|
sessionList: false,
|
|
sessionSelect: false,
|
|
sessionCreate: false,
|
|
threadSettings: true,
|
|
},
|
|
});
|
|
|
|
await adapter.setControlMode({ mode: "async" });
|
|
snapshots.length = 0;
|
|
asyncAdapter.emit({
|
|
type: "session.snapshot",
|
|
threadId: "async-thread-2",
|
|
payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } },
|
|
});
|
|
assert.equal(snapshots.length, 1);
|
|
assert.equal(snapshots[0].metadata.adapter, "async");
|
|
assert.equal(snapshots[0].metadata.title, "Second");
|
|
assert.equal(snapshots[0].metadata.controlMode, "async");
|
|
assert.equal(snapshots[0].metadata.modeEpoch, 1);
|
|
assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false);
|
|
assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("active turns and pending requests prevent a mode switch", async (t) => {
|
|
const cases = [
|
|
["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }],
|
|
["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }],
|
|
["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }],
|
|
["pending approval", {
|
|
...idleSnapshot("sync"),
|
|
pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }],
|
|
}],
|
|
["pending input", {
|
|
...idleSnapshot("sync"),
|
|
pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }],
|
|
}],
|
|
];
|
|
|
|
for (const [name, snapshot] of cases) {
|
|
await t.test(name, async () => {
|
|
const sync = new FakeAdapter("sync", { snapshot });
|
|
let factoryCalls = 0;
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => {
|
|
factoryCalls += 1;
|
|
return mode === "sync" ? sync : new FakeAdapter("async");
|
|
},
|
|
});
|
|
await adapter.start();
|
|
await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/);
|
|
assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter");
|
|
assert.equal(adapter.getControlMode(), "sync");
|
|
await adapter.dispose();
|
|
});
|
|
}
|
|
});
|
|
|
|
test("candidate startup failure leaves the old adapter authoritative", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const failed = new FakeAdapter("async", { startError: new Error("candidate failed") });
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => mode === "sync" ? sync : failed,
|
|
});
|
|
const events = [];
|
|
adapter.onEvent((event) => events.push(event.type));
|
|
await adapter.start();
|
|
events.length = 0;
|
|
|
|
await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/);
|
|
assert.equal(adapter.getControlMode(), "sync");
|
|
assert.equal(failed.disposed, true);
|
|
assert.equal(sync.disposed, false);
|
|
assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private");
|
|
assert.equal((await adapter.sendInput("still attached")).adapter, "sync");
|
|
assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("a mode factory cannot reuse the currently active adapter instance", async () => {
|
|
const shared = new FakeAdapter("shared");
|
|
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared });
|
|
await adapter.start();
|
|
|
|
await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/);
|
|
assert.equal(adapter.getControlMode(), "sync");
|
|
assert.equal(shared.disposed, false);
|
|
assert.equal((await adapter.sendInput("still live")).adapter, "shared");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("listener failures cannot turn a committed switch into a rejected command", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const asyncAdapter = new FakeAdapter("async");
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
|
});
|
|
adapter.onEvent(() => { throw new Error("consumer failed"); });
|
|
await adapter.start();
|
|
|
|
const result = await adapter.setControlMode({ mode: "async" });
|
|
assert.equal(result.changed, true);
|
|
assert.equal(adapter.getControlMode(), "async");
|
|
assert.equal(sync.disposed, true);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("a turn that appears while the candidate starts aborts before commit", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const gate = deferred();
|
|
const candidate = new FakeAdapter("async", { startGate: gate });
|
|
const adapter = new SwitchableAgentAdapter({
|
|
initialMode: "sync",
|
|
createAdapter: (mode) => mode === "sync" ? sync : candidate,
|
|
});
|
|
await adapter.start();
|
|
|
|
const switching = adapter.setControlMode({ mode: "async" });
|
|
await Promise.resolve();
|
|
sync.snapshotValue.turnId = "turn-race";
|
|
sync.snapshotValue.state = "active";
|
|
gate.resolve();
|
|
|
|
await assert.rejects(switching, /turn or request is active/);
|
|
assert.equal(adapter.getControlMode(), "sync");
|
|
assert.equal(candidate.disposed, true);
|
|
assert.equal(sync.disposed, false);
|
|
sync.snapshotValue.turnId = null;
|
|
sync.snapshotValue.state = "idle";
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("control mode validation and idempotent switches are explicit", async () => {
|
|
const sync = new FakeAdapter("sync");
|
|
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
|
await adapter.start();
|
|
|
|
await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/);
|
|
assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), {
|
|
changed: false,
|
|
controlMode: "sync",
|
|
previousControlMode: "sync",
|
|
modeEpoch: 0,
|
|
});
|
|
await adapter.dispose();
|
|
});
|