feat(vscodex): add remote Codex collaboration module

This commit is contained in:
fawney
2026-09-01 20:08:36 +08:00
parent 1aab31a148
commit 5059093d29
102 changed files with 38569 additions and 11 deletions
+745
View File
@@ -0,0 +1,745 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
class FakeRpc {
responses = [];
requests = [];
notificationListener;
requestListener;
exitListener;
overrides;
constructor(overrides = {}) {
this.overrides = overrides;
}
get running() {
return true;
}
async start() {}
async request(method, params) {
this.requests.push({ method, params });
if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
const override = this.overrides[method];
return typeof override === "function" ? override(params) : override;
}
if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
if (method === "turn/start") return { turn: { id: "turn-test" } };
if (method === "turn/steer") return { turn: { id: "turn-test" } };
if (method === "turn/interrupt") return {};
throw new Error(`unexpected request ${method}`);
}
notify() {}
respond(id, result) {
this.responses.push({ id, result });
}
respondError(id, code, message) {
this.responses.push({ id, error: { code, message } });
}
onNotification(listener) {
this.notificationListener = listener;
return { dispose: () => undefined };
}
onServerRequest(listener) {
this.requestListener = listener;
return { dispose: () => undefined };
}
onExit(listener) {
this.exitListener = listener;
return { dispose: () => undefined };
}
close() {}
emitRequest(request) {
this.requestListener(request);
}
emitNotification(notification) {
this.notificationListener(notification);
}
}
class FakeRelay {
frames = [];
listeners = new Set();
async connect() {}
send(frame) {
this.frames.push(frame);
}
onMessage(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
close() {}
}
test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 1,
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
});
rpc.emitRequest({
id: "1",
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
});
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
await adapter.respondApproval(1, "deny");
await adapter.respondApproval("1", "deny");
assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
await adapter.dispose();
});
test("commandActions are included in high-risk approval classification", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 2,
method: "item/commandExecution/requestApproval",
params: {
threadId: "t",
turnId: "u",
itemId: "actions",
command: null,
commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
},
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.pendingApprovals[0].risk, "high");
await adapter.respondApproval(2, "deny");
await adapter.dispose();
});
test("output snapshots stay redacted and interrupt clears the active turn", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.startThread({});
await adapter.startTurn({ text: "hello" });
assert.equal((await adapter.snapshot()).turnId, "turn-test");
rpc.emitNotification({
method: "item/agentMessage/delta",
params: { delta: "credential Bearer abcdefghijklmnop" },
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
assert.match(snapshot.outputTail, /\[REDACTED\]/);
await adapter.interruptTurn({});
const afterInterrupt = await adapter.snapshot();
assert.equal(afterInterrupt.turnId, null);
assert.equal(afterInterrupt.state, "idle");
await adapter.dispose();
});
test("async adapter lists app-server threads and exposes the model catalog", async () => {
const rpc = new FakeRpc({
"model/list": {
data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
nextCursor: null,
},
"thread/list": {
data: [
{
id: "thread-recent",
name: null,
preview: "Inspect the workspace\nwith detail",
cwd: "/tmp/workspace",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
source: "vscode",
},
],
nextCursor: "next-page",
backwardsCursor: null,
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
assert.equal(result.sessions[0].threadId, "thread-recent");
assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
assert.equal(result.nextCursor, "next-page");
const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
assert.deepEqual(listRequest.params, {
limit: 100,
sortKey: "updated_at",
sortDirection: "desc",
searchTerm: "workspace",
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.mode, "async");
assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
await adapter.dispose();
});
test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.startThread({});
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
// A usage update may arrive after the turn has completed. It must not
// make the adapter report that historical turn as active again.
turnId: "turn-finished",
tokenUsage: {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
},
},
});
const expected = {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
};
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.metadata.tokenUsage, expected);
assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
assert.equal(snapshot.turnId, null);
const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
assert.ok(usageEvent);
assert.deepEqual(usageEvent.payload.tokenUsage, expected);
assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
// Keep the raw diagnostic envelope redacted while exposing only the safe
// numeric projection to the browser.
assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
turnId: "turn-finished",
tokenUsage: { total: { inputTokens: -1 } },
},
});
assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
await adapter.dispose();
});
test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
const thread = {
id: "thread-selected",
name: "Selected thread",
preview: "hello",
cwd: "/tmp/selected",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_010,
status: { type: "idle" },
turns: [{
id: "turn-history",
status: "completed",
startedAt: 1_700_000_001,
completedAt: 1_700_000_004,
durationMs: 3_000,
items: [
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
{ type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
{ type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
],
}],
};
const rpc = new FakeRpc({
"model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
"thread/resume": {
thread,
model: "gpt-5.6-sol",
modelProvider: "openai",
serviceTier: null,
cwd: "/tmp/selected",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: { type: "workspaceWrite" },
reasoningEffort: "high",
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
const result = await adapter.selectSession({ threadId: "thread-selected" });
assert.equal(result.threadId, "thread-selected");
let snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.length, 4);
assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
assert.equal(snapshot.metadata.title, "Selected thread");
assert.equal(snapshot.metadata.threadSettings.effort, "high");
assert.equal(snapshot.metadata.historyComplete, true);
assert.equal(snapshot.status.turnStatus, "completed");
assert.match(snapshot.outputTail, /Done/);
assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
const authoritative = events.find((event) => event.type === "session.snapshot");
assert.equal(authoritative.payload.threadId, "thread-selected");
assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
assert.equal(authoritative.payload.messages.length, 4);
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-old",
turnId: "turn-old",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
},
});
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-selected",
turnId: "turn-live",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "current-item", text: "current thread" },
},
});
snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
await adapter.dispose();
});
test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
const threadId = "thread-paged-history";
const userItem = (id, text) => ({
type: "userMessage",
id,
clientId: null,
content: [{ type: "text", text, text_elements: [] }],
});
const assistantItem = (id, text) => ({
type: "agentMessage",
id,
text,
phase: "final_answer",
});
const earlyUser = userItem("early-user", "first question");
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": {
thread: {
id: threadId,
name: "Paged history",
preview: "first question",
cwd: "/tmp/paged",
createdAt: 50,
updatedAt: 350,
historyMode: "paginated",
status: { type: "idle" },
turns: [],
},
model: "gpt-5.6-sol",
cwd: "/tmp/paged",
initialTurnsPage: {
data: [{
id: "turn-late",
status: "completed",
startedAt: 300,
completedAt: 310,
itemsView: "full",
items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
}],
nextCursor: "turn-page-2",
backwardsCursor: null,
},
},
"thread/turns/list": (params) => {
if (params.cursor === "turn-page-2") {
return {
data: [{
id: "turn-early",
status: "completed",
startedAt: 100,
completedAt: 110,
itemsView: "summary",
items: [earlyUser],
}],
nextCursor: "turn-page-3",
backwardsCursor: null,
};
}
assert.equal(params.cursor, "turn-page-3");
return {
data: [{
id: "turn-middle",
status: "completed",
startedAt: 200,
completedAt: 210,
itemsView: "full",
items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
}],
nextCursor: null,
backwardsCursor: null,
};
},
"thread/items/list": (params) => {
assert.equal(params.turnId, "turn-early");
return {
data: [
// The summary row is repeated by the full item page; hydration must
// de-duplicate it while adding the omitted assistant response.
{ turnId: "turn-early", item: earlyUser },
{ turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
],
nextCursor: null,
backwardsCursor: null,
};
},
});
const events = [];
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.selectSession({ threadId });
const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
assert.deepEqual(resume.params, {
threadId,
excludeTurns: true,
initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
});
const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
&& entry.params.limit === 100
&& entry.params.sortDirection === "asc"
&& entry.params.itemsView === "full"));
const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
assert.deepEqual(itemPages.map((entry) => entry.params), [{
threadId,
turnId: "turn-early",
limit: 100,
sortDirection: "asc",
}]);
assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
["turn-early", "first question"],
["turn-early", "first answer"],
["turn-middle", "second question"],
["turn-middle", "second answer"],
["turn-late", "third question"],
["turn-late", "third answer"],
]);
assert.equal(snapshot.metadata.historyComplete, true);
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
assert.equal(outputSnapshot.payload.historyComplete, true);
assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
"first question",
"first answer",
"second question",
"second answer",
"third question",
"third answer",
]);
await adapter.dispose();
});
test("async adapter falls back to thread/read when resume omits existing history", async () => {
const metadataThread = {
id: "thread-paginated",
preview: "existing conversation",
cwd: "/tmp/project",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
turns: [],
};
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
"thread/read": {
thread: {
...metadataThread,
turns: [{
id: "turn-read",
status: "completed",
items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
}],
},
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.selectSession({ threadId: "thread-paginated" });
const read = rpc.requests.find((entry) => entry.method === "thread/read");
assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
await adapter.dispose();
});
test("async adapter starts new sessions and sends flat durable thread settings", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/start": {
thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
model: "gpt-5.6-sol",
cwd: "/tmp/new",
reasoningEffort: "medium",
},
"thread/settings/update": { ok: true },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
await adapter.start();
await adapter.newSession({});
await adapter.updateThreadSettings({
threadSettings: {
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandboxPolicy: "workspace-write",
permissions: ":workspace",
},
});
const start = rpc.requests.find((entry) => entry.method === "thread/start");
assert.equal(start.params.cwd, "/tmp/default");
const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
assert.deepEqual(update.params, {
threadId: "thread-new",
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
permissions: ":workspace",
});
assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
assert.equal(snapshot.metadata.latestReasoningEffort, "high");
assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
await adapter.dispose();
});
test("thread settings updates require the send_task_input capability", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output"],
sessionId: "test-session",
});
await host.handleFrame({
kind: "command",
type: "thread.settings.update",
commandId: "settings-without-capability",
actor: { role: "operator" },
payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
});
const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
assert.equal(result.type, "command.rejected");
assert.match(result.payload.error, /missing capability: send_task_input/);
await adapter.dispose();
});
test("RelayHost exposes session list as read-only and protects session selection", async () => {
const relay = new FakeRelay();
const calls = [];
const adapter = {
async start() {},
async sendInput() { return {}; },
async cancel() { return {}; },
async respondApproval() { return {}; },
async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
onEvent() { return { dispose() {} }; },
async dispose() {},
async listSessions(params) {
calls.push({ method: "listSessions", params });
return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
},
async selectSession(params) {
calls.push({ method: "selectSession", params });
return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
},
async newSession(params) {
calls.push({ method: "newSession", params });
return { opened: true, command: "chatgpt.newCodexPanel" };
},
getControlMode() { return "sync"; },
async setControlMode(params) {
calls.push({ method: "setControlMode", params });
return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
},
};
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input"],
sessionId: "test-session",
});
await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
assert.equal(listed.type, "command.accepted");
assert.equal(listed.payload.result.activeThreadId, "thread-a");
assert.equal(calls[0].method, "listSessions");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
assert.equal(denied.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
assert.equal(selected.type, "command.accepted");
assert.equal(selected.payload.result.threadId, "thread-b");
assert.equal(calls.at(-1).method, "selectSession");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
assert.equal(deniedNew.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
assert.equal(opened.type, "command.accepted");
assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
assert.equal(calls.at(-1).method, "newSession");
await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
assert.equal(mode.type, "command.accepted");
assert.equal(mode.payload.result.mode, "sync");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
assert.equal(deniedMode.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
assert.equal(changedMode.type, "command.accepted");
assert.equal(changedMode.payload.result.controlMode, "async");
assert.equal(calls.at(-1).method, "setControlMode");
});
test("approval decision conflicts and unknown tagged objects fail closed", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
sessionId: "test-session",
});
rpc.emitRequest({
id: 3,
method: "execCommandApproval",
params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "conflicting-response",
actor: { role: "operator" },
payload: {
requestId: 3,
decision: "deny",
response: { decision: "approved_mcp_policy_amendment" },
},
});
assert.deepEqual(rpc.responses[0], {
id: 3,
result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
});
rpc.emitRequest({
id: 4,
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "unknown-tagged-response",
actor: { role: "operator" },
payload: {
requestId: 4,
decision: "allow",
response: { decision: { futurePolicyGrant: { scope: "all" } } },
},
});
assert.deepEqual(rpc.responses[1], {
id: 4,
result: { decision: "decline" },
});
await adapter.dispose();
});
+298
View File
@@ -0,0 +1,298 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { WebSocket } = require("ws");
const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js");
const internalToken = "test-internal-token-with-enough-entropy";
function internalFetch(base, pathname, options = {}) {
return fetch(`${base}${pathname}`, {
...options,
headers: {
Authorization: `Bearer ${internalToken}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
}
function websocketClient(base, clientType, token, sessionId) {
const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const messages = [];
const waiters = [];
const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => {
const existing = messages.find(predicate);
if (existing) return resolve(existing);
const timer = setTimeout(() => {
const index = waiters.findIndex((entry) => entry.resolve === resolve);
if (index >= 0) waiters.splice(index, 1);
reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`));
}, timeout);
waiters.push({
predicate,
resolve: (message) => {
clearTimeout(timer);
resolve(message);
},
});
});
socket.on("message", (data) => {
const message = JSON.parse(data.toString("utf8"));
messages.push(message);
for (let index = waiters.length - 1; index >= 0; index -= 1) {
if (!waiters[index].predicate(message)) continue;
const waiter = waiters.splice(index, 1)[0];
waiter.resolve(message);
}
});
return new Promise((resolve, reject) => {
socket.once("open", () => {
socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) }));
socket.send(JSON.stringify(clientType === "host"
? { v: 1, kind: "auth", accessToken: token }
: { type: "auth", token }));
wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject);
});
socket.once("error", reject);
});
}
async function pairDevice(base, userId, name) {
const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, {
method: "POST",
body: JSON.stringify({ name }),
});
assert.equal(pairingResponse.status, 201);
const pairing = await pairingResponse.json();
const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: pairing.code, name }),
});
assert.equal(exchangeResponse.status, 201);
return exchangeResponse.json();
}
async function browserTicket(base, userId, deviceId) {
const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId }),
});
assert.equal(response.status, 201);
return response.json();
}
function exchangeAttempt(base, headers = {}) {
return fetch(`${base}/v1/pairings/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ code: "INVALID-CODE" }),
});
}
test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const trustedHeaders = (clientIp) => ({
Authorization: `Bearer ${internalToken}`,
"X-Aether-Client-IP": clientIp,
});
for (let attempt = 0; attempt < 10; attempt += 1) {
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400);
}
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429);
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400);
assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400);
server.exchangeAttempts.clear();
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400);
}
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal((await exchangeAttempt(base, {
Authorization: "Bearer invalid-internal-token",
"X-Aether-Client-IP": `198.51.100.${30 + attempt}`,
})).status, 400);
}
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429);
server.exchangeAttempts.clear();
const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"];
for (let attempt = 0; attempt < 10; attempt += 1) {
assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400);
}
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429);
});
test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
pairingTtlMs: 5_000,
ticketTtlMs: 5_000,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`);
assert.equal(unauthorized.status, 401);
const paired = await pairDevice(base, "user-a", "MacBook VS Code");
assert.match(paired.device_token, /^avx1\./);
const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices");
assert.equal(devicesResponse.status, 200);
const devices = await devicesResponse.json();
assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [
{ id: paired.device_id, name: "MacBook VS Code", connected: false },
]);
const ticket = await browserTicket(base, "user-a", paired.device_id);
assert.equal(ticket.ws_url, "/api/vscodex/ws");
const host = await websocketClient(base, "host", paired.device_token, "host-user-a");
const browser = await websocketClient(base, "web", ticket.ticket);
t.after(() => host.socket.close());
t.after(() => browser.socket.close());
browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
host.socket.send(JSON.stringify({
v: 1,
kind: "event",
type: "connection.opened",
id: "connection-a",
sessionId: "host-user-a",
seq: 1,
ts: new Date().toISOString(),
payload: {},
}));
host.socket.send(JSON.stringify({
v: 1,
kind: "event",
type: "session.snapshot",
id: "snapshot-a",
sessionId: "host-user-a",
seq: 2,
ts: new Date().toISOString(),
payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] },
}));
const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot");
assert.equal(snapshot.payload.threadId, "thread-a");
assert.equal(snapshot.payload.messages[0].text, "user-a-only");
browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} }));
const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command");
assert.equal(command.type, "session/list");
const secondUser = await pairDevice(base, "user-b", "Other VS Code");
const secondTicket = await browserTicket(base, "user-b", secondUser.device_id);
const secondBrowser = await websocketClient(base, "web", secondTicket.ticket);
t.after(() => secondBrowser.socket.close());
secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false);
const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const closed = new Promise((resolve, reject) => {
reusedTicket.once("open", () => {
reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket }));
});
reusedTicket.once("close", (code) => resolve(code));
reusedTicket.once("error", reject);
});
assert.equal(await closed, 1008, "browser tickets are one-time credentials");
});
test("device revocation closes its room and blocks future host authentication", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const paired = await pairDevice(base, "user-a", "Revoked device");
const host = await websocketClient(base, "host", paired.device_token, "revoked-host");
const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" });
assert.equal(response.status, 204);
await new Promise((resolve) => host.socket.once("close", resolve));
const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const closed = new Promise((resolve, reject) => {
rejected.once("open", () => {
rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" }));
rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token }));
});
rejected.once("close", (code) => resolve(code));
rejected.once("error", reject);
});
assert.equal(await closed, 1008);
});
test("room revocation wins a concurrent room creation", async () => {
const rooms = new RoomManager();
let releaseCreation;
const creationGate = new Promise((resolve) => { releaseCreation = resolve; });
let stopped = false;
const room = {
key: rooms.key("user-a", "device-a"),
userId: "user-a",
deviceId: "device-a",
relay: { stop: async () => { stopped = true; } },
connections: 0,
lastActiveMs: Date.now(),
};
rooms.createRoom = async (key) => {
await creationGate;
rooms.rooms.set(key, room);
return room;
};
const pendingGet = rooms.get("user-a", "device-a");
await new Promise((resolve) => setImmediate(resolve));
const pendingRevoke = rooms.revoke("user-a", "device-a");
releaseCreation();
await assert.rejects(pendingGet, /device revoked/);
await pendingRevoke;
assert.equal(stopped, true);
assert.equal(rooms.rooms.has(room.key), false);
await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/);
});
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
"use strict";
const assert = require("node:assert/strict");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { mkdtempSync, rmSync } = require("node:fs");
const test = require("node:test");
const {
CODEX_IPC_METHOD_VERSIONS,
CodexIpcClient,
IpcFrameDecoder,
applyIpcPatches,
encodeIpcFrame,
} = require("../vscode-extension/dist/codexIpc.js");
function waitFor(predicate, timeoutMs = 2_000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const poll = () => {
if (predicate()) return resolve();
if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture"));
setTimeout(poll, 5);
};
poll();
});
}
test("private IPC framing handles split UTF-8 frames", () => {
const message = {
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: "client-1",
version: 1,
params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" },
};
const frame = encodeIpcFrame(message);
const decoder = new IpcFrameDecoder();
const first = decoder.push(frame.subarray(0, 3));
assert.deepEqual(first, []);
const second = decoder.push(frame.subarray(3, frame.length - 1));
assert.deepEqual(second, []);
assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]);
});
test("applyIpcPatches updates a conversation snapshot", () => {
const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" };
const next = applyIpcPatches(initial, [
{ op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" },
{ op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } },
{ op: "replace", path: ["status"], value: "active" },
]);
assert.deepEqual(next, {
turns: [{ items: [{ text: "new" }, { text: "second" }] }],
status: "active",
});
});
test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => {
const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-"));
const socketPath = path.join(temp, "ipc.sock");
const threadId = "11111111-1111-4111-8111-111111111111";
const ownerId = "owner-client";
const requests = [];
const followingBroadcasts = [];
let fixtureSocket;
const server = net.createServer((socket) => {
fixtureSocket = socket;
const decoder = new IpcFrameDecoder();
socket.on("data", (chunk) => {
for (const message of decoder.push(chunk)) {
if (message.type === "request" && message.method === "initialize") {
socket.write(encodeIpcFrame({
type: "response",
requestId: message.requestId,
resultType: "success",
method: "initialize",
handledByClientId: "fixture-client",
result: { clientId: "fixture-client" },
}));
continue;
}
if (message.type === "broadcast" && message.method === "thread-stream-following-changed") {
followingBroadcasts.push(message);
const target = message.sourceClientId;
socket.write(encodeIpcFrame({
type: "broadcast",
method: "thread-stream-state-changed",
sourceClientId: ownerId,
targetClientIds: [target],
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"],
params: {
conversationId: threadId,
hostId: "local",
change: {
type: "snapshot",
revision: 1,
conversationState: { id: threadId, title: "fixture", turns: [], requests: [] },
},
},
}));
continue;
}
if (message.type === "request") {
requests.push(message);
socket.write(encodeIpcFrame({
type: "response",
requestId: message.requestId,
resultType: "success",
method: message.method,
handledByClientId: ownerId,
result: { method: message.method, ok: true },
}));
}
}
});
});
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const client = new CodexIpcClient({ socketPath, autoReconnect: false });
const streamEvents = [];
client.onStreamEvent((event) => streamEvents.push(event));
await client.connect();
await client.followConversation(threadId);
await waitFor(() => streamEvents.some((event) => event.kind === "snapshot"));
assert.equal(client.getConversationState(threadId).ownerClientId, ownerId);
fixtureSocket.write(encodeIpcFrame({
type: "broadcast",
method: "thread-stream-following-status-requested",
sourceClientId: ownerId,
targetClientIds: ["fixture-client"],
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"],
params: { conversationId: threadId, hostId: "local" },
}));
await waitFor(() => followingBroadcasts.length >= 2);
assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]);
assert.deepEqual(followingBroadcasts[1].params, {
conversationId: threadId,
hostId: "local",
following: true,
});
await client.startTurn(threadId, "hello", { ownerClientId: ownerId });
await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId });
await client.updateThreadSettings(threadId, {
model: "gpt-5.6-sol",
effort: "ultra",
multiAgentMode: "explicitRequestOnly",
}, { ownerClientId: ownerId });
await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId });
await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId });
await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId });
await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId });
await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId });
await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId });
assert.deepEqual(requests.map((request) => request.method), [
"thread-follower-start-turn",
"thread-follower-steer-turn",
"thread-follower-update-thread-settings",
"thread-follower-interrupt-turn",
"thread-follower-command-approval-decision",
"thread-follower-file-approval-decision",
"thread-follower-permissions-request-approval-response",
"thread-follower-submit-user-input",
"thread-follower-submit-mcp-server-elicitation-response",
]);
assert.deepEqual(requests[0].params, {
conversationId: threadId,
turnStart: {
request: {
threadId,
input: [{ type: "text", text: "hello", text_elements: [] }],
},
context: { inheritThreadSettings: true },
},
});
assert.deepEqual(requests[2].params, {
conversationId: threadId,
threadSettings: {
model: "gpt-5.6-sol",
effort: "ultra",
multiAgentMode: "explicitRequestOnly",
},
});
assert.equal(requests[2].version, 1);
assert.deepEqual(requests[3].params, {
conversationId: threadId,
mode: "user-stop",
expectedTurnId: "turn-1",
});
assert.equal(requests[3].version, 4);
assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" });
assert.deepEqual(requests[8].params, {
conversationId: threadId,
requestId: 11,
response: { action: "decline", content: null, _meta: null },
});
await client.dispose();
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(temp, { recursive: true, force: true });
}
});
+57
View File
@@ -0,0 +1,57 @@
"use strict";
const assert = require("node:assert/strict");
const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js");
const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js");
function temporaryDirectory() {
return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-"));
}
test("resolveCodexCommand finds a bare command in PATH", () => {
const root = temporaryDirectory();
try {
const bin = path.join(root, "bin");
const executable = path.join(bin, "codex-test");
mkdirSync(bin);
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
chmodSync(executable, 0o755);
assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => {
const root = temporaryDirectory();
try {
const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex");
mkdirSync(path.dirname(executable), { recursive: true });
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
chmodSync(executable, 0o755);
assert.equal(
resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }),
executable,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a missing explicit command reports a full-path setting hint", () => {
assert.throws(
() => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }),
/Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/,
);
});
test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => {
const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] });
await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/);
client.close();
});
+105
View File
@@ -0,0 +1,105 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js");
class FakeRelay {
constructor({ connectError } = {}) {
this.connectError = connectError;
this.frames = [];
this.closed = false;
this.listeners = { message: new Set(), open: new Set(), close: new Set() };
}
async connect() {
if (this.connectError) throw this.connectError;
for (const listener of this.listeners.open) listener();
}
send(frame) { this.frames.push(frame); }
close() { this.closed = true; }
onMessage(listener) { return this.add("message", listener); }
onOpen(listener) { return this.add("open", listener); }
onClose(listener) { return this.add("close", listener); }
add(type, listener) {
this.listeners[type].add(listener);
return { dispose: () => this.listeners[type].delete(listener) };
}
receive(frame) { for (const listener of this.listeners.message) listener(frame); }
disconnect(error) { for (const listener of this.listeners.close) listener(error); }
}
test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay({ connectError: new Error("cloud offline") });
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
await relay.connect();
assert.equal(relay.isConnected("local"), true);
assert.equal(relay.isConnected("cloud"), false);
relay.send({ kind: "event", type: "session.snapshot" });
assert.equal(local.frames.length, 1);
assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect");
relay.close();
assert.equal(local.closed, true);
assert.equal(cloud.closed, true);
});
test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
const messages = [];
const closes = [];
relay.onMessage((frame) => messages.push(frame));
relay.onClose((error) => closes.push(error?.message));
await relay.connect();
assert.equal(relay.isConnected("local"), true);
assert.equal(relay.isConnected("cloud"), true);
cloud.receive({ kind: "command", type: "turn.start" });
assert.equal(messages.length, 1);
local.disconnect(new Error("local offline"));
assert.equal(relay.isConnected("local"), false);
assert.deepEqual(closes, []);
cloud.disconnect(new Error("cloud offline"));
assert.deepEqual(closes, ["cloud offline"]);
relay.close();
});
test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
let opens = 0;
relay.onOpen(() => { opens += 1; });
await relay.connect();
assert.equal(opens, 2);
cloud.disconnect(new Error("cloud offline"));
for (const listener of cloud.listeners.open) listener();
assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot");
relay.close();
});
test("CompositeRelayTransport fails when the required local relay cannot connect", async () => {
const local = new FakeRelay({ connectError: new Error("local offline") });
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
await assert.rejects(relay.connect(), /local: local offline/);
assert.equal(local.closed, true);
assert.equal(cloud.closed, true);
});
@@ -0,0 +1,34 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
test("VS Code runtime strings have English and Simplified Chinese bundles", () => {
const extensionRoot = path.join(__dirname, "..", "vscode-extension");
const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8");
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8"));
const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8"));
const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8"));
const keys = [...source.matchAll(/(?<![A-Za-z])t\("([^"]+)"/g)].map((match) => match[1]);
assert.equal(manifest.l10n, "./l10n");
assert.ok(keys.length > 20, "expected runtime-localized extension strings");
for (const key of new Set(keys)) {
assert.equal(english[key], key, `missing English source string: ${key}`);
assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`);
assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`);
}
});
test("production copy scripts require the Vue build instead of silently falling back", () => {
const projectRoot = path.join(__dirname, "..");
const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8");
const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8");
const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8"));
assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/);
assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/);
assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/);
});
+120
View File
@@ -0,0 +1,120 @@
const assert = require("node:assert/strict");
const http = require("node:http");
const path = require("node:path");
const test = require("node:test");
const {
LocalRelayController,
localRelayTarget,
relayHealthAvailable,
} = require("../vscode-extension/dist/localRelay.js");
test("local relay target accepts only loopback ws URLs", () => {
assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), {
host: "127.0.0.1",
port: 8898,
healthUrl: "http://127.0.0.1:8898/api/health",
webUrl: "http://127.0.0.1:8898/",
});
assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined);
assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined);
assert.equal(localRelayTarget("not a url"), undefined);
});
test("local relay health probe recognizes a responding HTTP service", async (t) => {
const server = http.createServer((request, response) => {
if (request.url === "/api/health") {
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
} else if (request.url === "/aborted") {
response.writeHead(200, { "content-type": "application/json" });
response.write('{"ok":');
response.destroy();
} else if (request.url === "/drip") {
response.writeHead(200, { "content-type": "application/json" });
const interval = setInterval(() => response.write(" "), 10);
response.on("close", () => clearInterval(interval));
} else {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true);
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false);
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false);
const startedAt = Date.now();
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false);
assert.ok(Date.now() - startedAt < 500);
});
test("local relay controller starts and stops a bundled loopback relay", async () => {
let starts = 0;
let stops = 0;
class FakeRelay {
async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; }
async stop() { stops += 1; }
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
probeTimeoutMs: 20,
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
});
assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true);
assert.equal(starts, 1);
await controller.stop();
assert.equal(stops, 1);
});
test("local relay controller does not leak a relay when stopped during startup", async () => {
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
let startEntered;
const entered = new Promise((resolve) => { startEntered = resolve; });
let stops = 0;
class SlowRelay {
async start() {
startEntered();
await startGate;
return { host: "127.0.0.1", port: 65533 };
}
async stop() { stops += 1; }
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
probeTimeoutMs: 20,
loadRelayModule: () => ({ CodexRelay: SlowRelay }),
});
const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect");
await entered;
const stopping = controller.stop();
releaseStart();
await Promise.all([starting, stopping]);
assert.equal(stops, 1);
});
test("local relay controller does not start after stop wins an in-flight health probe", async () => {
let resolveProbe;
const probe = new Promise((resolve) => { resolveProbe = resolve; });
let probeEntered;
const entered = new Promise((resolve) => { probeEntered = resolve; });
let starts = 0;
class FakeRelay {
async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; }
async stop() {}
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
probeRelayHealth: async () => {
probeEntered();
return probe;
},
});
const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect");
await entered;
await controller.stop();
resolveProbe(false);
assert.equal(await ensuring, false);
assert.equal(starts, 0);
});
@@ -0,0 +1,194 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js");
const i18n = require("../public/i18n.js");
function embeddedWindow() {
const listeners = new Map();
const posts = [];
const parent = { postMessage: (message, origin) => posts.push({ message, origin }) };
const bodyClasses = new Set();
const documentElement = { dataset: {}, style: {} };
const windowLike = {
location: { search: "?embed=aether", origin: "https://aether.example" },
parent,
document: {
body: { classList: { add: (value) => bodyClasses.add(value) } },
documentElement,
},
addEventListener: (name, listener) => listeners.set(name, listener),
removeEventListener: (name, listener) => {
if (listeners.get(name) === listener) listeners.delete(name);
},
};
return { bodyClasses, documentElement, listeners, parent, posts, windowLike };
}
test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => {
assert.equal(isAetherEmbed({ search: "" }), false);
assert.equal(isAetherEmbed({ search: "?embed=other" }), false);
assert.equal(isAetherEmbed({ search: "?embed=aether" }), true);
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
assert.equal(bridge.active, true);
bridge.start();
assert.equal(fixture.bodyClasses.has("embed-aether"), true);
assert.deepEqual(fixture.posts, [{
message: { v: 1, type: "aether-vscodex/ready" },
origin: "https://aether.example",
}]);
});
test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => {
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
bridge.start();
const dispatch = fixture.listeners.get("message");
const connect = {
v: 1,
type: "aether-vscodex/connect",
ticket: "one-time-ticket",
wsUrl: "/api/vscodex/ws",
locale: "en-US",
theme: "dark",
};
dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect });
dispatch({ origin: "https://aether.example", source: {}, data: connect });
let received = null;
bridge.on("connect", (message) => { received = message; });
assert.equal(received, null);
dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect });
assert.equal(received.ticket, "one-time-ticket");
assert.equal(fixture.documentElement.dataset.theme, "dark");
const second = embeddedWindow();
const bufferedBridge = createAetherEmbedBridge(second.windowLike);
bufferedBridge.start();
second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect });
let buffered = null;
bufferedBridge.on("connect", (message) => { buffered = message; });
assert.equal(buffered.ticket, "one-time-ticket");
});
test("bridge ticket requests never place the ticket in a URL", () => {
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
bridge.start();
bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" });
assert.deepEqual(fixture.posts.at(-1), {
message: {
v: 1,
type: "aether-vscodex/request-ticket",
reason: "disconnected",
deviceId: "device-1",
},
origin: "https://aether.example",
});
});
test("locale dictionary covers static shell and core dynamic status text", () => {
assert.equal(i18n.translate("设置", "en-US"), "Settings");
assert.equal(i18n.translate("中文", "en-US"), "Chinese");
assert.equal(i18n.translate("正在思考", "en-US"), "Thinking");
assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files");
assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s");
assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed");
assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout");
assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host");
assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md");
assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out");
assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)");
assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed");
assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s");
assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s");
assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi");
assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s");
assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed");
assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted");
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置");
assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置");
assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s");
assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”...");
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s");
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files");
assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s");
assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan");
assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s");
assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)");
assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative");
assert.equal(i18n.translate("控制模式", "en-US"), "Control mode");
assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation");
assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently");
assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes");
assert.equal(i18n.translate("Settings", "zh-CN"), "设置");
assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN");
assert.equal(i18n.normalizeLocale("en-GB"), "en-US");
});
test("renderer-owned dynamic labels have English fallbacks without translating host values", () => {
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8");
// Command/path/title values are appended after a locale-specific prefix;
// they are never passed through the translator as a whole.
assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/);
assert.match(app, /uiWithRaw\("已读取 ", "Read ",/);
assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/);
});
test("public shell uses relative assets and embedded startup skips the health probe", () => {
const publicRoot = path.join(__dirname, "..", "public");
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
assert.match(html, /href="\.\/style\.css"/);
assert.match(html, /src="\.\/embed-bridge\.js"/);
assert.match(html, /src="\.\/i18n\.js"/);
assert.match(html, /src="\.\/app\.js"/);
assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/);
assert.doesNotMatch(app, /ticket=.*state\.embedTicket/);
assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/);
assert.match(app, /outputContent\.textContent = t\("无输出"\)/);
assert.match(app, /button\.title = t\(title\)/);
assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/);
assert.match(app, /return t\("需要远程确认或输入"\)/);
assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/);
assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/);
assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/);
});
test("control mode is snapshot-authoritative and gates independent session actions", () => {
const publicRoot = path.join(__dirname, "..", "public");
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/);
assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/);
assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/);
assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/);
assert.match(app, /sessionList: source\.sessionList === true/);
assert.match(app, /Boolean\(state\.sessionListCommandId\)/);
assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/);
assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/);
assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/);
assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/);
assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/);
assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/);
assert.match(app, /sessionPickerButton\.disabled = !listAllowed/);
});
+206
View File
@@ -0,0 +1,206 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const { RelayClient } = require("../vscode-extension/dist/relayClient.js");
class FakeWebSocket extends EventEmitter {
static instances = [];
constructor(url) {
super();
this.url = url;
this.readyState = 0;
this.sent = [];
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = 1;
this.emit("open");
}
receive(frame) {
this.emit("message", Buffer.from(JSON.stringify(frame)));
}
send(data) {
this.sent.push(JSON.parse(data));
}
close() {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
}
}
test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const firstConnect = client.connect();
const first = FakeWebSocket.instances[0];
first.open();
assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]);
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } });
assert.equal(first.sent.length, 2, "application event must not be sent before authentication");
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
await firstConnect;
assert.equal(first.sent.length, 3);
assert.equal(first.sent[2].id, "event-1");
first.close();
const secondConnect = client.connect();
const second = FakeWebSocket.instances[1];
second.open();
assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]);
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } });
assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated");
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
await secondConnect;
assert.equal(second.sent.length, 3);
assert.equal(second.sent[2].id, "event-2");
});
test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
maxFrameBytes: 4_096,
maxQueuedBytes: 4_096,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } });
client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } });
client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } });
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } });
assert.ok(client.queueBytes <= 4_096);
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
await connecting;
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]);
});
test("RelayClient evicts reconstructible projections before queued control events", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
maxFrameBytes: 4_096,
maxQueuedBytes: 2_500,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } });
client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } });
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } });
assert.ok(client.queueBytes <= 2_500);
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
await connecting;
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
assert.deepEqual(queuedIds, ["approval", "command"]);
});
test("RelayClient supports a tokenless local handshake", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://127.0.0.1:8787/v1/connect",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]);
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
await connecting;
client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} });
assert.equal(socket.sent.length, 2);
assert.equal(socket.sent[1].type, "connection.opened");
});
test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://127.0.0.1:8787/v1/connect",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
await connecting;
const historyText = "x".repeat(512 * 1024);
assert.doesNotThrow(() => client.send({
v: 1,
kind: "event",
type: "session.snapshot",
id: "large-history-snapshot",
sessionId: "session-local",
payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] },
}));
assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length);
});
test("RelayClient ignores late events from a replaced socket", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const firstConnect = client.connect();
const first = FakeWebSocket.instances[0];
first.open();
client.close();
const secondConnect = client.connect();
const second = FakeWebSocket.instances[1];
second.open();
// Simulate a delayed event from the old socket after the replacement.
first.open();
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket");
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } });
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
await secondConnect;
assert.equal(second.sent[2].id, "event-after-replace");
await assert.rejects(firstConnect);
});
File diff suppressed because it is too large Load Diff
@@ -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();
});