2000 lines
65 KiB
TypeScript
2000 lines
65 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
|
|
import { AcpxOperationalError } from "../src/errors.js";
|
|
import { AcpRuntimeManager } from "../src/runtime/engine/manager.js";
|
|
import type {
|
|
AcpRuntimeEvent,
|
|
AcpRuntimeHandle,
|
|
AcpRuntimeTurn,
|
|
AcpRuntimeTurnResult,
|
|
} from "../src/runtime/public/contract.js";
|
|
import {
|
|
createRuntimeOptions,
|
|
InMemorySessionStore,
|
|
makeSessionRecord,
|
|
} from "./runtime-test-helpers.js";
|
|
|
|
type FakeClientHandlers = {
|
|
onSessionUpdate?: (notification: Record<string, unknown>) => void;
|
|
onClientOperation?: (operation: Record<string, unknown>) => void;
|
|
};
|
|
|
|
type FakeClient = {
|
|
initializeResult?: {
|
|
protocolVersion?: number;
|
|
agentCapabilities?: Record<string, unknown>;
|
|
};
|
|
start: () => Promise<void>;
|
|
close: () => Promise<void>;
|
|
createSession: (cwd: string) => Promise<{
|
|
sessionId: string;
|
|
agentSessionId?: string;
|
|
configOptions?: SetSessionConfigOptionResponse["configOptions"];
|
|
}>;
|
|
loadSession: (
|
|
sessionId: string,
|
|
cwd: string,
|
|
) => Promise<{
|
|
agentSessionId?: string;
|
|
configOptions?: SetSessionConfigOptionResponse["configOptions"];
|
|
}>;
|
|
hasReusableSession: (sessionId: string) => boolean;
|
|
supportsLoadSession: () => boolean;
|
|
supportsCloseSession?: () => boolean;
|
|
loadSessionWithOptions: (
|
|
sessionId: string,
|
|
cwd: string,
|
|
options: { suppressReplayUpdates: boolean },
|
|
) => Promise<{ agentSessionId?: string }>;
|
|
getAgentLifecycleSnapshot: () => {
|
|
pid?: number;
|
|
startedAt?: string;
|
|
running: boolean;
|
|
lastExit?: {
|
|
exitCode: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
exitedAt: string;
|
|
reason: string;
|
|
};
|
|
};
|
|
prompt: (
|
|
sessionId: string,
|
|
input: unknown,
|
|
) => Promise<{
|
|
stopReason: string;
|
|
}>;
|
|
closeSession?: (sessionId: string) => Promise<void>;
|
|
waitForSessionUpdatesIdle?: (options?: { idleMs?: number; timeoutMs?: number }) => Promise<void>;
|
|
requestCancelActivePrompt: () => Promise<boolean>;
|
|
hasActivePrompt: () => boolean;
|
|
setSessionMode: (sessionId: string, modeId: string) => Promise<void>;
|
|
setSessionConfigOption: (
|
|
sessionId: string,
|
|
configId: string,
|
|
value: string,
|
|
) => Promise<SetSessionConfigOptionResponse | void>;
|
|
clearEventHandlers: () => void;
|
|
setEventHandlers: (handlers: FakeClientHandlers) => void;
|
|
};
|
|
|
|
function createHandle(sessionKey: string, acpxRecordId = sessionKey): AcpRuntimeHandle {
|
|
return {
|
|
sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: sessionKey,
|
|
acpxRecordId,
|
|
};
|
|
}
|
|
|
|
async function collectEvents(iterable: AsyncIterable<AcpRuntimeEvent>): Promise<AcpRuntimeEvent[]> {
|
|
const events: AcpRuntimeEvent[] = [];
|
|
for await (const event of iterable) {
|
|
events.push(event);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
async function collectTurn(turn: AcpRuntimeTurn): Promise<{
|
|
events: AcpRuntimeEvent[];
|
|
result: AcpRuntimeTurnResult;
|
|
}> {
|
|
const [events, result] = await Promise.all([collectEvents(turn.events), turn.result]);
|
|
return { events, result };
|
|
}
|
|
|
|
test("AcpRuntimeManager reuses compatible records without spawning a new client", async () => {
|
|
const existing = makeSessionRecord({
|
|
acpxRecordId: "session-key",
|
|
acpSessionId: "sid-1",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
closed: true,
|
|
closedAt: "2026-01-01T00:05:00.000Z",
|
|
});
|
|
const store = new InMemorySessionStore([existing]);
|
|
let constructed = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
constructed += 1;
|
|
throw new Error("clientFactory should not be called");
|
|
},
|
|
},
|
|
);
|
|
|
|
const record = await manager.ensureSession({
|
|
sessionKey: "session-key",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
cwd: "/workspace",
|
|
});
|
|
|
|
assert.equal(constructed, 0);
|
|
assert.equal(record.acpSessionId, "sid-1");
|
|
assert.equal(record.closed, false);
|
|
assert.equal(store.savedRecordIds.length, 1);
|
|
});
|
|
|
|
test("AcpRuntimeManager creates and resumes sessions through the client", async () => {
|
|
const store = new InMemorySessionStore();
|
|
const lifecycle = {
|
|
pid: 456,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
running: true,
|
|
};
|
|
const createClient = (): FakeClient =>
|
|
({
|
|
initializeResult: {
|
|
protocolVersion: 1,
|
|
agentCapabilities: { loadSession: true },
|
|
},
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async (cwd) => {
|
|
assert.equal(cwd, "/workspace");
|
|
return {
|
|
sessionId: "new-session",
|
|
agentSessionId: "agent-session",
|
|
configOptions: [
|
|
{
|
|
id: "mode",
|
|
name: "Mode",
|
|
type: "select",
|
|
currentValue: "ask",
|
|
options: [{ value: "ask", name: "Ask" }],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
loadSession: async (sessionId, cwd) => {
|
|
assert.equal(sessionId, "resume-session");
|
|
assert.equal(cwd, "/workspace");
|
|
return {
|
|
agentSessionId: "resumed-agent",
|
|
configOptions: [
|
|
{
|
|
id: "model",
|
|
name: "Model",
|
|
type: "select",
|
|
currentValue: "fast",
|
|
options: [{ value: "fast", name: "Fast" }],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "runtime-session" }),
|
|
getAgentLifecycleSnapshot: () => lifecycle,
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as FakeClient;
|
|
let constructed = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
constructed += 1;
|
|
return createClient() as never;
|
|
},
|
|
},
|
|
);
|
|
|
|
const created = await manager.ensureSession({
|
|
sessionKey: "created-session",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
assert.equal(created.acpSessionId, "new-session");
|
|
assert.equal(created.agentSessionId, "agent-session");
|
|
assert.equal(created.protocolVersion, 1);
|
|
assert.deepEqual(
|
|
created.acpx?.config_options?.map((option) => option.id),
|
|
["mode"],
|
|
);
|
|
assert.equal(created.eventLog.segment_count > 0, true);
|
|
assert.match(created.eventLog.active_path, /created-session/);
|
|
|
|
const resumed = await manager.ensureSession({
|
|
sessionKey: "resumed-session",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
resumeSessionId: "resume-session",
|
|
});
|
|
assert.equal(resumed.acpSessionId, "resume-session");
|
|
assert.equal(resumed.agentSessionId, "resumed-agent");
|
|
assert.deepEqual(
|
|
resumed.acpx?.config_options?.map((option) => option.id),
|
|
["model"],
|
|
);
|
|
assert.equal(constructed, 2);
|
|
});
|
|
|
|
test("AcpRuntimeManager creates a fresh record for each oneshot session", async () => {
|
|
const store = new InMemorySessionStore();
|
|
let createdSessions = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
initializeResult: {
|
|
protocolVersion: 1,
|
|
agentCapabilities: { loadSession: true },
|
|
},
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({
|
|
sessionId: `new-session-${++createdSessions}`,
|
|
agentSessionId: `agent-session-${createdSessions}`,
|
|
}),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "runtime-session" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const first = await manager.ensureSession({
|
|
sessionKey: "oneshot-session",
|
|
agent: "codex",
|
|
mode: "oneshot",
|
|
});
|
|
const second = await manager.ensureSession({
|
|
sessionKey: "oneshot-session",
|
|
agent: "codex",
|
|
mode: "oneshot",
|
|
});
|
|
|
|
assert.notEqual(first.acpxRecordId, second.acpxRecordId);
|
|
assert.equal(first.name, "oneshot-session");
|
|
assert.equal(second.name, "oneshot-session");
|
|
assert.equal(store.records.size, 2);
|
|
});
|
|
|
|
test("AcpRuntimeManager streams runtime events and saves updated status", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "turn-session",
|
|
acpSessionId: "turn-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
const client: FakeClient = {
|
|
initializeResult: {
|
|
protocolVersion: 1,
|
|
agentCapabilities: { prompt: true },
|
|
},
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: (sessionId) => sessionId === "turn-sid",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({
|
|
pid: 999,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
running: true,
|
|
}),
|
|
prompt: async (sessionId, input) => {
|
|
assert.equal(sessionId, "turn-sid");
|
|
assert.equal(input, "hello");
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "turn-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "hello" },
|
|
},
|
|
});
|
|
handlers.onClientOperation?.({
|
|
method: "write_file",
|
|
status: "ok",
|
|
summary: "saved notes.md",
|
|
});
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("turn-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-1",
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, [
|
|
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
|
|
{ type: "status", text: "write_file ok saved notes.md" },
|
|
]);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
|
|
const saved = await store.load("turn-session");
|
|
assert.equal(saved?.lastRequestId, "req-1");
|
|
assert.equal(saved?.lastPromptAt != null, true);
|
|
assert.equal(saved?.pid, 999);
|
|
assert.equal(saved?.protocolVersion, 1);
|
|
});
|
|
|
|
test("AcpRuntimeManager keeps reusable persistent clients pooled across turns and closes them on runtime close", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "pooled-persistent-session",
|
|
acpSessionId: "pooled-sid",
|
|
agentCommand: "gemini --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let factoryCalls = 0;
|
|
let closeCalls = 0;
|
|
let promptCalls = 0;
|
|
let handlers: FakeClientHandlers = {};
|
|
const client: FakeClient = {
|
|
initializeResult: {
|
|
protocolVersion: 1,
|
|
agentCapabilities: { prompt: true },
|
|
},
|
|
start: async () => {},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: (sessionId) => sessionId === "pooled-sid",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "pooled-agent" }),
|
|
getAgentLifecycleSnapshot: () => ({
|
|
pid: 104_981,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
running: true,
|
|
}),
|
|
prompt: async (sessionId) => {
|
|
promptCalls += 1;
|
|
assert.equal(sessionId, "pooled-sid");
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "pooled-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: `turn ${promptCalls}` },
|
|
},
|
|
});
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
waitForSessionUpdatesIdle: async () => {},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
factoryCalls += 1;
|
|
return client as never;
|
|
},
|
|
},
|
|
);
|
|
|
|
const firstEvents = await collectEvents(
|
|
manager.runTurn({
|
|
handle: createHandle("pooled-persistent-session"),
|
|
text: "first",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-pooled-1",
|
|
}),
|
|
);
|
|
const secondEvents = await collectEvents(
|
|
manager.runTurn({
|
|
handle: createHandle("pooled-persistent-session"),
|
|
text: "second",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-pooled-2",
|
|
}),
|
|
);
|
|
|
|
assert.equal(factoryCalls, 1);
|
|
assert.equal(promptCalls, 2);
|
|
assert.equal(closeCalls, 0);
|
|
assert.deepEqual(firstEvents, [
|
|
{ type: "text_delta", text: "turn 1", stream: "output", tag: "agent_message_chunk" },
|
|
{ type: "done", stopReason: "end_turn" },
|
|
]);
|
|
assert.deepEqual(secondEvents, [
|
|
{ type: "text_delta", text: "turn 2", stream: "output", tag: "agent_message_chunk" },
|
|
{ type: "done", stopReason: "end_turn" },
|
|
]);
|
|
|
|
await manager.close(createHandle("pooled-persistent-session"));
|
|
|
|
assert.equal(closeCalls, 1);
|
|
const closed = await store.load("pooled-persistent-session");
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(typeof closed?.closedAt, "string");
|
|
});
|
|
|
|
test("AcpRuntimeManager runTurn remains a compatibility adapter over startTurn", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "legacy-turn-session",
|
|
acpSessionId: "legacy-turn-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: (sessionId) => sessionId === "legacy-turn-sid",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "legacy-turn-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "legacy" },
|
|
},
|
|
});
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const events = await collectEvents(
|
|
manager.runTurn({
|
|
handle: createHandle("legacy-turn-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-legacy",
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(events, [
|
|
{ type: "text_delta", text: "legacy", stream: "output", tag: "agent_message_chunk" },
|
|
{ type: "done", stopReason: "end_turn" },
|
|
]);
|
|
});
|
|
|
|
test("AcpRuntimeManager retains a reusable persistent client across turns", async () => {
|
|
const store = new InMemorySessionStore();
|
|
let constructed = 0;
|
|
let createSessionCalls = 0;
|
|
let loadSessionCalls = 0;
|
|
let promptCalls = 0;
|
|
let closeCalls = 0;
|
|
const promptSessionIds: string[] = [];
|
|
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
constructed += 1;
|
|
return {
|
|
initializeResult: {
|
|
protocolVersion: 1,
|
|
agentCapabilities: { loadSession: true },
|
|
},
|
|
start: async () => {},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => {
|
|
createSessionCalls += 1;
|
|
return { sessionId: "pooled-persistent-sid", agentSessionId: "pooled-agent-id" };
|
|
},
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: (sessionId: string) => sessionId === "pooled-persistent-sid",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => {
|
|
loadSessionCalls += 1;
|
|
return { agentSessionId: "unexpected-load-agent-id" };
|
|
},
|
|
getAgentLifecycleSnapshot: () => ({
|
|
pid: 1234,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
running: true,
|
|
}),
|
|
prompt: async (sessionId: string) => {
|
|
promptCalls += 1;
|
|
promptSessionIds.push(sessionId);
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
} as never;
|
|
},
|
|
},
|
|
);
|
|
|
|
const record = await manager.ensureSession({
|
|
sessionKey: "pooled-persistent-session",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
const handle = createHandle("pooled-persistent-session", record.acpxRecordId);
|
|
|
|
for (const requestId of ["req-pooled-1", "req-pooled-2"]) {
|
|
const turn = manager.startTurn({
|
|
handle,
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId,
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
}
|
|
|
|
assert.equal(constructed, 1);
|
|
assert.equal(createSessionCalls, 1);
|
|
assert.equal(loadSessionCalls, 0);
|
|
assert.equal(promptCalls, 2);
|
|
assert.deepEqual(promptSessionIds, ["pooled-persistent-sid", "pooled-persistent-sid"]);
|
|
assert.equal(closeCalls, 0);
|
|
|
|
await manager.close(handle);
|
|
|
|
assert.equal(closeCalls, 1);
|
|
});
|
|
|
|
test("AcpRuntimeManager closeStream suppresses future live events while preserving terminal completion", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "stream-close-session",
|
|
acpSessionId: "stream-close-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
let resolvePromptStart!: () => void;
|
|
let resolvePrompt!: (value: { stopReason: string }) => void;
|
|
const promptStarted = new Promise<void>((resolve) => {
|
|
resolvePromptStart = resolve;
|
|
});
|
|
const promptResult = new Promise<{ stopReason: string }>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
});
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
resolvePromptStart();
|
|
return await promptResult;
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => true,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("stream-close-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-close-stream",
|
|
});
|
|
const iterator = turn.events[Symbol.asyncIterator]();
|
|
|
|
const firstEventPromise = iterator.next();
|
|
await promptStarted;
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "stream-close-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "visible" },
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(await firstEventPromise, {
|
|
done: false,
|
|
value: { type: "text_delta", text: "visible", stream: "output", tag: "agent_message_chunk" },
|
|
});
|
|
|
|
await turn.closeStream({
|
|
reason: "observer closed stream",
|
|
});
|
|
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "stream-close-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "suppressed" },
|
|
},
|
|
});
|
|
resolvePrompt({ stopReason: "end_turn" });
|
|
|
|
assert.deepEqual(await iterator.next(), {
|
|
done: true,
|
|
value: undefined,
|
|
});
|
|
assert.deepEqual(await turn.result, {
|
|
status: "completed",
|
|
stopReason: "end_turn",
|
|
});
|
|
assert.deepEqual(await iterator.next(), {
|
|
done: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
test("AcpRuntimeManager does not pool a persistent client after active close", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "active-close-session",
|
|
acpSessionId: "active-close-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let closeCalls = 0;
|
|
let promptActive = false;
|
|
let resolvePromptStart!: () => void;
|
|
let resolvePrompt!: (value: { stopReason: string }) => void;
|
|
const promptStarted = new Promise<void>((resolve) => {
|
|
resolvePromptStart = resolve;
|
|
});
|
|
const promptResult = new Promise<{ stopReason: string }>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
});
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
promptActive = false;
|
|
},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: (sessionId) => sessionId === "active-close-sid",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "active-close-agent-id" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: promptActive }),
|
|
prompt: async () => {
|
|
promptActive = true;
|
|
resolvePromptStart();
|
|
return await promptResult;
|
|
},
|
|
requestCancelActivePrompt: async () => true,
|
|
hasActivePrompt: () => promptActive,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
const handle = createHandle("active-close-session");
|
|
|
|
const turn = manager.startTurn({
|
|
handle,
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-active-close",
|
|
});
|
|
const eventsPromise = collectEvents(turn.events);
|
|
await promptStarted;
|
|
|
|
await manager.close(handle);
|
|
|
|
let closed = await store.load("active-close-session");
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(closeCalls, 0);
|
|
|
|
resolvePrompt({ stopReason: "cancelled" });
|
|
|
|
const events = await eventsPromise;
|
|
const result = await turn.result;
|
|
closed = await store.load("active-close-session");
|
|
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "cancelled", stopReason: "cancelled" });
|
|
assert.equal(closeCalls, 1);
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(typeof closed?.closedAt, "string");
|
|
});
|
|
|
|
test("AcpRuntimeManager accepts a session reply even when the prompt RPC times out", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "late-reply-session",
|
|
acpSessionId: "late-reply-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
setTimeout(() => {
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "late-reply-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "late reply" },
|
|
},
|
|
});
|
|
}, 5);
|
|
return await new Promise<{ stopReason: string }>(() => {});
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => true,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("late-reply-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-late-reply",
|
|
timeoutMs: 20,
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, [
|
|
{ type: "text_delta", text: "late reply", stream: "output", tag: "agent_message_chunk" },
|
|
]);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
});
|
|
|
|
test("AcpRuntimeManager waits for late reply chunks to settle before ending a salvaged turn", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "late-reply-stream-session",
|
|
acpSessionId: "late-reply-stream-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
let lastUpdateAt = Date.now();
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
setTimeout(() => {
|
|
lastUpdateAt = Date.now();
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "late-reply-stream-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "late" },
|
|
},
|
|
});
|
|
}, 5);
|
|
setTimeout(() => {
|
|
lastUpdateAt = Date.now();
|
|
handlers.onSessionUpdate?.({
|
|
sessionId: "late-reply-stream-sid",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: " reply" },
|
|
},
|
|
});
|
|
}, 300);
|
|
return await new Promise<{ stopReason: string }>(() => {});
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => true,
|
|
waitForSessionUpdatesIdle: async ({ idleMs = 0, timeoutMs = 0 } = {}) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() <= deadline) {
|
|
if (Date.now() - lastUpdateAt >= idleMs) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
throw new Error("timed out waiting for session updates to go idle");
|
|
},
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("late-reply-stream-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-late-reply-stream",
|
|
timeoutMs: 20,
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, [
|
|
{ type: "text_delta", text: "late", stream: "output", tag: "agent_message_chunk" },
|
|
{ type: "text_delta", text: " reply", stream: "output", tag: "agent_message_chunk" },
|
|
]);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
});
|
|
|
|
test("AcpRuntimeManager routes controls through the active controller while a turn is running", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "live-session",
|
|
acpSessionId: "live-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let handlers: FakeClientHandlers = {};
|
|
let cancelRequested = 0;
|
|
let setModeCalls = 0;
|
|
let setConfigCalls = 0;
|
|
let resolvePromptStart!: () => void;
|
|
let resolvePrompt!: (value: { stopReason: string }) => void;
|
|
const promptStarted = new Promise<void>((resolve) => {
|
|
resolvePromptStart = resolve;
|
|
});
|
|
const promptResult = new Promise<{ stopReason: string }>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
});
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
resolvePromptStart();
|
|
return await promptResult;
|
|
},
|
|
requestCancelActivePrompt: async () => {
|
|
cancelRequested += 1;
|
|
resolvePrompt({ stopReason: "cancelled" });
|
|
return true;
|
|
},
|
|
hasActivePrompt: () => true,
|
|
setSessionMode: async (_sessionId, modeId) => {
|
|
assert.equal(modeId, "plan");
|
|
setModeCalls += 1;
|
|
},
|
|
setSessionConfigOption: async (_sessionId, key, value) => {
|
|
assert.equal(key, "approval");
|
|
assert.equal(value, "manual");
|
|
setConfigCalls += 1;
|
|
return {
|
|
configOptions: [
|
|
{
|
|
id: "approval",
|
|
name: "Approval",
|
|
type: "select",
|
|
currentValue: "manual",
|
|
options: [{ value: "manual", name: "Manual" }],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
clearEventHandlers: () => {
|
|
handlers = {};
|
|
},
|
|
setEventHandlers: (nextHandlers) => {
|
|
handlers = nextHandlers;
|
|
},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("live-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-live",
|
|
});
|
|
const eventsPromise = collectEvents(turn.events);
|
|
await promptStarted;
|
|
await manager.setMode(createHandle("live-session"), "plan");
|
|
await manager.setConfigOption(createHandle("live-session"), "approval", "manual");
|
|
const liveStatusDuringTurn = await manager.getStatus(createHandle("live-session"));
|
|
await turn.cancel();
|
|
const events = await eventsPromise;
|
|
const result = await turn.result;
|
|
const liveStatusAfterTurn = await manager.getStatus(createHandle("live-session"));
|
|
|
|
assert.equal(setModeCalls, 1);
|
|
assert.equal(setConfigCalls, 1);
|
|
const expectedConfigOptions = [
|
|
{
|
|
id: "approval",
|
|
name: "Approval",
|
|
type: "select",
|
|
currentValue: "manual",
|
|
options: [{ value: "manual", name: "Manual" }],
|
|
},
|
|
];
|
|
assert.deepEqual(liveStatusDuringTurn.details?.configOptions, expectedConfigOptions);
|
|
assert.deepEqual(liveStatusAfterTurn.details?.configOptions, expectedConfigOptions);
|
|
assert.equal(cancelRequested, 1);
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "cancelled", stopReason: "cancelled" });
|
|
assert.equal(handlers.onSessionUpdate, undefined);
|
|
});
|
|
|
|
test("AcpRuntimeManager waits for oneshot load fallback to resolve before sending controls", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "fallback-session",
|
|
acpSessionId: "stale-session",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let promptActive = false;
|
|
let promptSessionId: string | undefined;
|
|
let setModeSessionId: string | undefined;
|
|
let resolveLoadFailure!: () => void;
|
|
const loadFailure = new Promise<void>((resolve) => {
|
|
resolveLoadFailure = resolve;
|
|
});
|
|
let resolvePromptStarted!: () => void;
|
|
const promptStarted = new Promise<void>((resolve) => {
|
|
resolvePromptStarted = resolve;
|
|
});
|
|
let resolvePrompt!: (value: { stopReason: string }) => void;
|
|
const promptResult = new Promise<{ stopReason: string }>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
});
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "fresh-session", agentSessionId: "fresh-agent" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => {
|
|
await loadFailure;
|
|
throw { error: { code: -32002, message: "session not found" } };
|
|
},
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async (sessionId) => {
|
|
promptActive = true;
|
|
promptSessionId = sessionId;
|
|
resolvePromptStarted();
|
|
return await promptResult;
|
|
},
|
|
requestCancelActivePrompt: async () => {
|
|
promptActive = false;
|
|
resolvePrompt({ stopReason: "cancelled" });
|
|
return true;
|
|
},
|
|
hasActivePrompt: () => promptActive,
|
|
setSessionMode: async (sessionId, modeId) => {
|
|
assert.equal(modeId, "plan");
|
|
setModeSessionId = sessionId;
|
|
},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("fallback-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "oneshot",
|
|
requestId: "req-fallback",
|
|
});
|
|
const eventsPromise = collectEvents(turn.events);
|
|
const setModePromise = manager.setMode(createHandle("fallback-session"), "plan", "oneshot");
|
|
resolveLoadFailure();
|
|
await setModePromise;
|
|
await promptStarted;
|
|
await turn.cancel();
|
|
const events = await eventsPromise;
|
|
const result = await turn.result;
|
|
|
|
assert.equal(setModeSessionId, "fresh-session");
|
|
assert.equal(promptSessionId, "fresh-session");
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "cancelled", stopReason: "cancelled" });
|
|
});
|
|
|
|
test("AcpRuntimeManager honors aborts requested before prompt starts after oneshot load fallback", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "aborted-session",
|
|
acpSessionId: "stale-session",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let promptCalled = false;
|
|
let cancelCalls = 0;
|
|
let resolveLoadFailure!: () => void;
|
|
const loadFailure = new Promise<void>((resolve) => {
|
|
resolveLoadFailure = resolve;
|
|
});
|
|
const client: FakeClient = {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "fresh-session", agentSessionId: "fresh-agent" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => {
|
|
await loadFailure;
|
|
throw { error: { code: -32002, message: "session not found" } };
|
|
},
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
promptCalled = true;
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => {
|
|
cancelCalls += 1;
|
|
return true;
|
|
},
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
};
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => client as never,
|
|
},
|
|
);
|
|
const controller = new AbortController();
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("aborted-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "oneshot",
|
|
requestId: "req-abort",
|
|
signal: controller.signal,
|
|
});
|
|
const eventsPromise = collectEvents(turn.events);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
controller.abort();
|
|
resolveLoadFailure();
|
|
const events = await eventsPromise;
|
|
const result = await turn.result;
|
|
|
|
assert.equal(promptCalled, false);
|
|
assert.equal(cancelCalls, 0);
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "cancelled", stopReason: "cancelled" });
|
|
});
|
|
|
|
test("AcpRuntimeManager handles offline oneshot controls, status, close, and missing records", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "offline-session:oneshot:1",
|
|
acpSessionId: "offline-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
const setModeSessions: string[] = [];
|
|
const setConfigSessions: string[] = [];
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "fresh-offline" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => false,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async (sessionId: string) => {
|
|
setModeSessions.push(sessionId);
|
|
},
|
|
setSessionConfigOption: async (sessionId: string) => {
|
|
setConfigSessions.push(sessionId);
|
|
},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const handle = createHandle("offline-session", "offline-session:oneshot:1");
|
|
|
|
const status = await manager.getStatus(handle);
|
|
assert.match(status.summary ?? "", /session=offline-session/);
|
|
assert.equal(status.details?.closed, false);
|
|
|
|
await manager.setMode(handle, "plan", "oneshot");
|
|
await manager.setConfigOption(handle, "approval", "manual", "oneshot");
|
|
await manager.close(handle);
|
|
|
|
assert.deepEqual(setModeSessions, ["fresh-offline", "fresh-offline"]);
|
|
assert.deepEqual(setConfigSessions, ["fresh-offline"]);
|
|
|
|
const closed = await store.load("offline-session:oneshot:1");
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(typeof closed?.closedAt, "string");
|
|
|
|
await assert.rejects(
|
|
async () => await manager.getStatus(createHandle("missing-session")),
|
|
/ACP session not found/,
|
|
);
|
|
});
|
|
|
|
test("AcpRuntimeManager closes the backend session when discarding persistent state", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "discard-session",
|
|
acpSessionId: "discard-sid",
|
|
agentCommand: "claude --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let startCalls = 0;
|
|
let closeCalls = 0;
|
|
const closedSessionIds: string[] = [];
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {
|
|
startCalls += 1;
|
|
},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
supportsCloseSession: () => true,
|
|
closeSession: async (sessionId: string) => {
|
|
closedSessionIds.push(sessionId);
|
|
},
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
await manager.close(createHandle("discard-session"), {
|
|
discardPersistentState: true,
|
|
});
|
|
|
|
assert.equal(startCalls, 1);
|
|
assert.equal(closeCalls, 1);
|
|
assert.deepEqual(closedSessionIds, ["discard-sid"]);
|
|
const closed = await store.load("discard-session");
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(typeof closed?.closedAt, "string");
|
|
assert.equal(closed?.acpx?.reset_on_next_ensure, true);
|
|
|
|
let recreatedSessions = 0;
|
|
const restartedManager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => {
|
|
recreatedSessions += 1;
|
|
return { sessionId: "fresh-discard-sid", agentSessionId: "fresh-agent" };
|
|
},
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
supportsCloseSession: () => true,
|
|
closeSession: async () => {},
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const recreated = await restartedManager.ensureSession({
|
|
sessionKey: "discard-session",
|
|
agent: "claude",
|
|
mode: "persistent",
|
|
cwd: "/workspace",
|
|
});
|
|
|
|
assert.equal(recreatedSessions, 1);
|
|
assert.equal(recreated.acpSessionId, "fresh-discard-sid");
|
|
assert.equal(recreated.agentSessionId, "fresh-agent");
|
|
assert.equal(recreated.messages.length, 0);
|
|
assert.equal(recreated.acpx?.reset_on_next_ensure, undefined);
|
|
});
|
|
|
|
test("AcpRuntimeManager treats missing backend sessions as a successful discard reset", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "discard-missing-session",
|
|
acpSessionId: "missing-backend-session",
|
|
agentCommand: "claude --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let startCalls = 0;
|
|
let closeCalls = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {
|
|
startCalls += 1;
|
|
},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
supportsCloseSession: () => true,
|
|
closeSession: async () => {
|
|
throw { error: { code: -32002, message: "session not found" } };
|
|
},
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
await manager.close(createHandle("discard-missing-session"), {
|
|
discardPersistentState: true,
|
|
});
|
|
|
|
assert.equal(startCalls, 1);
|
|
assert.equal(closeCalls, 1);
|
|
const closed = await store.load("discard-missing-session");
|
|
assert.equal(closed?.closed, true);
|
|
assert.equal(typeof closed?.closedAt, "string");
|
|
assert.equal(closed?.acpx?.reset_on_next_ensure, true);
|
|
});
|
|
|
|
test("AcpRuntimeManager applies timeoutMs to backend session shutdown during discard reset", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "discard-timeout-session",
|
|
acpSessionId: "slow-backend-session",
|
|
agentCommand: "claude --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let startCalls = 0;
|
|
let closeCalls = 0;
|
|
let closeSessionCalls = 0;
|
|
const never = new Promise<void>(() => {});
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store, timeoutMs: 5 }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {
|
|
startCalls += 1;
|
|
},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
supportsCloseSession: () => true,
|
|
closeSession: async () => {
|
|
closeSessionCalls += 1;
|
|
await never;
|
|
},
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
await assert.rejects(
|
|
async () =>
|
|
await manager.close(createHandle("discard-timeout-session"), {
|
|
discardPersistentState: true,
|
|
}),
|
|
/Timed out after 5ms/,
|
|
);
|
|
|
|
assert.equal(startCalls, 1);
|
|
assert.equal(closeSessionCalls, 1);
|
|
assert.equal(closeCalls, 1);
|
|
const unchanged = await store.load("discard-timeout-session");
|
|
assert.equal(unchanged?.closed, false);
|
|
assert.equal(unchanged?.closedAt, undefined);
|
|
assert.equal(unchanged?.acpx?.reset_on_next_ensure, undefined);
|
|
});
|
|
|
|
test("AcpRuntimeManager fails offline persistent controls clearly when session/load is unavailable", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "offline-persistent-session",
|
|
acpSessionId: "offline-persistent-backend-session",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let createSessionCalls = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => {
|
|
createSessionCalls += 1;
|
|
return { sessionId: "fresh-offline" };
|
|
},
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => false,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
await assert.rejects(
|
|
async () => await manager.setMode(createHandle("offline-persistent-session"), "plan"),
|
|
/Persistent ACP session offline-persistent-backend-session could not be resumed: agent does not support session\/load/,
|
|
);
|
|
await assert.rejects(
|
|
async () =>
|
|
await manager.setConfigOption(
|
|
createHandle("offline-persistent-session"),
|
|
"approval",
|
|
"manual",
|
|
),
|
|
/Persistent ACP session offline-persistent-backend-session could not be resumed: agent does not support session\/load/,
|
|
);
|
|
assert.equal(createSessionCalls, 0);
|
|
});
|
|
|
|
test("AcpRuntimeManager surfaces normalized prompt failures", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "error-session",
|
|
acpSessionId: "error-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
throw new AcpxOperationalError("prompt exploded", {
|
|
outputCode: "RUNTIME",
|
|
detailCode: "AGENT_DISCONNECTED",
|
|
origin: "acp",
|
|
retryable: true,
|
|
});
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("error-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-error",
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, {
|
|
status: "failed",
|
|
error: {
|
|
code: "RUNTIME",
|
|
detailCode: "AGENT_DISCONNECTED",
|
|
message: "prompt exploded",
|
|
retryable: true,
|
|
},
|
|
});
|
|
const legacyEvents = await collectEvents(
|
|
manager.runTurn({
|
|
handle: createHandle("error-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-error-legacy",
|
|
}),
|
|
);
|
|
assert.deepEqual(legacyEvents, [
|
|
{
|
|
type: "error",
|
|
code: "RUNTIME",
|
|
detailCode: "AGENT_DISCONNECTED",
|
|
message: "prompt exploded",
|
|
retryable: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("AcpRuntimeManager rejects unsupported runtime attachment media types", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "attachment-session",
|
|
acpSessionId: "attachment-sid",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => true,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => ({ stopReason: "end_turn" }),
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
manager.startTurn({
|
|
handle: createHandle("attachment-session"),
|
|
text: "",
|
|
attachments: [{ mediaType: "application/pdf", data: "Zm9v" }],
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-attachment",
|
|
}),
|
|
/Unsupported ACP runtime attachment media type: application\/pdf/,
|
|
);
|
|
});
|
|
|
|
test("AcpRuntimeManager fails persistent turns clearly when session/load is unavailable", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "persistent-session",
|
|
acpSessionId: "persistent-backend-session",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let createSessionCalls = 0;
|
|
let promptCalls = 0;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => {
|
|
createSessionCalls += 1;
|
|
return { sessionId: "fresh-session" };
|
|
},
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => false,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
promptCalls += 1;
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("persistent-session"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-persistent",
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, {
|
|
status: "failed",
|
|
error: {
|
|
code: "RUNTIME",
|
|
detailCode: "SESSION_RESUME_REQUIRED",
|
|
message:
|
|
"Persistent ACP session persistent-backend-session could not be resumed: agent does not support session/load",
|
|
retryable: true,
|
|
},
|
|
});
|
|
assert.equal(createSessionCalls, 0);
|
|
assert.equal(promptCalls, 0);
|
|
});
|
|
|
|
test("AcpRuntimeManager still falls back to a fresh session for oneshot turns", async () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "oneshot-session:oneshot:1",
|
|
acpSessionId: "stale-backend-session",
|
|
agentCommand: "codex --acp",
|
|
cwd: "/workspace",
|
|
});
|
|
const store = new InMemorySessionStore([record]);
|
|
let promptSessionId: string | undefined;
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () =>
|
|
({
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({
|
|
sessionId: "fresh-session",
|
|
agentSessionId: "fresh-agent",
|
|
}),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => false,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async (sessionId: string) => {
|
|
promptSessionId = sessionId;
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("oneshot-session", "oneshot-session:oneshot:1"),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "oneshot",
|
|
requestId: "req-oneshot",
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
assert.equal(promptSessionId, "fresh-session");
|
|
const saved = await store.load("oneshot-session:oneshot:1");
|
|
assert.equal(saved?.acpSessionId, "fresh-session");
|
|
assert.equal(saved?.agentSessionId, "fresh-agent");
|
|
});
|
|
|
|
test("AcpRuntimeManager falls back when a kept-open persistent client is no longer reusable", async () => {
|
|
const store = new InMemorySessionStore();
|
|
let firstClientReusable = true;
|
|
let firstClientCloseCalls = 0;
|
|
let firstClientPromptCalls = 0;
|
|
let secondClientPromptCalls = 0;
|
|
let constructed = 0;
|
|
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
constructed += 1;
|
|
if (constructed === 1) {
|
|
return {
|
|
start: async () => {},
|
|
close: async () => {
|
|
firstClientCloseCalls += 1;
|
|
},
|
|
createSession: async () => ({
|
|
sessionId: "pending-session-id",
|
|
agentSessionId: "pending-agent-id",
|
|
}),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => firstClientReusable,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: firstClientReusable }),
|
|
prompt: async () => {
|
|
firstClientPromptCalls += 1;
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
} as never;
|
|
}
|
|
|
|
return {
|
|
start: async () => {},
|
|
close: async () => {},
|
|
createSession: async () => ({ sessionId: "unused" }),
|
|
loadSession: async () => ({ agentSessionId: "unused" }),
|
|
hasReusableSession: () => false,
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => ({ agentSessionId: "resumed-agent-id" }),
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async () => {
|
|
secondClientPromptCalls += 1;
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async () => {},
|
|
setSessionConfigOption: async () => {},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
} as never;
|
|
},
|
|
},
|
|
);
|
|
|
|
const record = await manager.ensureSession({
|
|
sessionKey: "pending-persistent-session",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
firstClientReusable = false;
|
|
|
|
const turn = manager.startTurn({
|
|
handle: createHandle("pending-persistent-session", record.acpxRecordId),
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-pending-persistent-session",
|
|
});
|
|
const { events, result } = await collectTurn(turn);
|
|
|
|
assert.deepEqual(events, []);
|
|
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
|
|
assert.equal(firstClientCloseCalls, 1);
|
|
assert.equal(firstClientPromptCalls, 0);
|
|
assert.equal(secondClientPromptCalls, 1);
|
|
assert.equal(constructed, 2);
|
|
});
|
|
|
|
test("AcpRuntimeManager reuses a kept-open persistent client for controls before the first turn", async () => {
|
|
const store = new InMemorySessionStore();
|
|
let constructed = 0;
|
|
let createSessionCalls = 0;
|
|
let loadSessionCalls = 0;
|
|
let promptCalls = 0;
|
|
let closeCalls = 0;
|
|
const setModeSessions: string[] = [];
|
|
const setConfigCalls: Array<{ sessionId: string; key: string; value: string }> = [];
|
|
|
|
const manager = new AcpRuntimeManager(
|
|
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
|
|
{
|
|
clientFactory: () => {
|
|
constructed += 1;
|
|
return {
|
|
start: async () => {},
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
createSession: async () => {
|
|
createSessionCalls += 1;
|
|
return {
|
|
sessionId: "pending-session-id",
|
|
agentSessionId: "pending-agent-id",
|
|
};
|
|
},
|
|
loadSession: async () => {
|
|
loadSessionCalls += 1;
|
|
return { agentSessionId: "unexpected-agent-id" };
|
|
},
|
|
hasReusableSession: (sessionId: string) => sessionId === "pending-session-id",
|
|
supportsLoadSession: () => true,
|
|
loadSessionWithOptions: async () => {
|
|
loadSessionCalls += 1;
|
|
return { agentSessionId: "unexpected-agent-id" };
|
|
},
|
|
getAgentLifecycleSnapshot: () => ({ running: true }),
|
|
prompt: async (sessionId: string) => {
|
|
promptCalls += 1;
|
|
assert.equal(sessionId, "pending-session-id");
|
|
return { stopReason: "end_turn" };
|
|
},
|
|
requestCancelActivePrompt: async () => false,
|
|
hasActivePrompt: () => false,
|
|
setSessionMode: async (sessionId: string, modeId: string) => {
|
|
assert.equal(modeId, "auto");
|
|
setModeSessions.push(sessionId);
|
|
},
|
|
setSessionConfigOption: async (sessionId: string, key: string, value: string) => {
|
|
setConfigCalls.push({ sessionId, key, value });
|
|
return {
|
|
configOptions: [
|
|
{
|
|
id: key,
|
|
name: "Approval",
|
|
type: "select",
|
|
currentValue: value,
|
|
options: [{ value, name: "Manual" }],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
clearEventHandlers: () => {},
|
|
setEventHandlers: () => {},
|
|
} as never;
|
|
},
|
|
},
|
|
);
|
|
|
|
const record = await manager.ensureSession({
|
|
sessionKey: "pending-control-session",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
const handle = createHandle("pending-control-session", record.acpxRecordId);
|
|
|
|
await manager.setMode(handle, "auto");
|
|
await manager.setConfigOption(handle, "approval", "manual");
|
|
const status = await manager.getStatus(handle);
|
|
const events = await collectEvents(
|
|
manager.runTurn({
|
|
handle,
|
|
text: "hello",
|
|
mode: "prompt",
|
|
sessionMode: "persistent",
|
|
requestId: "req-pending-control-session",
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(events, [{ type: "done", stopReason: "end_turn" }]);
|
|
assert.equal(constructed, 1);
|
|
assert.equal(createSessionCalls, 1);
|
|
assert.equal(loadSessionCalls, 0);
|
|
assert.equal(promptCalls, 1);
|
|
assert.deepEqual(setModeSessions, ["pending-session-id"]);
|
|
assert.deepEqual(setConfigCalls, [
|
|
{
|
|
sessionId: "pending-session-id",
|
|
key: "approval",
|
|
value: "manual",
|
|
},
|
|
]);
|
|
assert.deepEqual(status.details?.configOptions, [
|
|
{
|
|
id: "approval",
|
|
name: "Approval",
|
|
type: "select",
|
|
currentValue: "manual",
|
|
options: [{ value: "manual", name: "Manual" }],
|
|
},
|
|
]);
|
|
assert.deepEqual(store.records.get(record.acpxRecordId)?.acpx?.desired_config_options, {
|
|
approval: "manual",
|
|
});
|
|
assert.equal(closeCalls, 0);
|
|
|
|
await manager.close(handle);
|
|
|
|
assert.equal(closeCalls, 1);
|
|
});
|