mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user