mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"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();
|
||||
});
|
||||
Reference in New Issue
Block a user