mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
2248 lines
88 KiB
JavaScript
2248 lines
88 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
|
|
const { CodexIpcAgentAdapter } = require("../vscode-extension/dist/codexIpcAgentAdapter.js");
|
|
|
|
const THREAD_ID = "11111111-1111-4111-8111-111111111111";
|
|
const SECOND_THREAD_ID = "22222222-2222-4222-8222-222222222222";
|
|
const STALE_THREAD_ID = "33333333-3333-4333-8333-333333333333";
|
|
const THIRD_THREAD_ID = "44444444-4444-4444-8444-444444444444";
|
|
const ULID_THREAD_ID = "01a0399e790373d63b6fcde4e1f97d02";
|
|
|
|
async function waitFor(predicate, timeoutMs = 1_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
assert.fail(`condition was not met within ${timeoutMs}ms`);
|
|
}
|
|
|
|
class FakeIpcClient {
|
|
constructor(state, emitSnapshot = true) {
|
|
this.socketPath = "/tmp/fake-codex-ipc.sock";
|
|
this.state = state;
|
|
this.emitSnapshot = emitSnapshot;
|
|
this.broadcastListeners = new Set();
|
|
this.streamListeners = new Set();
|
|
this.errorListeners = new Set();
|
|
this.closeListeners = new Set();
|
|
this.calls = [];
|
|
this.streamStates = new Map();
|
|
}
|
|
|
|
subscribe(set, listener) {
|
|
set.add(listener);
|
|
return { dispose: () => set.delete(listener) };
|
|
}
|
|
|
|
onBroadcast(listener) { return this.subscribe(this.broadcastListeners, listener); }
|
|
onStreamEvent(listener) { return this.subscribe(this.streamListeners, listener); }
|
|
onError(listener) { return this.subscribe(this.errorListeners, listener); }
|
|
onClose(listener) { return this.subscribe(this.closeListeners, listener); }
|
|
getClientId() { return "follower"; }
|
|
getConversationState(threadId) {
|
|
const state = this.streamStates.get(threadId);
|
|
if (!state) return undefined;
|
|
return {
|
|
...state,
|
|
conversationState: JSON.parse(JSON.stringify(state.conversationState)),
|
|
};
|
|
}
|
|
async connect() { this.calls.push({ method: "connect" }); return "follower"; }
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
return threadId === THREAD_ID ? "owner" : null;
|
|
}
|
|
async followConversation(threadId, following, options) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
if (!following) {
|
|
this.streamStates.delete(threadId);
|
|
return;
|
|
}
|
|
if (this.emitSnapshot) queueMicrotask(() => this.emitState(this.state, 1, "snapshot", threadId));
|
|
}
|
|
emitState(state, revision, kind = "snapshot", threadId = THREAD_ID, ownerClientId = "owner") {
|
|
const event = {
|
|
kind,
|
|
conversationId: threadId,
|
|
hostId: "local",
|
|
ownerClientId,
|
|
revision,
|
|
conversationState: state,
|
|
raw: { type: "broadcast", method: "thread-stream-state-changed", version: 11 },
|
|
};
|
|
this.streamStates.set(threadId, {
|
|
conversationId: threadId,
|
|
hostId: "local",
|
|
ownerClientId,
|
|
revision,
|
|
conversationState: JSON.parse(JSON.stringify(state)),
|
|
});
|
|
for (const listener of this.streamListeners) listener(event);
|
|
}
|
|
emitFollowing(threadId, following, options = {}) {
|
|
const frame = {
|
|
type: "broadcast",
|
|
method: "thread-stream-following-changed",
|
|
version: options.version ?? 1,
|
|
sourceClientId: options.sourceClientId ?? "owner",
|
|
...(options.targetClientIds ? { targetClientIds: options.targetClientIds } : {}),
|
|
params: {
|
|
conversationId: threadId,
|
|
hostId: options.hostId ?? "local",
|
|
following,
|
|
},
|
|
};
|
|
for (const listener of this.broadcastListeners) listener(frame);
|
|
}
|
|
emitClientStatus(clientId, status, options = {}) {
|
|
const frame = {
|
|
type: "broadcast",
|
|
method: "client-status-changed",
|
|
version: options.version ?? 0,
|
|
sourceClientId: options.sourceClientId ?? clientId,
|
|
params: {
|
|
clientId,
|
|
clientType: options.clientType ?? "vscode-webview",
|
|
status,
|
|
},
|
|
};
|
|
for (const listener of this.broadcastListeners) listener(frame);
|
|
}
|
|
emitClose(error = new Error("fixture IPC socket closed")) {
|
|
for (const listener of this.closeListeners) listener(error);
|
|
}
|
|
async startTurn(threadId, input, options) {
|
|
this.calls.push({ method: "startTurn", threadId, input, options });
|
|
return { turnId: "turn-new" };
|
|
}
|
|
async steerTurn(threadId, input, options) {
|
|
this.calls.push({ method: "steerTurn", threadId, input, options });
|
|
return { turnId: "turn-new" };
|
|
}
|
|
async updateThreadSettings(threadId, settings, options) {
|
|
this.calls.push({ method: "updateThreadSettings", threadId, settings, options });
|
|
return { updated: true };
|
|
}
|
|
async interruptTurn(threadId, options) {
|
|
this.calls.push({ method: "interruptTurn", threadId, options });
|
|
return { interrupted: true };
|
|
}
|
|
async respondCommandApproval(threadId, requestId, decision, options) {
|
|
this.calls.push({ method: "respondCommandApproval", threadId, requestId, decision, options });
|
|
return { accepted: true };
|
|
}
|
|
async respondFileApproval(threadId, requestId, decision, options) {
|
|
this.calls.push({ method: "respondFileApproval", threadId, requestId, decision, options });
|
|
return { accepted: true };
|
|
}
|
|
async respondPermissionsApproval(threadId, requestId, response, options) {
|
|
this.calls.push({ method: "respondPermissionsApproval", threadId, requestId, response, options });
|
|
return { accepted: true };
|
|
}
|
|
async respondUserInput(threadId, requestId, response, options) {
|
|
this.calls.push({ method: "respondUserInput", threadId, requestId, response, options });
|
|
return { accepted: true };
|
|
}
|
|
async respondMcpElicitation(threadId, requestId, response, options) {
|
|
this.calls.push({ method: "respondMcpElicitation", threadId, requestId, response, options });
|
|
return { accepted: true };
|
|
}
|
|
async loadCompleteHistory() { this.calls.push({ method: "loadCompleteHistory" }); }
|
|
async dispose() { this.calls.push({ method: "dispose" }); }
|
|
}
|
|
|
|
class DiscoveryIpcClient extends FakeIpcClient {
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
return "owner";
|
|
}
|
|
}
|
|
|
|
class MultiSessionIpcClient extends FakeIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states.get(THREAD_ID));
|
|
this.states = states;
|
|
this.owners = owners;
|
|
}
|
|
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
return this.owners[threadId] || null;
|
|
}
|
|
|
|
async followConversation(threadId, following, options) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
if (!following) {
|
|
this.streamStates.delete(threadId);
|
|
return;
|
|
}
|
|
if (this.states.has(threadId)) {
|
|
const owner = this.owners[threadId] || "owner";
|
|
queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Simulates a target owned by another Codex process that never answers follow. */
|
|
class NoSnapshotSwitchIpcClient extends MultiSessionIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states, owners);
|
|
this.targetFollowFailed = false;
|
|
}
|
|
|
|
async followConversation(threadId, following, options) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
if (!following) {
|
|
this.streamStates.delete(threadId);
|
|
return;
|
|
}
|
|
if (threadId === SECOND_THREAD_ID) {
|
|
this.targetFollowFailed = true;
|
|
return;
|
|
}
|
|
// The old owner is deliberately silent after the failed target attach;
|
|
// the adapter must restore from its cached stream state instead.
|
|
if (this.targetFollowFailed && threadId === THREAD_ID) return;
|
|
if (this.states.has(threadId)) {
|
|
const owner = this.owners[threadId] || "owner";
|
|
queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
|
|
}
|
|
}
|
|
}
|
|
|
|
class ThrowingTargetFollowIpcClient extends MultiSessionIpcClient {
|
|
async followConversation(threadId, following, options) {
|
|
if (threadId === SECOND_THREAD_ID && following) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
throw new Error("target follow failed immediately");
|
|
}
|
|
return super.followConversation(threadId, following, options);
|
|
}
|
|
}
|
|
|
|
class OwnerChangingIpcClient extends MultiSessionIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states, owners);
|
|
this.targetDiscoveryCount = 0;
|
|
}
|
|
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
if (threadId === SECOND_THREAD_ID) {
|
|
this.targetDiscoveryCount += 1;
|
|
return this.targetDiscoveryCount === 1 ? "owner-b" : "owner-c";
|
|
}
|
|
return this.owners[threadId] || null;
|
|
}
|
|
}
|
|
|
|
/** Delays the first target snapshot so list probing overlaps a session selection request. */
|
|
class DelayedProbeIpcClient extends MultiSessionIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states, owners);
|
|
this.targetFollowCount = 0;
|
|
}
|
|
|
|
async followConversation(threadId, following, options) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
if (!following) {
|
|
this.streamStates.delete(threadId);
|
|
return;
|
|
}
|
|
if (!this.states.has(threadId)) return;
|
|
const owner = this.owners[threadId] || "owner";
|
|
if (threadId === SECOND_THREAD_ID) {
|
|
this.targetFollowCount += 1;
|
|
const delay = this.targetFollowCount === 1 ? 30 : 0;
|
|
setTimeout(() => this.emitState(this.states.get(threadId), this.targetFollowCount, "snapshot", threadId, owner), delay);
|
|
return;
|
|
}
|
|
queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
|
|
}
|
|
}
|
|
|
|
/** Delays the first waiting attach so a newer official route can supersede it. */
|
|
class WaitingRouteRaceIpcClient extends MultiSessionIpcClient {
|
|
async followConversation(threadId, following, options) {
|
|
this.calls.push({ method: "followConversation", threadId, following, options });
|
|
if (!following) {
|
|
this.streamStates.delete(threadId);
|
|
return;
|
|
}
|
|
if (!this.states.has(threadId)) return;
|
|
const owner = this.owners[threadId] || "owner";
|
|
const delay = threadId === THREAD_ID ? 30 : 0;
|
|
setTimeout(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner), delay);
|
|
}
|
|
}
|
|
|
|
/** Holds fallback discovery so an official route can arrive while it is stale. */
|
|
class WaitingPollRouteRaceIpcClient extends WaitingRouteRaceIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states, owners);
|
|
this.delayFallbackDiscovery = false;
|
|
this.fallbackDiscoveryStarted = false;
|
|
this.fallbackOwner = new Promise((resolve) => {
|
|
this.resolveFallbackOwner = resolve;
|
|
});
|
|
}
|
|
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
if (threadId === SECOND_THREAD_ID && this.delayFallbackDiscovery) {
|
|
this.fallbackDiscoveryStarted = true;
|
|
return this.fallbackOwner;
|
|
}
|
|
return this.owners[threadId] || null;
|
|
}
|
|
|
|
releaseFallbackDiscovery() {
|
|
this.delayFallbackDiscovery = false;
|
|
const resolve = this.resolveFallbackOwner;
|
|
this.resolveFallbackOwner = null;
|
|
if (resolve) resolve(this.owners[SECOND_THREAD_ID] || null);
|
|
}
|
|
}
|
|
|
|
/** Holds the post-snapshot owner confirmation so assertions run mid-switch. */
|
|
class DelayedTargetOwnerConfirmationIpcClient extends MultiSessionIpcClient {
|
|
constructor(states, owners = {}) {
|
|
super(states, owners);
|
|
this.targetDiscoveryCount = 0;
|
|
this.targetConfirmationStarted = false;
|
|
this.targetOwnerConfirmation = new Promise((resolve) => {
|
|
this.resolveTargetOwnerConfirmation = resolve;
|
|
});
|
|
}
|
|
|
|
async findThreadOwner(threadId) {
|
|
this.calls.push({ method: "findThreadOwner", threadId });
|
|
const owner = this.owners[threadId] || null;
|
|
if (threadId !== SECOND_THREAD_ID) return owner;
|
|
this.targetDiscoveryCount += 1;
|
|
if (this.targetDiscoveryCount === 1) return owner;
|
|
this.targetConfirmationStarted = true;
|
|
return this.targetOwnerConfirmation;
|
|
}
|
|
|
|
releaseTargetOwnerConfirmation() {
|
|
if (!this.resolveTargetOwnerConfirmation) return;
|
|
const resolve = this.resolveTargetOwnerConfirmation;
|
|
this.resolveTargetOwnerConfirmation = null;
|
|
resolve(this.owners[SECOND_THREAD_ID] || null);
|
|
}
|
|
}
|
|
|
|
function fixtureState(requests = []) {
|
|
return {
|
|
id: THREAD_ID,
|
|
title: "fixture session",
|
|
cwd: "/tmp/workspace",
|
|
turns: [{
|
|
id: "turn-old",
|
|
status: "completed",
|
|
items: [
|
|
{ type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello" }] },
|
|
{ type: "agentMessage", id: "agent-1", text: "hi from VS Code" },
|
|
],
|
|
}],
|
|
requests,
|
|
threadRuntimeStatus: { type: "idle" },
|
|
};
|
|
}
|
|
|
|
test("IPC adapter follows an existing session and routes input/approval without spawning", async () => {
|
|
const client = new FakeIpcClient(fixtureState([
|
|
{
|
|
id: 7,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: { threadId: THREAD_ID, turnId: "turn-old", command: "echo safe" },
|
|
},
|
|
{
|
|
id: "question-1",
|
|
method: "item/tool/requestUserInput",
|
|
params: { threadId: THREAD_ID, turnId: "turn-old", questions: [{ id: "choice", question: "Pick one" }] },
|
|
},
|
|
]));
|
|
const events = [];
|
|
let newSessionCalls = 0;
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
openNewSession: async () => {
|
|
newSessionCalls += 1;
|
|
return { opened: true, command: "chatgpt.newCodexPanel" };
|
|
},
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
|
|
await adapter.start();
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.threadId, THREAD_ID);
|
|
assert.equal(snapshot.metadata.adapter, "codex-ipc-follower");
|
|
assert.match(snapshot.outputTail, /hi from VS Code/);
|
|
assert.deepEqual(snapshot.pendingApprovals.map((item) => item.requestId), [7]);
|
|
assert.equal(snapshot.pendingRequests.length, 2);
|
|
assert.equal(client.calls.some((call) => call.method === "startProcess"), false);
|
|
await assert.rejects(() => adapter.startThread({}), /does not create a new thread/);
|
|
assert.deepEqual(await adapter.newSession(), { opened: true, command: "chatgpt.newCodexPanel" });
|
|
assert.equal(newSessionCalls, 1);
|
|
|
|
await adapter.startTurn({ text: "remote input" });
|
|
const startCall = client.calls.find((call) => call.method === "startTurn");
|
|
assert.deepEqual(startCall.input, "remote input");
|
|
assert.equal(startCall.options.ownerClientId, "owner");
|
|
|
|
await adapter.respondApproval(7, "allow");
|
|
const approvalCall = client.calls.find((call) => call.method === "respondCommandApproval");
|
|
assert.equal(approvalCall.decision, "accept");
|
|
|
|
await adapter.respondApproval("question-1", "allow", undefined, { answers: { choice: ["yes"] } });
|
|
const inputCall = client.calls.find((call) => call.method === "respondUserInput");
|
|
assert.deepEqual(inputCall.response, { answers: { choice: { answers: ["yes"] } } });
|
|
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
|
|
assert.ok(outputSnapshot);
|
|
assert.deepEqual(outputSnapshot.payload.messages.map((item) => [item.role, item.kind, item.text]), [
|
|
["user", "user", "hello"],
|
|
["assistant", "assistant", "hi from VS Code"],
|
|
]);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter clears stale projections on close while preserving closed session identity", async () => {
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
|
|
const before = await adapter.snapshot();
|
|
assert.equal(before.threadId, THREAD_ID);
|
|
assert.equal(before.metadata.ownerClientId, "owner");
|
|
assert.equal(before.metadata.attachReady, true);
|
|
assert.ok(before.messages.length > 0);
|
|
assert.match(before.outputTail, /hi from VS Code/);
|
|
events.length = 0;
|
|
|
|
client.emitClose(new Error("fixture IPC connection lost"));
|
|
|
|
const closedEvent = events.find((event) => event.type === "connection.closed");
|
|
assert.ok(closedEvent);
|
|
assert.equal(closedEvent.threadId, THREAD_ID);
|
|
assert.equal(closedEvent.payload.ownerClientId, "owner");
|
|
assert.equal(closedEvent.payload.message, "fixture IPC connection lost");
|
|
|
|
const after = await adapter.snapshot();
|
|
assert.equal(after.state, "disconnected");
|
|
assert.equal(after.threadId, null);
|
|
assert.equal(after.turnId, null);
|
|
assert.equal(after.outputTail, "");
|
|
assert.deepEqual(after.messages, []);
|
|
assert.deepEqual(after.subagents, []);
|
|
assert.equal(after.metadata.attachReady, false);
|
|
assert.equal(Object.hasOwn(after.metadata, "ownerClientId"), false);
|
|
assert.equal(Object.hasOwn(after.metadata, "revision"), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter persists model and effort through the official follower settings envelope", async () => {
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
|
|
await adapter.start();
|
|
const result = await adapter.updateThreadSettings({
|
|
threadSettings: { model: " gpt-5.6-sol ", effort: " ultra " },
|
|
// UI-only fields must not leak into the owner request.
|
|
commandId: "ignored",
|
|
});
|
|
assert.deepEqual(result, { updated: true });
|
|
const call = client.calls.find((entry) => entry.method === "updateThreadSettings");
|
|
assert.equal(call.threadId, THREAD_ID);
|
|
assert.deepEqual(call.settings, { model: "gpt-5.6-sol", effort: "ultra" });
|
|
assert.equal(call.options.ownerClientId, "owner");
|
|
await assert.rejects(() => adapter.updateThreadSettings({ model: "" }), /non-empty string/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter projects official latest model and reasoning effort fields", async () => {
|
|
const state = fixtureState();
|
|
state.latestModel = "gpt-5.6-sol";
|
|
state.latestReasoningEffort = "ultra";
|
|
state.latestThreadSettings = {
|
|
model: "gpt-5.6-sol",
|
|
modelProvider: "aether",
|
|
effort: "ultra",
|
|
multiAgentMode: "explicitRequestOnly",
|
|
};
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const metadata = (await adapter.snapshot()).metadata;
|
|
assert.equal(metadata.model, "gpt-5.6-sol");
|
|
assert.equal(metadata.latestModel, "gpt-5.6-sol");
|
|
assert.equal(metadata.effort, "ultra");
|
|
assert.equal(metadata.latestReasoningEffort, "ultra");
|
|
assert.equal(metadata.modelProvider, "aether");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter projects a bounded model catalog from compatible state locations", async () => {
|
|
const state = fixtureState();
|
|
// Exercise all of the state names used by different official extension
|
|
// builds. The same entries should be merged rather than duplicated.
|
|
state.availableModels = [{ model: "gpt-5.6-sol" }];
|
|
state.models = [
|
|
{ model: "gpt-5.6-sol", description: "initial description" },
|
|
{ id: "gpt-5.6-terra", displayName: "5.6 Terra", efforts: ["low", "medium"] },
|
|
];
|
|
state.modelCatalog = {
|
|
data: [{
|
|
id: "gpt-5.6-sol",
|
|
model: "gpt-5.6-sol",
|
|
displayName: "5.6 Sol",
|
|
description: "通用 Codex 模型",
|
|
hidden: false,
|
|
isDefault: true,
|
|
defaultReasoningEffort: "medium",
|
|
supportedReasoningEfforts: [
|
|
{ reasoningEffort: "low", description: "快速" },
|
|
{ reasoningEffort: "high", description: "深入" },
|
|
],
|
|
apiKey: "sk-this-must-not-cross-the-relay",
|
|
capabilities: { internal: true },
|
|
}],
|
|
nextCursor: "private-cursor",
|
|
};
|
|
state.listModels = { data: [{ model: "gpt-5.6-terra", hidden: false }] };
|
|
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
const metadata = (await adapter.snapshot()).metadata;
|
|
assert.ok(Array.isArray(metadata.availableModels));
|
|
assert.deepEqual(metadata.models, metadata.availableModels);
|
|
assert.equal(metadata.availableModels.length, 2);
|
|
const sol = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-sol");
|
|
assert.deepEqual(sol, {
|
|
model: "gpt-5.6-sol",
|
|
id: "gpt-5.6-sol",
|
|
description: "initial description",
|
|
displayName: "5.6 Sol",
|
|
hidden: false,
|
|
isDefault: true,
|
|
defaultReasoningEffort: "medium",
|
|
supportedReasoningEfforts: [
|
|
{ reasoningEffort: "low", description: "快速" },
|
|
{ reasoningEffort: "high", description: "深入" },
|
|
],
|
|
});
|
|
const terra = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-terra");
|
|
assert.deepEqual(terra, {
|
|
model: "gpt-5.6-terra",
|
|
id: "gpt-5.6-terra",
|
|
displayName: "5.6 Terra",
|
|
supportedReasoningEfforts: [
|
|
{ reasoningEffort: "low" },
|
|
{ reasoningEffort: "medium" },
|
|
],
|
|
hidden: false,
|
|
});
|
|
assert.equal(JSON.stringify(metadata.availableModels).includes("sk-this-must-not-cross-the-relay"), false);
|
|
assert.equal(JSON.stringify(metadata.availableModels).includes("capabilities"), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter publishes a snapshot when only thread settings metadata changes", async () => {
|
|
const state = fixtureState();
|
|
state.latestModel = "gpt-5.6-terra";
|
|
state.latestReasoningEffort = "medium";
|
|
state.latestThreadSettings = { model: "gpt-5.6-terra", effort: "medium" };
|
|
const client = new FakeIpcClient(state);
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
events.length = 0;
|
|
|
|
const updated = structuredClone(state);
|
|
updated.latestModel = "gpt-5.6-sol";
|
|
updated.latestReasoningEffort = "ultra";
|
|
updated.latestThreadSettings = { model: "gpt-5.6-sol", effort: "ultra" };
|
|
client.emitState(updated, 2, "patches");
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const snapshots = events.filter((event) => event.type === "session.snapshot");
|
|
assert.equal(snapshots.length, 1);
|
|
assert.equal(snapshots[0].payload.metadata.latestModel, "gpt-5.6-sol");
|
|
assert.equal(snapshots[0].payload.metadata.latestReasoningEffort, "ultra");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter projects official activity, timestamps, command details, and turn duration", async () => {
|
|
const turnStartedAtMs = Date.now() - 20_000;
|
|
const finalAssistantStartedAtMs = turnStartedAtMs + 15_000;
|
|
const state = fixtureState();
|
|
state.turns[0] = {
|
|
id: "turn-timed",
|
|
status: "completed",
|
|
turnStartedAtMs,
|
|
finalAssistantStartedAtMs,
|
|
durationMs: 18_000,
|
|
commandExecutionStartedAtMsById: { "command-1": turnStartedAtMs + 2_000 },
|
|
items: [
|
|
{ type: "userMessage", id: "user-timed", content: [{ type: "text", text: "**run** it" }] },
|
|
{
|
|
type: "commandExecution",
|
|
id: "command-1",
|
|
command: ["/bin/zsh", "-lc", "echo status-test"],
|
|
commandActions: [
|
|
{ type: "unknown", command: "/bin/zsh" },
|
|
{ type: "unknown", cmd: "echo status-test" },
|
|
],
|
|
cwd: "/tmp/workspace",
|
|
shellName: "zsh",
|
|
status: "completed",
|
|
aggregatedOutput: "status-test",
|
|
durationMs: 250,
|
|
exitCode: 0,
|
|
},
|
|
{ type: "agentMessage", id: "agent-timed", text: "done", phase: "final_answer" },
|
|
],
|
|
};
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.status.activity, "completed");
|
|
assert.equal(snapshot.durationMs, 18_000);
|
|
const [user, command, assistant] = snapshot.messages;
|
|
assert.equal(user.startedAtMs, turnStartedAtMs);
|
|
assert.equal(command.command, "echo status-test");
|
|
assert.deepEqual(command.commandActions, [
|
|
{ type: "unknown", command: "/bin/zsh", cmd: "/bin/zsh" },
|
|
{ type: "unknown", cmd: "echo status-test", command: "echo status-test" },
|
|
]);
|
|
assert.equal(command.cwd, "/tmp/workspace");
|
|
assert.equal(command.shellName, "zsh");
|
|
assert.equal(command.startedAtMs, turnStartedAtMs + 2_000);
|
|
assert.equal(command.durationMs, 250);
|
|
assert.equal(command.exitCode, 0);
|
|
assert.equal(assistant.startedAtMs, finalAssistantStartedAtMs);
|
|
assert.equal(assistant.durationMs, 18_000);
|
|
|
|
const active = structuredClone(state);
|
|
active.turns[0].status = "inProgress";
|
|
delete active.turns[0].durationMs;
|
|
active.turns[0].items = [{ type: "reasoning", id: "reasoning-1", summary: ["checking"] }];
|
|
client.emitState(active, 2, "patches");
|
|
const activeSnapshot = await adapter.snapshot();
|
|
assert.equal(activeSnapshot.activity, "thinking");
|
|
assert.equal(activeSnapshot.turnStatus, "inprogress");
|
|
assert.equal(activeSnapshot.turnId, "turn-timed");
|
|
assert.equal(activeSnapshot.messages[0].turnStatus, "inProgress");
|
|
|
|
active.turns[0].items = [{
|
|
type: "fileChange",
|
|
id: "edit-1",
|
|
status: "inProgress",
|
|
changes: [{ path: "src/example.ts", diff: "+const remote = true;" }],
|
|
}];
|
|
client.emitState(active, 3, "patches");
|
|
const editingSnapshot = await adapter.snapshot();
|
|
assert.equal(editingSnapshot.activity, "editing");
|
|
|
|
active.turns[0].items = [{
|
|
type: "commandExecution",
|
|
id: "command-active",
|
|
status: "inProgress",
|
|
command: "echo running",
|
|
}];
|
|
client.emitState(active, 4, "patches");
|
|
const runningSnapshot = await adapter.snapshot();
|
|
assert.equal(runningSnapshot.activity, "running");
|
|
|
|
active.requests = [{
|
|
id: "approval-active",
|
|
method: "item/commandExecution/requestApproval",
|
|
params: { threadId: THREAD_ID, turnId: "turn-timed", command: "echo approve" },
|
|
}];
|
|
client.emitState(active, 5, "patches");
|
|
const waitingSnapshot = await adapter.snapshot();
|
|
assert.equal(waitingSnapshot.activity, "waiting_approval");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter keeps command-action-only items visible and suppresses shell bootstraps", async () => {
|
|
const state = fixtureState();
|
|
state.turns[0] = {
|
|
id: "turn-command-actions",
|
|
status: "inProgress",
|
|
items: [
|
|
{
|
|
// Older snapshots can expose a generic shell item while retaining
|
|
// the official commandActions payload.
|
|
type: "shell",
|
|
id: "command-actions-only",
|
|
status: "inProgress",
|
|
aggregatedOutput: "",
|
|
commandActions: [
|
|
{ type: "unknown", command: "/bin/zsh" },
|
|
{ type: "search", cmd: "rg --files", path: "src" },
|
|
],
|
|
cwd: "/tmp/workspace",
|
|
shellName: "zsh",
|
|
},
|
|
{
|
|
type: "commandExecution",
|
|
id: "shell-bootstrap-only",
|
|
status: "inProgress",
|
|
aggregatedOutput: "",
|
|
command: "/bin/zsh",
|
|
commandActions: [{ type: "unknown", command: "/bin/zsh" }],
|
|
},
|
|
{
|
|
type: "commandExecution",
|
|
id: "wrapped-command",
|
|
status: "inProgress",
|
|
aggregatedOutput: "",
|
|
command: "/bin/zsh -lc 'printf wrapped'",
|
|
},
|
|
{
|
|
type: "commandExecution",
|
|
id: "wrapped-action",
|
|
status: "inProgress",
|
|
aggregatedOutput: "",
|
|
commandActions: ["/bin/zsh", "/bin/zsh -lc 'printf action-wrapped'"],
|
|
},
|
|
],
|
|
};
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.messages.length, 3);
|
|
const actionsOnly = snapshot.messages.find((message) => message.itemId === "command-actions-only");
|
|
assert.ok(actionsOnly);
|
|
assert.equal(actionsOnly.command, "rg --files");
|
|
assert.equal(actionsOnly.text, "rg --files");
|
|
assert.equal(actionsOnly.cwd, "/tmp/workspace");
|
|
assert.equal(actionsOnly.shellName, "zsh");
|
|
const wrapped = snapshot.messages.find((message) => message.itemId === "wrapped-command");
|
|
assert.ok(wrapped);
|
|
assert.equal(wrapped.command, "'printf wrapped'");
|
|
assert.equal(wrapped.text, "'printf wrapped'");
|
|
const wrappedAction = snapshot.messages.find((message) => message.itemId === "wrapped-action");
|
|
assert.ok(wrappedAction);
|
|
assert.equal(wrappedAction.command, "'printf action-wrapped'");
|
|
assert.equal(wrappedAction.text, "'printf action-wrapped'");
|
|
assert.doesNotMatch(snapshot.outputTail, /\/bin\/zsh/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter projects collab items, metadata envelopes, and subagent lifecycle", async () => {
|
|
const childThreadId = "22222222-2222-4222-8222-222222222222";
|
|
const state = fixtureState();
|
|
state.id = THREAD_ID;
|
|
state.turns[0] = {
|
|
id: "turn-subagents",
|
|
status: "inProgress",
|
|
turnStartedAtMs: Date.now() - 2_000,
|
|
items: [
|
|
{ type: "userMessage", id: "user-subagents", content: [{ type: "text", text: "inspect this" }] },
|
|
{
|
|
type: "agentMessage",
|
|
id: "message-with-collab-metadata",
|
|
text: "I am delegating this review",
|
|
metadata: {
|
|
codex_collab_agent_tool_call: {
|
|
type: "collabAgentToolCall",
|
|
id: "collab-spawn-1",
|
|
tool: "spawnAgent",
|
|
status: "inProgress",
|
|
senderThreadId: THREAD_ID,
|
|
receiverThreadIds: [childThreadId],
|
|
prompt: "Inspect the tests",
|
|
model: "gpt-5.6",
|
|
reasoningEffort: "high",
|
|
agentsStates: { [childThreadId]: { status: "running", message: null } },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "subAgentActivity",
|
|
id: "activity-started-1",
|
|
kind: "started",
|
|
agentThreadId: childThreadId,
|
|
agentPath: "root/reviewer_agent",
|
|
},
|
|
],
|
|
};
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
let snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.subagents.length, 1);
|
|
assert.equal(snapshot.subagents[0].threadId, childThreadId);
|
|
assert.equal(snapshot.subagents[0].displayName, "Reviewer agent");
|
|
assert.equal(snapshot.subagents[0].prompt, "Inspect the tests");
|
|
assert.equal(snapshot.subagents[0].objective, "Inspect the tests");
|
|
assert.equal(snapshot.subagents[0].status, "working");
|
|
assert.equal(snapshot.subagents[0].model, "gpt-5.6");
|
|
assert.equal(snapshot.subagents[0].canInteract, true);
|
|
assert.ok(snapshot.messages.some((message) => message.itemType === "collabAgentToolCall" && message.uiType === "multi-agent-action"));
|
|
assert.ok(snapshot.messages.some((message) => message.itemType === "subAgentActivity" && message.uiType === "subagent-activity"));
|
|
|
|
const completed = structuredClone(state);
|
|
completed.turns[0].status = "completed";
|
|
completed.turns[0].items.push({
|
|
type: "collabAgentToolCall",
|
|
id: "collab-wait-1",
|
|
tool: "wait",
|
|
status: "completed",
|
|
senderThreadId: THREAD_ID,
|
|
receiverThreadIds: [],
|
|
prompt: null,
|
|
model: null,
|
|
reasoningEffort: null,
|
|
agentsStates: {},
|
|
});
|
|
client.emitState(completed, 2, "patches");
|
|
snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.subagents[0].status, "done");
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter discovers pending requests retained inside official turn items", async () => {
|
|
const state = fixtureState();
|
|
state.turns[0].status = "inProgress";
|
|
state.turns[0].items.push({
|
|
type: "permission-request",
|
|
id: "permission-in-turn",
|
|
threadId: THREAD_ID,
|
|
turnId: "turn-old",
|
|
summary: "需要访问工作区",
|
|
permissions: { fileSystem: { write: true } },
|
|
});
|
|
const client = new FakeIpcClient(state);
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.pendingRequests.length, 1);
|
|
assert.equal(snapshot.pendingRequests[0].requestId, "permission-in-turn");
|
|
assert.equal(snapshot.pendingRequests[0].method, "item/permissions/requestApproval");
|
|
assert.deepEqual(snapshot.pendingRequests[0].params.permissions, { fileSystem: { write: true } });
|
|
assert.equal(snapshot.messages.some((message) => message && message.itemId === "permission-in-turn"), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter expires unanswered requests and rolls back a failed follow", async () => {
|
|
const client = new FakeIpcClient(fixtureState([{
|
|
id: 8,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: { threadId: THREAD_ID, command: "echo timeout" },
|
|
}]));
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 25,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
assert.ok(events.some((event) => event.type === "approval.expired" && event.requestId === 8));
|
|
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
|
|
const expiryResponse = client.calls.find((call) => call.method === "respondCommandApproval");
|
|
assert.equal(expiryResponse.decision, "decline");
|
|
await adapter.dispose();
|
|
|
|
const noSnapshotClient = new FakeIpcClient(fixtureState(), false);
|
|
const failed = new CodexIpcAgentAdapter({
|
|
client: noSnapshotClient,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 15,
|
|
});
|
|
await assert.rejects(() => failed.start(), /Timed out waiting for a snapshot/);
|
|
assert.equal((await failed.snapshot()).state, "disconnected");
|
|
assert.equal((await failed.snapshot()).threadId, null);
|
|
await failed.dispose();
|
|
});
|
|
|
|
test("IPC adapter stays available while waiting for the first VS Code Codex session", async (t) => {
|
|
// An empty rollout directory represents a freshly opened VS Code window
|
|
// whose Codex panel has not created/selected a conversation yet. Starting
|
|
// the relay must remain possible so a later panel navigation can attach
|
|
// without restarting the bridge.
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-no-session-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 25,
|
|
followTimeoutMs: 50,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
|
|
await assert.doesNotReject(() => adapter.start());
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.threadId, null);
|
|
assert.equal(snapshot.state, "waiting_for_host");
|
|
assert.equal(snapshot.metadata?.waitingForSession, true);
|
|
assert.equal(snapshot.metadata?.attachReady, false);
|
|
assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false);
|
|
assert.ok(events.some((event) => event.type === "connection.opened" && event.threadId === undefined));
|
|
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter attaches in-place when the official panel selects a session after waiting", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-route-after-wait-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 100,
|
|
followTimeoutMs: 250,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
|
|
await adapter.start();
|
|
assert.equal((await adapter.snapshot()).state, "waiting_for_host");
|
|
// The official webview broadcasts this untargeted route update when the
|
|
// user opens a conversation. The bridge should attach over the same IPC
|
|
// client rather than asking the user to restart the command.
|
|
client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === THREAD_ID);
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.state, "idle");
|
|
assert.equal(snapshot.metadata.waitingForSession, false);
|
|
assert.equal(snapshot.metadata.attachReady, true);
|
|
assert.equal(client.calls.filter((call) => call.method === "followConversation" && call.following === true).length, 1);
|
|
assert.equal(events.filter((event) => event.type === "connection.opened").length, 1);
|
|
assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC waiting attach follows the latest official route when selection changes mid-snapshot", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-route-race-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "latest route" }],
|
|
]);
|
|
const client = new WaitingRouteRaceIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 100,
|
|
followTimeoutMs: 250,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
|
|
await adapter.start();
|
|
client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
|
|
await waitFor(() => client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === THREAD_ID && call.following === true));
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
|
|
await waitFor(async () => {
|
|
const snapshot = await adapter.snapshot();
|
|
return snapshot.threadId === SECOND_THREAD_ID && snapshot.metadata.attachReady === true;
|
|
});
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.metadata.attachReady, true);
|
|
assert.equal(snapshot.metadata.title, "latest route");
|
|
assert.ok(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === true));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC waiting discovery never overrides a newer official VS Code route", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-poll-route-race-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "stale fallback" }],
|
|
]);
|
|
const client = new WaitingPollRouteRaceIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 100,
|
|
followTimeoutMs: 250,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
|
|
await adapter.start();
|
|
assert.equal((await adapter.snapshot()).state, "waiting_for_host");
|
|
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
await fs.writeFile(path.join(sessions, `rollout-${SECOND_THREAD_ID}.jsonl`), `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: { originator: "codex_vscode", source: "vscode", cwd: "/tmp/stale-fallback" },
|
|
})}\n`);
|
|
|
|
client.delayFallbackDiscovery = true;
|
|
const fallbackDiscovery = adapter.runWaitingDiscovery();
|
|
await waitFor(() => client.fallbackDiscoveryStarted);
|
|
client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
|
|
await waitFor(() => client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === THREAD_ID && call.following === true));
|
|
client.releaseFallbackDiscovery();
|
|
await fallbackDiscovery;
|
|
await waitFor(async () => (await adapter.snapshot()).metadata.attachReady === true);
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.threadId, THREAD_ID);
|
|
assert.equal(snapshot.metadata.title, "fixture session");
|
|
assert.equal(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === true), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter waits when the configured VS Code conversation has no live owner", async () => {
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: STALE_THREAD_ID,
|
|
autoDiscoverThread: false,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 25,
|
|
followTimeoutMs: 50,
|
|
});
|
|
|
|
await assert.doesNotReject(() => adapter.start());
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.threadId, null);
|
|
assert.equal(snapshot.state, "waiting_for_host");
|
|
assert.equal(snapshot.metadata?.waitingForSession, true);
|
|
assert.equal(snapshot.metadata?.attachReady, false);
|
|
assert.ok(client.calls.some((call) => call.method === "findThreadOwner" && call.threadId === STALE_THREAD_ID));
|
|
assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false);
|
|
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter preserves official request timestamps and streams a sliding output tail as a delta", async () => {
|
|
const startedAtMs = Date.now() - 200;
|
|
const initial = fixtureState([{
|
|
id: 9,
|
|
method: "item/commandExecution/requestApproval",
|
|
createdAt: Date.now(),
|
|
params: { threadId: THREAD_ID, command: "echo timestamp", startedAtMs },
|
|
}]);
|
|
initial.turns[0].items = [{ type: "agentMessage", id: "streaming", text: "x".repeat(40) }];
|
|
const client = new FakeIpcClient(initial);
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 10_000,
|
|
maxOutputTailChars: 32,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
const pending = (await adapter.snapshot()).pendingApprovals[0];
|
|
assert.equal(pending.createdAt, startedAtMs);
|
|
|
|
const next = structuredClone(initial);
|
|
next.turns[0].items[0].text += "y";
|
|
client.emitState(next, 2);
|
|
const outputEvents = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk");
|
|
assert.equal(outputEvents.at(-1).type, "output.chunk");
|
|
assert.equal(outputEvents.at(-1).payload.text, "y");
|
|
assert.equal(outputEvents.at(-1).payload.messages, undefined);
|
|
assert.equal(outputEvents.at(-1).payload.messagesPatch.start, 0);
|
|
assert.equal(outputEvents.at(-1).payload.messagesPatch.deleteCount, 1);
|
|
assert.equal(outputEvents.at(-1).payload.messagesPatch.messages[0].text, `${"x".repeat(40)}y`);
|
|
|
|
// The bounded tail can remain byte-for-byte identical when a repeated
|
|
// character arrives. It must still advance by one chunk using total length.
|
|
const repeated = structuredClone(next);
|
|
repeated.turns[0].items[0].text += "x";
|
|
client.emitState(repeated, 3);
|
|
const repeatedEvent = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk").at(-1);
|
|
assert.equal(repeatedEvent.type, "output.chunk");
|
|
assert.equal(repeatedEvent.payload.text, "x");
|
|
assert.equal(repeatedEvent.payload.messagesPatch.messages[0].text, `${"x".repeat(40)}yx`);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter normalizes epoch-second approval timestamps", async () => {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const startedSeconds = nowSeconds - 2;
|
|
const expiresSeconds = nowSeconds + 60;
|
|
const initial = fixtureState([
|
|
{
|
|
id: 10,
|
|
method: "item/commandExecution/requestApproval",
|
|
createdAt: startedSeconds,
|
|
expiresAt: expiresSeconds,
|
|
params: { threadId: THREAD_ID, command: "echo outer seconds" },
|
|
},
|
|
{
|
|
id: 11,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
threadId: THREAD_ID,
|
|
command: "echo nested seconds",
|
|
startedAt: String(startedSeconds),
|
|
expiresAt: String(expiresSeconds),
|
|
},
|
|
},
|
|
]);
|
|
const client = new FakeIpcClient(initial);
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 10_000,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
|
|
await adapter.start();
|
|
const pending = (await adapter.snapshot()).pendingApprovals;
|
|
assert.equal(pending.length, 2);
|
|
for (const approval of pending) {
|
|
assert.equal(approval.createdAt, startedSeconds * 1000);
|
|
assert.equal(approval.expiresAt, expiresSeconds * 1000);
|
|
}
|
|
assert.equal(events.some((event) => event.type === "approval.expired"), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter waits for the revision returned by complete-history loading", async () => {
|
|
const initial = fixtureState();
|
|
initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
|
|
const complete = fixtureState();
|
|
complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false };
|
|
class HistoryClient extends FakeIpcClient {
|
|
async loadCompleteHistory() {
|
|
this.calls.push({ method: "loadCompleteHistory" });
|
|
// The owner can acknowledge the request before the stream broadcast.
|
|
// An unrelated owner's revision must not release our waiter.
|
|
setTimeout(() => this.emitState(initial, 2, "snapshot", THREAD_ID, "other-owner"), 0);
|
|
setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 10);
|
|
return { revision: 2 };
|
|
}
|
|
}
|
|
const client = new HistoryClient(initial);
|
|
const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
|
|
await adapter.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
// The unrelated owner event at t=0 must not overwrite the attached stream.
|
|
assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.metadata.historyComplete, true);
|
|
assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter retries complete-history loading once after a transient failure", async () => {
|
|
const initial = fixtureState();
|
|
initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
|
|
const complete = fixtureState();
|
|
complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false };
|
|
class RetryingHistoryClient extends FakeIpcClient {
|
|
async loadCompleteHistory() {
|
|
this.calls.push({ method: "loadCompleteHistory" });
|
|
const attempts = this.calls.filter((call) => call.method === "loadCompleteHistory").length;
|
|
if (attempts === 1) throw Object.assign(new Error("history request timed out"), { code: "timeout" });
|
|
setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 5);
|
|
return { revision: 2 };
|
|
}
|
|
}
|
|
const client = new RetryingHistoryClient(initial);
|
|
const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
|
|
await adapter.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 2);
|
|
assert.equal((await adapter.snapshot()).metadata.historyComplete, true);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter cancels a pending history retry when switching sessions", async () => {
|
|
const initial = fixtureState();
|
|
initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
|
|
const states = new Map([[THREAD_ID, initial], [SECOND_THREAD_ID, fixtureState()]]);
|
|
class SwitchingHistoryClient extends MultiSessionIpcClient {
|
|
async loadCompleteHistory(threadId) {
|
|
this.calls.push({ method: "loadCompleteHistory", threadId });
|
|
throw Object.assign(new Error("history request timed out"), { code: "timeout" });
|
|
}
|
|
}
|
|
const client = new SwitchingHistoryClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
|
|
await adapter.start();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await adapter.selectSession({ threadId: SECOND_THREAD_ID });
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
assert.deepEqual(client.calls.filter((call) => call.method === "loadCompleteHistory").map((call) => call.threadId), [THREAD_ID]);
|
|
assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter cancels a pending history retry when disposed", async () => {
|
|
const initial = fixtureState();
|
|
initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
|
|
class DisposedHistoryClient extends FakeIpcClient {
|
|
async loadCompleteHistory() {
|
|
this.calls.push({ method: "loadCompleteHistory" });
|
|
throw Object.assign(new Error("history request timed out"), { code: "timeout" });
|
|
}
|
|
}
|
|
const client = new DisposedHistoryClient(initial);
|
|
const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
|
|
await adapter.start();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await adapter.dispose();
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1);
|
|
});
|
|
|
|
test("IPC adapter reports canonical history incomplete until one complete island and all item pages", async () => {
|
|
const state = fixtureState();
|
|
state.turns = [];
|
|
state.turnHistory = {
|
|
kind: "canonical",
|
|
history: {
|
|
isComplete: true,
|
|
islands: [{ entries: [] }, { entries: [] }],
|
|
entitiesByKey: {
|
|
"turn:1": {
|
|
id: "turn-1",
|
|
status: "completed",
|
|
items: [],
|
|
itemsPagination: { hasLoadedOldest: true },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const client = new FakeIpcClient(state);
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
|
|
|
|
const itemPagePending = structuredClone(state);
|
|
itemPagePending.turnHistory.history.islands = [{ entries: [] }];
|
|
itemPagePending.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = false;
|
|
client.emitState(itemPagePending, 2);
|
|
assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
|
|
|
|
const complete = structuredClone(itemPagePending);
|
|
complete.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = true;
|
|
events.length = 0;
|
|
client.emitState(complete, 3, "patches");
|
|
assert.equal((await adapter.snapshot()).metadata.historyComplete, true);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.ok(events.some((event) => event.type === "session.snapshot"));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC auto-discovery excludes subagents and Codex Desktop tasks", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-discovery-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
|
|
const preferredCwd = path.join(codexHome, "current-workspace");
|
|
const subagentId = "22222222-2222-4222-8222-222222222222";
|
|
const otherWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
|
const matchingDesktopId = "44444444-4444-4444-8444-444444444444";
|
|
const matchingVscodeId = "55555555-5555-4555-8555-555555555555";
|
|
const writeRollout = async (id, payload, mtimeSeconds) => {
|
|
const fileName = path.join(sessions, `rollout-2026-08-28T00-00-00-${id}.jsonl`);
|
|
await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
|
|
await fs.utimes(fileName, mtimeSeconds, mtimeSeconds);
|
|
};
|
|
|
|
await writeRollout(subagentId, {
|
|
originator: "codex_vscode",
|
|
source: { subagent: { thread_spawn: {} } },
|
|
thread_source: "subagent",
|
|
cwd: preferredCwd,
|
|
}, 3_000);
|
|
await writeRollout(otherWorkspaceId, {
|
|
originator: "codex_vscode",
|
|
source: "vscode",
|
|
thread_source: "user",
|
|
cwd: path.join(codexHome, "other-workspace"),
|
|
}, 2_000);
|
|
await writeRollout(matchingDesktopId, {
|
|
originator: "Codex Desktop",
|
|
source: "vscode",
|
|
thread_source: "user",
|
|
cwd: preferredCwd,
|
|
}, 4_000);
|
|
await writeRollout(matchingVscodeId, {
|
|
originator: "codex_vscode",
|
|
source: "vscode",
|
|
thread_source: "user",
|
|
cwd: preferredCwd,
|
|
}, 1_000);
|
|
|
|
const client = new DiscoveryIpcClient(fixtureState());
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
preferredCwds: [preferredCwd],
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
|
|
await adapter.start();
|
|
assert.equal((await adapter.snapshot()).threadId, matchingVscodeId);
|
|
const discovered = client.calls
|
|
.filter((call) => call.method === "findThreadOwner")
|
|
.map((call) => call.threadId);
|
|
assert.equal(discovered.includes(subagentId), false);
|
|
assert.equal(discovered.includes(matchingDesktopId), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC auto-discovery finds an older live conversation beyond the first twelve rollouts", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-deep-discovery-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
const staleIds = Array.from({ length: 20 }, (_, index) => `aaaaaaaa-aaaa-4aaa-8aaa-${String(index).padStart(12, "0")}`);
|
|
for (const [index, id] of staleIds.entries()) {
|
|
await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: {
|
|
originator: "codex_vscode",
|
|
source: "vscode",
|
|
cwd: `/tmp/stale-${index}`,
|
|
updated_at: `2026-08-29T${String(20 - index).padStart(2, "0")}:00:00Z`,
|
|
},
|
|
})}\n`);
|
|
}
|
|
const liveRollout = path.join(sessions, `rollout-${THREAD_ID}.jsonl`);
|
|
await fs.writeFile(liveRollout, `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: {
|
|
originator: "codex_vscode",
|
|
source: "vscode",
|
|
cwd: "/tmp/live-old",
|
|
updated_at: "2026-08-01T00:00:00Z",
|
|
},
|
|
})}\n`);
|
|
const oldMtime = new Date("2026-08-01T00:00:00Z");
|
|
await fs.utimes(liveRollout, oldMtime, oldMtime);
|
|
|
|
const client = new FakeIpcClient(fixtureState());
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
|
|
const discoveryCalls = client.calls.filter((call) => call.method === "findThreadOwner");
|
|
assert.ok(discoveryCalls.length > 12);
|
|
assert.ok(discoveryCalls.some((call) => call.threadId === THREAD_ID));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter lists only indexed VS Code sessions with live attachable snapshots", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-list-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
const writeRollout = async (id, payload) => {
|
|
const fileName = path.join(sessions, `rollout-${id}.jsonl`);
|
|
await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
|
|
};
|
|
await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" });
|
|
await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" });
|
|
await writeRollout(STALE_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/c" });
|
|
await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
|
|
JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }),
|
|
JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "另一个工作区", updated_at: "2026-08-29T11:00:00Z" }),
|
|
JSON.stringify({ id: STALE_THREAD_ID, thread_name: "已关闭会话", updated_at: "2026-08-29T12:00:00Z" }),
|
|
].join("\n") + "\n");
|
|
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "另一个工作区" }],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const result = await adapter.listSessions({ limit: 10 });
|
|
assert.equal(result.activeThreadId, THREAD_ID);
|
|
assert.deepEqual(result.sessions.map((entry) => entry.threadId), [SECOND_THREAD_ID, THREAD_ID]);
|
|
assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).available, true);
|
|
assert.equal(result.sessions.find((entry) => entry.threadId === STALE_THREAD_ID), undefined);
|
|
assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).title, "另一个工作区");
|
|
assert.ok(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === true));
|
|
assert.ok(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === false));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC session list keeps the active attachment when newer stale history fills the limit", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-limit-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
const writeRollout = async (id, cwd) => {
|
|
await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: { originator: "codex_vscode", source: "vscode", cwd },
|
|
})}\n`);
|
|
};
|
|
await writeRollout(THREAD_ID, "/tmp/current");
|
|
await writeRollout(STALE_THREAD_ID, "/tmp/stale");
|
|
await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
|
|
JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-28T10:00:00Z" }),
|
|
JSON.stringify({ id: STALE_THREAD_ID, thread_name: "较新的失效记录", updated_at: "2026-08-29T12:00:00Z" }),
|
|
].join("\n") + "\n");
|
|
|
|
const client = new MultiSessionIpcClient(new Map([[THREAD_ID, fixtureState()]]), { [THREAD_ID]: "owner-a" });
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const result = await adapter.listSessions({ limit: 1 });
|
|
assert.deepEqual(result.sessions.map((entry) => entry.threadId), [THREAD_ID]);
|
|
assert.equal(result.sessions[0].active, true);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC session list omits an owner that does not return a matching snapshot", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-probe-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
const writeRollout = async (id, payload) => {
|
|
const fileName = path.join(sessions, `rollout-${id}.jsonl`);
|
|
await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
|
|
};
|
|
await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" });
|
|
await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" });
|
|
await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
|
|
JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }),
|
|
JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "桌面会话", updated_at: "2026-08-29T11:00:00Z" }),
|
|
].join("\n") + "\n");
|
|
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "桌面会话" }],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "desktop-owner",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
ownerDiscoveryTimeoutMs: 250,
|
|
followTimeoutMs: 250,
|
|
});
|
|
await adapter.start();
|
|
const result = await adapter.listSessions({ limit: 10 });
|
|
assert.equal(result.sessions.find((entry) => entry.threadId === THREAD_ID).available, true);
|
|
assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID), undefined);
|
|
const targetCalls = client.calls.filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID);
|
|
assert.equal(targetCalls.filter((call) => call.following === true).length, 1);
|
|
assert.equal(targetCalls.filter((call) => call.following === false).length, 1);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter switches follows only after the target owner snapshot arrives", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话", turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "target output" }] }] }],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
const result = await adapter.selectSession({ threadId: SECOND_THREAD_ID });
|
|
assert.deepEqual(result, {
|
|
threadId: SECOND_THREAD_ID,
|
|
previousThreadId: THREAD_ID,
|
|
switched: true,
|
|
available: true,
|
|
});
|
|
assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
|
|
assert.match((await adapter.snapshot()).outputTail, /target output/);
|
|
assert.ok(events.some((event) => event.type === "session.switching"));
|
|
assert.ok(events.some((event) => event.type === "session.selected"));
|
|
const oldUnfollow = client.calls.find((call) => call.method === "followConversation" && call.threadId === THREAD_ID && call.following === false);
|
|
assert.ok(oldUnfollow);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter follows paired route changes from the attached VS Code panel", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, {
|
|
...fixtureState(),
|
|
id: SECOND_THREAD_ID,
|
|
title: "面板目标会话",
|
|
turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "panel target output" }] }],
|
|
}],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "target-owner",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 5,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.match(snapshot.outputTail, /panel target output/);
|
|
assert.ok(events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID));
|
|
assert.ok(events.some((event) => event.type === "session.selected" && event.threadId === SECOND_THREAD_ID));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter ignores isolated, targeted, and other-client following broadcasts", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "不应自动切换" }],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "target-owner",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
await adapter.start();
|
|
|
|
// Bind the route source with a same-thread leave/re-enter pair.
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
// A reconnect/status replay is a lone true and is not a route change.
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
// Targeted replies describe liveness to one follower, not panel navigation.
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner", targetClientIds: ["follower"] });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner", targetClientIds: ["follower"] });
|
|
// Another Codex window shares the router but cannot take over this bridge.
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-panel" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "other-panel" });
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
|
|
assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
|
|
assert.equal(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === true), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter does not trust an isolated false from another follower", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "真实面板目标" }],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "disposing-remote-follower" });
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter binds the matching route source when another unbound source emits false", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, {
|
|
...fixtureState(),
|
|
id: SECOND_THREAD_ID,
|
|
title: "真实面板目标",
|
|
turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "real panel target" }] }],
|
|
}],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" });
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-follower" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
|
|
|
|
assert.match((await adapter.snapshot()).outputTail, /real panel target/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter lets a replacement route source take over after the bound client disconnects", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, {
|
|
...fixtureState(),
|
|
id: SECOND_THREAD_ID,
|
|
title: "重连后的目标",
|
|
turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "replacement panel target" }] }],
|
|
}],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
await adapter.start();
|
|
|
|
// First bind the official panel without changing the selected conversation.
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "old-vscode-panel" });
|
|
client.emitFollowing(THREAD_ID, true, { sourceClientId: "old-vscode-panel" });
|
|
client.emitClientStatus("old-vscode-panel", "disconnected");
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "replacement-vscode-panel" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "replacement-vscode-panel" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
|
|
|
|
assert.match((await adapter.snapshot()).outputTail, /replacement panel target/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter coalesces rapid VS Code A to B to C navigation to C", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "中间会话" }],
|
|
[THIRD_THREAD_ID, {
|
|
...fixtureState(),
|
|
id: THIRD_THREAD_ID,
|
|
title: "最终会话",
|
|
turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "final C output" }] }],
|
|
}],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
[THIRD_THREAD_ID]: "owner-c",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 20,
|
|
});
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID);
|
|
|
|
assert.match((await adapter.snapshot()).outputTail, /final C output/);
|
|
assert.equal(client.calls.some((call) => call.method === "followConversation"
|
|
&& call.threadId === SECOND_THREAD_ID && call.following === true), false);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter cancels an in-flight B snapshot when VS Code moves on to C", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照 B" }],
|
|
[THIRD_THREAD_ID, {
|
|
...fixtureState(),
|
|
id: THIRD_THREAD_ID,
|
|
title: "最终 C",
|
|
turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "C arrived" }] }],
|
|
}],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
[THIRD_THREAD_ID]: "owner-c",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 1_000,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(() => events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID));
|
|
const movedOnAt = Date.now();
|
|
client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID, 500);
|
|
|
|
assert.ok(Date.now() - movedOnAt < 500, "C should not wait for B's 1s snapshot timeout");
|
|
assert.equal(events.some((event) => event.type === "session.selected"
|
|
&& event.threadId === SECOND_THREAD_ID && event.payload.switched === true), false);
|
|
assert.match((await adapter.snapshot()).outputTail, /C arrived/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter defers the latest VS Code route while the old turn is active", async () => {
|
|
const active = fixtureState();
|
|
active.turns[0].status = "inProgress";
|
|
active.threadRuntimeStatus = { type: "active" };
|
|
const completed = structuredClone(active);
|
|
completed.turns[0].status = "completed";
|
|
completed.threadRuntimeStatus = { type: "idle" };
|
|
const states = new Map([
|
|
[THREAD_ID, active],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "延后目标" }],
|
|
]);
|
|
const client = new MultiSessionIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "target-owner",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
await adapter.start();
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
|
|
|
|
client.emitState(completed, 2, "patches", THREAD_ID, "panel-owner");
|
|
await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID, 1_000);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter publishes an explicit rollback when a VS Code route cannot stream", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "target-owner",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 25,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
events.length = 0;
|
|
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(() => events.some((event) => event.type === "session.selected" && event.payload.failed === true));
|
|
|
|
const snapshot = await adapter.snapshot();
|
|
assert.equal(snapshot.threadId, THREAD_ID);
|
|
assert.match(snapshot.outputTail, /hi from VS Code/);
|
|
const rollbackIndex = events.findIndex((event) => event.type === "session.selected" && event.payload.failed === true);
|
|
const restoredOutputIndex = events.findIndex((event, index) => index > rollbackIndex
|
|
&& event.type === "output.snapshot" && event.threadId === THREAD_ID);
|
|
assert.ok(rollbackIndex >= 0);
|
|
assert.ok(restoredOutputIndex > rollbackIndex);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter keeps dispose authoritative while an automatic switch is waiting", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "等待中的目标" }],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "panel-owner",
|
|
[SECOND_THREAD_ID]: "target-owner",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 1_000,
|
|
vscodeSessionFollowDebounceMs: 0,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
|
|
client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
|
|
await waitFor(() => events.some((event) => event.type === "session.switching"));
|
|
|
|
const eventCountAtDispose = events.length;
|
|
await adapter.dispose();
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
assert.equal((await adapter.snapshot()).state, "disconnected");
|
|
assert.equal(events.slice(eventCountAtDispose).some((event) => [
|
|
"session.selected",
|
|
"output.snapshot",
|
|
"session.snapshot",
|
|
].includes(event.type)), false);
|
|
});
|
|
|
|
test("IPC adapter absorbs a snapshot waiter when target follow fails immediately", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "立即失败" }],
|
|
]);
|
|
const client = new ThrowingTargetFollowIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /target follow failed immediately/);
|
|
assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter rejects a target snapshot when owner changes before commit", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "Owner handoff" }],
|
|
]);
|
|
const client = new OwnerChangingIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
await assert.rejects(
|
|
() => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
|
|
/owner changed while switching/,
|
|
);
|
|
assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter serializes list probes before selecting the same session", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-serialization-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
for (const [id, cwd] of [[THREAD_ID, "/tmp/current"], [SECOND_THREAD_ID, "/tmp/target"]]) {
|
|
await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: { originator: "codex_vscode", source: "vscode", cwd },
|
|
})}\n`);
|
|
}
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }],
|
|
]);
|
|
const client = new DelayedProbeIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
const listPromise = adapter.listSessions({ limit: 10 });
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
const selectPromise = adapter.selectSession({ threadId: SECOND_THREAD_ID });
|
|
await Promise.all([listPromise, selectPromise]);
|
|
|
|
const targetFollowing = client.calls
|
|
.filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID)
|
|
.map((call) => call.following);
|
|
assert.deepEqual(targetFollowing, [true, false, true]);
|
|
assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter restores the cached previous projection when target follow has no snapshot", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const events = [];
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 25,
|
|
});
|
|
adapter.onEvent((event) => events.push(event));
|
|
await adapter.start();
|
|
const before = await adapter.snapshot();
|
|
assert.equal(before.threadId, THREAD_ID);
|
|
assert.match(before.outputTail, /hi from VS Code/);
|
|
|
|
await assert.rejects(
|
|
() => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
|
|
/Timed out waiting for a snapshot from VS Code conversation/,
|
|
);
|
|
|
|
const after = await adapter.snapshot();
|
|
assert.equal(after.threadId, THREAD_ID);
|
|
assert.equal(after.outputTail, before.outputTail);
|
|
assert.deepEqual(after.messages, before.messages);
|
|
assert.equal(after.state, "idle");
|
|
assert.ok(events.some((event) => event.type === "output.snapshot" && event.threadId === THREAD_ID));
|
|
assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID));
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter restores its owner-validated projection after the IPC cache is polluted", async () => {
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }],
|
|
]);
|
|
const client = new NoSnapshotSwitchIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 25,
|
|
});
|
|
await adapter.start();
|
|
const verified = await adapter.snapshot();
|
|
|
|
const polluted = {
|
|
...fixtureState(),
|
|
title: "来自旧 owner 的污染快照",
|
|
turns: [{
|
|
id: "turn-polluted",
|
|
status: "completed",
|
|
items: [{ type: "agentMessage", id: "polluted-message", text: "poisoned stale output" }],
|
|
}],
|
|
};
|
|
client.emitState(polluted, 99, "snapshot", THREAD_ID, "old-owner");
|
|
|
|
assert.equal(client.getConversationState(THREAD_ID).ownerClientId, "old-owner");
|
|
assert.equal(client.getConversationState(THREAD_ID).conversationState.title, "来自旧 owner 的污染快照");
|
|
assert.equal((await adapter.snapshot()).outputTail, verified.outputTail);
|
|
|
|
await assert.rejects(
|
|
() => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
|
|
/Timed out waiting for a snapshot from VS Code conversation/,
|
|
);
|
|
|
|
const restored = await adapter.snapshot();
|
|
assert.equal(restored.threadId, THREAD_ID);
|
|
assert.equal(restored.outputTail, verified.outputTail);
|
|
assert.deepEqual(restored.messages, verified.messages);
|
|
assert.doesNotMatch(restored.outputTail, /poisoned stale output/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC adapter denies target approvals on dispose and blocks ordinary commands mid-switch", async () => {
|
|
const targetApprovalId = "target-approval";
|
|
const targetState = {
|
|
...fixtureState([{
|
|
id: targetApprovalId,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: { threadId: SECOND_THREAD_ID, turnId: "turn-target", command: "echo target" },
|
|
}]),
|
|
id: SECOND_THREAD_ID,
|
|
title: "等待 owner 确认的目标",
|
|
};
|
|
const states = new Map([
|
|
[THREAD_ID, fixtureState()],
|
|
[SECOND_THREAD_ID, targetState],
|
|
]);
|
|
const client = new DelayedTargetOwnerConfirmationIpcClient(states, {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
|
|
const switching = adapter.selectSession({ threadId: SECOND_THREAD_ID });
|
|
void switching.catch(() => undefined);
|
|
await waitFor(() => client.targetConfirmationStarted);
|
|
await waitFor(async () => (await adapter.snapshot()).pendingApprovals.some((entry) => entry.requestId === targetApprovalId));
|
|
|
|
await assert.rejects(
|
|
() => adapter.startTurn({ text: "must not be sent while switching" }),
|
|
/session switch is still in progress/,
|
|
);
|
|
await assert.rejects(
|
|
() => adapter.respondApproval(targetApprovalId, "allow"),
|
|
/session switch is still in progress/,
|
|
);
|
|
assert.equal(client.calls.some((call) => call.method === "startTurn"), false);
|
|
assert.equal(client.calls.some((call) => call.method === "respondCommandApproval"), false);
|
|
|
|
await adapter.dispose();
|
|
const denied = client.calls.find((call) => call.method === "respondCommandApproval"
|
|
&& call.requestId === targetApprovalId);
|
|
assert.ok(denied);
|
|
assert.equal(denied.threadId, SECOND_THREAD_ID);
|
|
assert.equal(denied.decision, "decline");
|
|
assert.equal(denied.options.ownerClientId, "owner-b");
|
|
|
|
client.releaseTargetOwnerConfirmation();
|
|
await assert.rejects(switching, /session selection was cancelled because the IPC session closed/);
|
|
});
|
|
|
|
test("IPC adapter refuses session switching during an active turn or pending request", async () => {
|
|
const state = fixtureState([{ id: 12, method: "item/commandExecution/requestApproval", params: { command: "echo pending" } }]);
|
|
const client = new MultiSessionIpcClient(new Map([[THREAD_ID, state], [SECOND_THREAD_ID, fixtureState()]]), {
|
|
[THREAD_ID]: "owner-a",
|
|
[SECOND_THREAD_ID]: "owner-b",
|
|
});
|
|
const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, loadCompleteHistory: false, approvalTimeoutMs: 0, followTimeoutMs: 500 });
|
|
await adapter.start();
|
|
await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /turn or approval is active/);
|
|
await adapter.dispose();
|
|
});
|
|
|
|
test("IPC session discovery accepts current ULID rollout filenames", async (t) => {
|
|
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-ulid-"));
|
|
t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
|
|
const sessions = path.join(codexHome, "sessions", "2026", "08");
|
|
await fs.mkdir(sessions, { recursive: true });
|
|
await fs.writeFile(path.join(sessions, `rollout-2026-08-29T00-00-00-${ULID_THREAD_ID}.jsonl`), `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: { id: ULID_THREAD_ID, originator: "codex_vscode", source: "vscode", cwd: "/tmp/ulid" },
|
|
})}\n`);
|
|
await fs.writeFile(path.join(codexHome, "session_index.jsonl"), `${JSON.stringify({ id: ULID_THREAD_ID, thread_name: "ULID 会话", updated_at: "2026-08-29T12:00:00Z" })}\n`);
|
|
const client = new DiscoveryIpcClient(fixtureState());
|
|
const adapter = new CodexIpcAgentAdapter({
|
|
client,
|
|
threadId: THREAD_ID,
|
|
codexHome,
|
|
loadCompleteHistory: false,
|
|
approvalTimeoutMs: 0,
|
|
followTimeoutMs: 500,
|
|
});
|
|
await adapter.start();
|
|
const result = await adapter.listSessions({ limit: 10 });
|
|
const session = result.sessions.find((entry) => entry.threadId === ULID_THREAD_ID);
|
|
assert.ok(session);
|
|
assert.equal(session.title, "ULID 会话");
|
|
assert.equal(session.available, true);
|
|
await adapter.dispose();
|
|
});
|