mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Plumbs persistSession through the structured response pipeline so metadata, branch name, commit message, and PR text generators no longer leave provider-side artifacts behind: - Codex: passes ephemeral: true on thread/start (no rollout file) - OpenCode: deletes the session via session.delete on close - Claude: forwards persistSession: false to the SDK query Adds a real-credentials e2e test per provider that asserts the generated title is applied AND no persistence artifact is produced (rollout for Codex, project JSONL for Claude, leftover session for OpenCode).
5228 lines
152 KiB
TypeScript
5228 lines
152 KiB
TypeScript
import { expect, test, vi } from "vitest";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
|
import { AgentManager } from "./agent-manager.js";
|
|
import { AgentStorage } from "./agent-storage.js";
|
|
import type {
|
|
AgentClient,
|
|
AgentCreateSessionOptions,
|
|
AgentFeature,
|
|
AgentLaunchContext,
|
|
AgentProvider,
|
|
AgentPersistenceHandle,
|
|
AgentRunResult,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentStreamEvent,
|
|
AgentTimelineItem,
|
|
PersistedAgentDescriptor,
|
|
} from "./agent-sdk-types.js";
|
|
import type { ProviderDefinition } from "./provider-registry.js";
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
const TEST_CAPABILITIES = {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: false,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
} as const;
|
|
|
|
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
|
return {
|
|
type: "toggle",
|
|
id: args.id,
|
|
label: args.label,
|
|
value: args.value,
|
|
};
|
|
}
|
|
|
|
function createPersistedDescriptor(args: {
|
|
cwd: string;
|
|
sessionId: string;
|
|
nativeHandle?: string;
|
|
}): PersistedAgentDescriptor {
|
|
return {
|
|
provider: "codex",
|
|
sessionId: args.sessionId,
|
|
cwd: args.cwd,
|
|
title: null,
|
|
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
|
|
persistence: {
|
|
provider: "codex",
|
|
sessionId: args.sessionId,
|
|
nativeHandle: args.nativeHandle,
|
|
metadata: {
|
|
provider: "codex",
|
|
cwd: args.cwd,
|
|
},
|
|
},
|
|
timeline: [],
|
|
};
|
|
}
|
|
|
|
class TestAgentClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
async listModels() {
|
|
return [
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
isDefault: true,
|
|
},
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4-mini",
|
|
label: "GPT-5.4 Mini",
|
|
},
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.2-codex",
|
|
label: "GPT-5.2 Codex",
|
|
},
|
|
];
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
_launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
return new TestAgentSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
class TestAgentSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private runtimeModel: string | null = null;
|
|
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
|
private turnIdCounter = 0;
|
|
private interrupted = false;
|
|
|
|
constructor(private readonly config: AgentSessionConfig) {}
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return {
|
|
sessionId: this.id ?? this.config.provider,
|
|
finalText: "",
|
|
timeline: [],
|
|
};
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
this.interrupted = false;
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
// Use setTimeout so events arrive after the caller sets up the foreground waiter
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
this.runtimeModel = "gpt-5.2-codex";
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subscribers.add(callback);
|
|
return () => {
|
|
this.subscribers.delete(callback);
|
|
};
|
|
}
|
|
|
|
pushEvent(event: AgentStreamEvent): void {
|
|
for (const cb of this.subscribers) {
|
|
try {
|
|
cb(event);
|
|
} catch {
|
|
// error isolation per design
|
|
}
|
|
}
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: this.runtimeModel ?? this.config.model ?? null,
|
|
modeId: this.config.modeId ?? null,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
};
|
|
}
|
|
|
|
async interrupt(): Promise<void> {
|
|
this.interrupted = true;
|
|
}
|
|
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class StreamingAssistantSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
|
private turnIdCounter = 0;
|
|
|
|
constructor(private readonly config: AgentSessionConfig) {}
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return {
|
|
sessionId: this.id,
|
|
finalText: "",
|
|
timeline: [],
|
|
};
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: { type: "assistant_message", text: "final " },
|
|
});
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: { type: "assistant_message", text: "reply" },
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subscribers.add(callback);
|
|
return () => {
|
|
this.subscribers.delete(callback);
|
|
};
|
|
}
|
|
|
|
private pushEvent(event: AgentStreamEvent): void {
|
|
for (const callback of this.subscribers) {
|
|
callback(event);
|
|
}
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: this.config.model ?? null,
|
|
modeId: this.config.modeId ?? null,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
};
|
|
}
|
|
|
|
async interrupt(): Promise<void> {}
|
|
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class StreamingAssistantClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new StreamingAssistantSession(config);
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new StreamingAssistantSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const logger = createTestLogger();
|
|
|
|
test("normalizeConfig injects the provider default model when omitted", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000101",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
expect(snapshot.config.model).toBe("gpt-5.4");
|
|
expect(snapshot.config.modeId).toBe("auto");
|
|
});
|
|
|
|
test("normalizeConfig strips legacy 'default' model id", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000102",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "default",
|
|
});
|
|
|
|
expect(snapshot.config.model).toBe("gpt-5.4");
|
|
expect(snapshot.config.modeId).toBe("auto");
|
|
});
|
|
|
|
test("setAgentMode persists the selected mode across session reload", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class ModeAwareSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private currentMode: string | null;
|
|
|
|
constructor(private readonly config: AgentSessionConfig) {
|
|
this.currentMode = config.modeId ?? null;
|
|
}
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
return { turnId: "turn-1" };
|
|
}
|
|
|
|
subscribe(): () => void {
|
|
return () => {};
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: this.config.model ?? null,
|
|
modeId: this.currentMode,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return this.currentMode;
|
|
}
|
|
|
|
async setMode(modeId: string): Promise<void> {
|
|
this.currentMode = modeId;
|
|
}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence() {
|
|
return { provider: this.provider, sessionId: this.id };
|
|
}
|
|
|
|
async interrupt(): Promise<void> {}
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class ModeAwareClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ModeAwareSession(config);
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new ModeAwareSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? workdir,
|
|
modeId: config?.modeId,
|
|
model: config?.model,
|
|
});
|
|
}
|
|
|
|
async listModels() {
|
|
return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }];
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ModeAwareClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000301",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
modeId: "auto",
|
|
});
|
|
|
|
await manager.setAgentMode(snapshot.id, "full-access");
|
|
|
|
const beforeReload = manager.getAgent(snapshot.id);
|
|
expect(beforeReload?.config.modeId).toBe("full-access");
|
|
expect(beforeReload?.currentModeId).toBe("full-access");
|
|
|
|
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
|
expect(reloaded.config.modeId).toBe("full-access");
|
|
expect(reloaded.currentModeId).toBe("full-access");
|
|
});
|
|
|
|
test("reloadAgentSession completes when the previous session close hangs", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class HangingCloseSession extends TestAgentSession {
|
|
closeCalled = false;
|
|
|
|
override async close(): Promise<void> {
|
|
this.closeCalled = true;
|
|
await new Promise(() => {});
|
|
}
|
|
}
|
|
|
|
class HangingCloseClient extends TestAgentClient {
|
|
readonly firstSession = new HangingCloseSession({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
resumeSessionCalls = 0;
|
|
|
|
override async createSession(): Promise<AgentSession> {
|
|
return this.firstSession;
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumeSessionCalls += 1;
|
|
return new TestAgentSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? workdir,
|
|
});
|
|
}
|
|
}
|
|
|
|
const client = new HangingCloseClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { reloadSessionCloseMs: 10 },
|
|
idFactory: () => "00000000-0000-4000-8000-000000000302",
|
|
});
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
|
|
|
expect(reloaded.id).toBe(snapshot.id);
|
|
expect(client.firstSession.closeCalled).toBe(true);
|
|
expect(client.resumeSessionCalls).toBe(1);
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cancelAgentRun completes when provider interrupt hangs", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-interrupt-timeout-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class HangingInterruptSession extends TestAgentSession {
|
|
interruptCalled = false;
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCalled = true;
|
|
await new Promise(() => {});
|
|
}
|
|
}
|
|
|
|
class HangingInterruptClient extends TestAgentClient {
|
|
readonly session = new HangingInterruptSession({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
override async createSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
const client = new HangingInterruptClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { interruptSessionMs: 10 },
|
|
idFactory: () => "00000000-0000-4000-8000-000000000303",
|
|
});
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_state" &&
|
|
event.agent.id === snapshot.id &&
|
|
event.agent.lifecycle === "running"
|
|
) {
|
|
unsubscribe();
|
|
resolve();
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
client.session.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: "hanging-interrupt-turn",
|
|
});
|
|
});
|
|
|
|
await expect(manager.cancelAgentRun(snapshot.id)).resolves.toBe(true);
|
|
expect(client.session.interruptCalled).toBe(true);
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("listProviderAvailability uses registered client keys, including custom providers", async () => {
|
|
const customClient: AgentClient = {
|
|
provider: "zai",
|
|
capabilities: TEST_CAPABILITIES,
|
|
async isAvailable() {
|
|
return true;
|
|
},
|
|
async createSession() {
|
|
throw new Error("not implemented");
|
|
},
|
|
async resumeSession() {
|
|
throw new Error("not implemented");
|
|
},
|
|
};
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
zai: customClient,
|
|
},
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.listProviderAvailability()).resolves.toEqual([
|
|
{
|
|
provider: "zai",
|
|
available: true,
|
|
error: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("createAgent passes daemon launch env through the provider launch context", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
lastLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
this.lastLaunchContext = launchContext;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const client = new CaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
expect(client.lastConfig).toEqual({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
modeId: "auto",
|
|
});
|
|
expect(client.lastLaunchContext).toEqual({
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("createAgent passes persistSession to provider create options", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
lastCreateOptions: AgentCreateSessionOptions | undefined;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
_launchContext?: AgentLaunchContext,
|
|
options?: AgentCreateSessionOptions,
|
|
): Promise<AgentSession> {
|
|
this.lastCreateOptions = options;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const client = new CaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
|
});
|
|
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ persistSession: false },
|
|
);
|
|
|
|
expect(client.lastCreateOptions).toEqual({ persistSession: false });
|
|
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const client = new CaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(snapshot.config.mcpServers).toEqual({
|
|
paseo: {
|
|
type: "http",
|
|
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
|
},
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
|
});
|
|
|
|
test("createAgent preserves a user-provided paseo MCP config", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const client = new CaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "https://example.com/custom-paseo",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(snapshot.config.mcpServers).toEqual({
|
|
paseo: {
|
|
type: "http",
|
|
url: "https://example.com/custom-paseo",
|
|
},
|
|
});
|
|
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
|
});
|
|
|
|
test("createAgent fails when cwd does not exist", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent({
|
|
provider: "codex",
|
|
cwd: join(workdir, "does-not-exist"),
|
|
}),
|
|
).rejects.toThrow("Working directory does not exist");
|
|
});
|
|
|
|
test("createAgent reports configured providers when provider is unknown", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent({
|
|
provider: "missing-provider",
|
|
cwd: workdir,
|
|
}),
|
|
).rejects.toThrow("Unknown provider 'missing-provider'. Configured providers: codex.");
|
|
});
|
|
|
|
test("createAgent reports available providers when selected provider is unavailable", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class UnavailableCodexClient extends TestAgentClient {
|
|
override async isAvailable(): Promise<boolean> {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new UnavailableCodexClient(),
|
|
claude: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
}),
|
|
).rejects.toThrow(
|
|
"Provider 'codex' is not available. Available providers: claude. Use one of those providers, or install/configure 'codex'.",
|
|
);
|
|
});
|
|
|
|
test("createAgent rejects a disabled provider without creating a session", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class DisabledCodexClient extends TestAgentClient {
|
|
createSessionCalls = 0;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
}
|
|
|
|
const disabledClient = new DisabledCodexClient();
|
|
const providerDefinitions = {
|
|
codex: {
|
|
enabled: false,
|
|
},
|
|
} satisfies Partial<Record<AgentProvider, Pick<ProviderDefinition, "enabled">>>;
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: disabledClient,
|
|
},
|
|
providerDefinitions,
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
}),
|
|
).rejects.toThrow("Provider 'codex' is disabled");
|
|
expect(disabledClient.createSessionCalls).toBe(0);
|
|
expect(await storage.list()).toHaveLength(0);
|
|
});
|
|
|
|
test("updateProviderRegistry re-enables a previously disabled provider", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new TestAgentClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
providerDefinitions: {
|
|
codex: { enabled: false },
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
|
|
"Provider 'codex' is disabled",
|
|
);
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { codex: { enabled: true } },
|
|
clients: { codex: client },
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
expect(snapshot.config.provider).toBe("codex");
|
|
});
|
|
|
|
test("updateProviderRegistry disables a previously enabled provider", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new TestAgentClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
providerDefinitions: {
|
|
codex: { enabled: true },
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { codex: { enabled: false } },
|
|
clients: { codex: client },
|
|
});
|
|
|
|
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
|
|
"Provider 'codex' is disabled",
|
|
);
|
|
});
|
|
|
|
test("updateProviderRegistry registers a previously unknown provider", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {},
|
|
providerDefinitions: {},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
expect(manager.getRegisteredProviderIds()).not.toContain("codex");
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { codex: { enabled: true } },
|
|
clients: { codex: new TestAgentClient() },
|
|
});
|
|
|
|
expect(manager.getRegisteredProviderIds()).toContain("codex");
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
expect(snapshot.config.provider).toBe("codex");
|
|
});
|
|
|
|
test("createAgent passes explicit model strings through to the provider", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
class CaptureModelClient extends TestAgentClient {
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
const client = new CaptureModelClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "not-a-real-model",
|
|
});
|
|
|
|
expect(client.lastConfig?.model).toBe("not-a-real-model");
|
|
});
|
|
|
|
test("resumeAgentFromPersistence keeps metadata config, applies overrides, and passes launch env", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class ResumeCaptureClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
lastResumeOverrides: Partial<AgentSessionConfig> | undefined;
|
|
lastResumeLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastResumeOverrides = overrides;
|
|
this.lastResumeLaunchContext = launchContext;
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const merged: AgentSessionConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: "codex",
|
|
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
|
};
|
|
return new TestAgentSession(merged);
|
|
}
|
|
}
|
|
|
|
const client = new ResumeCaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000106",
|
|
});
|
|
|
|
const handle: AgentPersistenceHandle = {
|
|
provider: "codex",
|
|
sessionId: "resume-session-1",
|
|
metadata: {
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
systemPrompt: "old prompt",
|
|
mcpServers: {
|
|
legacy: {
|
|
type: "stdio",
|
|
command: "legacy-bridge",
|
|
args: ["/tmp/legacy.sock"],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const resumed = await manager.resumeAgentFromPersistence(handle, {
|
|
cwd: workdir,
|
|
systemPrompt: "new prompt",
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(resumed.config.systemPrompt).toBe("new prompt");
|
|
expect(resumed.config.mcpServers).toEqual({
|
|
paseo: {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"],
|
|
},
|
|
});
|
|
expect(client.lastResumeOverrides).toMatchObject({
|
|
systemPrompt: "new prompt",
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"],
|
|
},
|
|
},
|
|
});
|
|
expect(client.lastResumeLaunchContext).toEqual({
|
|
env: {
|
|
PASEO_AGENT_ID: resumed.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("findPersistedAgent returns matching descriptors by session id or native handle", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const descriptors: PersistedAgentDescriptor[] = [
|
|
createPersistedDescriptor({
|
|
cwd: workdir,
|
|
sessionId: "session-direct",
|
|
nativeHandle: "native-direct",
|
|
}),
|
|
createPersistedDescriptor({
|
|
cwd: workdir,
|
|
sessionId: "session-other",
|
|
nativeHandle: "native-match",
|
|
}),
|
|
];
|
|
|
|
class PersistedAgentsClient extends TestAgentClient {
|
|
lastLimit: number | undefined;
|
|
|
|
override async listPersistedAgents(options?: { limit?: number }) {
|
|
this.lastLimit = options?.limit;
|
|
return descriptors;
|
|
}
|
|
}
|
|
|
|
const client = new PersistedAgentsClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]);
|
|
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]);
|
|
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull();
|
|
expect(client.lastLimit).toBe(200);
|
|
});
|
|
|
|
test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-context-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class ReloadCaptureClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
lastCreateLaunchContext: AgentLaunchContext | undefined;
|
|
lastResumeLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastCreateLaunchContext = launchContext;
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastResumeLaunchContext = launchContext;
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const merged: AgentSessionConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: "codex",
|
|
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
|
};
|
|
return new TestAgentSession(merged);
|
|
}
|
|
}
|
|
|
|
const client = new ReloadCaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000108",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
expect(client.lastCreateLaunchContext).toEqual({
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
},
|
|
});
|
|
|
|
await manager.reloadAgentSession(snapshot.id, {
|
|
systemPrompt: "reloaded prompt",
|
|
});
|
|
|
|
expect(client.lastResumeLaunchContext).toEqual({
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("reloadAgentSession preserves timeline and does not force history replay", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class HistoryProbeSession extends TestAgentSession {
|
|
constructor(
|
|
config: AgentSessionConfig,
|
|
private readonly historyText: string | null,
|
|
) {
|
|
super(config);
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
if (!this.historyText) {
|
|
return;
|
|
}
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: this.historyText },
|
|
};
|
|
}
|
|
}
|
|
|
|
class HistoryProbeClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new HistoryProbeSession(config, null);
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const merged: AgentSessionConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: "codex",
|
|
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
|
};
|
|
return new HistoryProbeSession(merged, "history replay from provider");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new HistoryProbeClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000113",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "keep this timeline in memory",
|
|
});
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
const beforeReload = manager.getTimeline(snapshot.id);
|
|
expect(beforeReload).toHaveLength(1);
|
|
|
|
await manager.reloadAgentSession(snapshot.id, {
|
|
systemPrompt: "reloaded prompt",
|
|
});
|
|
const afterReload = manager.getTimeline(snapshot.id);
|
|
expect(afterReload).toEqual(beforeReload);
|
|
|
|
// If reload resets historyPrimed, this would replay provider history and append another item.
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
const afterHydrate = manager.getTimeline(snapshot.id);
|
|
expect(afterHydrate).toEqual(beforeReload);
|
|
});
|
|
|
|
test("reloadAgentSession preserves current title when config title is unset", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
await manager.setTitle(snapshot.id, "Generated title");
|
|
|
|
const beforeReload = await storage.get(snapshot.id);
|
|
expect(beforeReload?.title).toBe("Generated title");
|
|
expect(beforeReload?.config?.title).toBeUndefined();
|
|
|
|
await manager.reloadAgentSession(snapshot.id);
|
|
|
|
const afterReload = await storage.get(snapshot.id);
|
|
expect(afterReload?.title).toBe("Generated title");
|
|
expect(afterReload?.config?.title).toBeUndefined();
|
|
});
|
|
|
|
test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000127",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const before = await storage.get(snapshot.id);
|
|
expect(before).not.toBeNull();
|
|
|
|
await manager.setTitle(snapshot.id, "Generated title");
|
|
|
|
const after = await storage.get(snapshot.id);
|
|
expect(after?.title).toBe("Generated title");
|
|
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
|
|
|
|
const live = manager.getAgent(snapshot.id);
|
|
expect(live).not.toBeNull();
|
|
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
|
});
|
|
|
|
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000132",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
modeId: "plan",
|
|
model: "gpt-5.2-codex",
|
|
thinkingOptionId: "low",
|
|
});
|
|
|
|
await manager.setAgentMode(snapshot.id, "build");
|
|
await manager.setAgentModel(snapshot.id, "gpt-5.4");
|
|
await manager.setAgentThinkingOption(snapshot.id, "high");
|
|
await manager.flush();
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted).not.toBeNull();
|
|
expect(persisted?.lastModeId).toBe("build");
|
|
expect(persisted?.config?.model).toBe("gpt-5.4");
|
|
expect(persisted?.config?.thinkingOptionId).toBe("high");
|
|
expect(persisted?.runtimeInfo?.modeId).toBe("build");
|
|
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
|
});
|
|
|
|
test("session config drift events update state through the stream channel", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
|
|
let capturedSession: TestAgentSession | null = null;
|
|
class ConfigEventClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
capturedSession = new TestAgentSession(config);
|
|
return capturedSession;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ConfigEventClient(),
|
|
},
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
modeId: "plan",
|
|
model: "gpt-5.2-codex",
|
|
thinkingOptionId: "low",
|
|
});
|
|
const streams: AgentStreamEvent[] = [];
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_stream") {
|
|
streams.push(event.event);
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
|
|
capturedSession?.pushEvent({
|
|
type: "mode_changed",
|
|
provider: "codex",
|
|
currentModeId: "build",
|
|
availableModes: [
|
|
{ id: "plan", label: "Plan" },
|
|
{ id: "build", label: "Build" },
|
|
],
|
|
});
|
|
capturedSession?.pushEvent({
|
|
type: "model_changed",
|
|
provider: "codex",
|
|
runtimeInfo: {
|
|
provider: "codex",
|
|
sessionId: capturedSession.id,
|
|
model: "gpt-5.4",
|
|
modeId: "build",
|
|
thinkingOptionId: "low",
|
|
},
|
|
});
|
|
capturedSession?.pushEvent({
|
|
type: "thinking_option_changed",
|
|
provider: "codex",
|
|
thinkingOptionId: "high",
|
|
});
|
|
await manager.flush();
|
|
|
|
const agent = manager.getAgent(snapshot.id);
|
|
expect(agent?.currentModeId).toBe("build");
|
|
expect(agent?.availableModes).toEqual([
|
|
{ id: "plan", label: "Plan" },
|
|
{ id: "build", label: "Build" },
|
|
]);
|
|
expect(agent?.runtimeInfo).toMatchObject({
|
|
model: "gpt-5.4",
|
|
modeId: "build",
|
|
thinkingOptionId: "high",
|
|
});
|
|
expect(streams.map((event) => event.type)).toEqual([]);
|
|
});
|
|
|
|
test("setLabels merges and persists labels", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Label test",
|
|
});
|
|
|
|
await manager.setLabels(snapshot.id, { surface: "mobile" });
|
|
await manager.setLabels(snapshot.id, { phase: "1a" });
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.labels).toEqual({
|
|
surface: "mobile",
|
|
phase: "1a",
|
|
});
|
|
});
|
|
|
|
test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000134",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Finished attention test",
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, "say hello");
|
|
await manager.flush();
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.lastStatus).toBe("idle");
|
|
expect(persisted?.requiresAttention).toBe(true);
|
|
expect(persisted?.attentionReason).toBe("finished");
|
|
expect(persisted?.attentionTimestamp).toEqual(expect.any(String));
|
|
});
|
|
|
|
test("archiveSnapshot clears persisted attention and normalizes running status", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-attention-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000135",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Archive attention test",
|
|
});
|
|
|
|
const live = manager.getAgent(snapshot.id);
|
|
expect(live).not.toBeNull();
|
|
live!.lifecycle = "running";
|
|
live!.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: new Date("2025-01-02T00:00:00.000Z"),
|
|
};
|
|
|
|
const archivedAt = "2025-01-03T00:00:00.000Z";
|
|
const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt);
|
|
|
|
expect(archivedRecord.archivedAt).toBe(archivedAt);
|
|
expect(archivedRecord.lastStatus).toBe("idle");
|
|
expect(archivedRecord.requiresAttention).toBe(false);
|
|
expect(archivedRecord.attentionReason).toBeNull();
|
|
expect(archivedRecord.attentionTimestamp).toBeNull();
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.archivedAt).toBe(archivedAt);
|
|
expect(persisted?.lastStatus).toBe("idle");
|
|
expect(persisted?.requiresAttention).toBe(false);
|
|
expect(persisted?.attentionReason).toBeNull();
|
|
expect(persisted?.attentionTimestamp).toBeNull();
|
|
});
|
|
|
|
test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class DelayedPersistenceSession extends TestAgentSession {
|
|
private persistenceReady = false;
|
|
private delayedInterrupted = false;
|
|
private releaseGate: (() => void) | null = null;
|
|
private readonly gate = new Promise<void>((resolve) => {
|
|
this.releaseGate = resolve;
|
|
});
|
|
private activeTurnId: string | null = null;
|
|
|
|
constructor(
|
|
config: AgentSessionConfig,
|
|
private readonly stableSessionId: string,
|
|
initiallyReady = false,
|
|
) {
|
|
super(config);
|
|
this.persistenceReady = initiallyReady;
|
|
}
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.delayedInterrupted = false;
|
|
const turnId = `delayed-turn-${Date.now()}`;
|
|
this.activeTurnId = turnId;
|
|
// Push turn_started, then thread_started, then wait on gate
|
|
setTimeout(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.persistenceReady = true;
|
|
this.pushEvent({
|
|
type: "thread_started",
|
|
provider: this.provider,
|
|
sessionId: this.stableSessionId,
|
|
});
|
|
await this.gate;
|
|
if (this.delayedInterrupted) {
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "Interrupted",
|
|
turnId,
|
|
});
|
|
} else {
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.persistenceReady ? this.stableSessionId : null,
|
|
model: null,
|
|
modeId: null,
|
|
};
|
|
}
|
|
|
|
describePersistence() {
|
|
if (!this.persistenceReady) {
|
|
return null;
|
|
}
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.stableSessionId,
|
|
};
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.delayedInterrupted = true;
|
|
this.releaseGate?.();
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.delayedInterrupted = true;
|
|
this.releaseGate?.();
|
|
}
|
|
}
|
|
|
|
class DelayedPersistenceClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
createSessionCalls = 0;
|
|
resumeSessionCalls = 0;
|
|
private nextSessionNumber = 1;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const sessionId = `delayed-session-${this.nextSessionNumber++}`;
|
|
this.createSessionCalls += 1;
|
|
return new DelayedPersistenceSession(config, sessionId);
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumeSessionCalls += 1;
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const merged: AgentSessionConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: "codex",
|
|
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
|
};
|
|
return new DelayedPersistenceSession(merged, handle.sessionId, true);
|
|
}
|
|
}
|
|
|
|
const client = new DelayedPersistenceClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000114",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
expect(snapshot.persistence).toBeNull();
|
|
|
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
|
const first = await stream.next();
|
|
expect(first.done).toBe(false);
|
|
expect(first.value?.type).toBe("turn_started");
|
|
|
|
// Wait for the thread_started event to propagate through subscribe
|
|
// (it's a session-level event, not forwarded to the foreground stream)
|
|
await vi.waitFor(() => {
|
|
const active = manager.getAgent(snapshot.id);
|
|
expect(active?.persistence?.sessionId).toBe("delayed-session-1");
|
|
});
|
|
|
|
const active = manager.getAgent(snapshot.id);
|
|
expect(active?.lifecycle).toBe("running");
|
|
|
|
const reloaded = await manager.reloadAgentSession(snapshot.id, {
|
|
systemPrompt: "voice mode on",
|
|
});
|
|
|
|
expect(client.createSessionCalls).toBe(1);
|
|
expect(client.resumeSessionCalls).toBe(1);
|
|
expect(reloaded.persistence?.sessionId).toBe("delayed-session-1");
|
|
|
|
// Drain stream after cancellation to ensure clean shutdown.
|
|
while (true) {
|
|
const next = await stream.next();
|
|
if (next.done) {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("fetchTimeline returns a bounded reset window when cursor epoch is stale", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000118",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "one",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "two",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "three",
|
|
});
|
|
|
|
const baseline = manager.fetchTimeline(snapshot.id, {
|
|
direction: "tail",
|
|
limit: 2,
|
|
});
|
|
expect(baseline.rows).toHaveLength(2);
|
|
|
|
const result = manager.fetchTimeline(snapshot.id, {
|
|
direction: "after",
|
|
cursor: {
|
|
epoch: "stale-epoch",
|
|
seq: baseline.rows[baseline.rows.length - 1]!.seq,
|
|
},
|
|
limit: 1,
|
|
});
|
|
|
|
expect(result.reset).toBe(true);
|
|
expect(result.staleCursor).toBe(true);
|
|
expect(result.gap).toBe(false);
|
|
expect(result.rows).toHaveLength(1);
|
|
expect(result.rows[0]?.seq).toBe(3);
|
|
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
|
|
expect(result.hasOlder).toBe(true);
|
|
});
|
|
|
|
test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000140",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "row one",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "row two",
|
|
});
|
|
|
|
await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([
|
|
{
|
|
seq: 1,
|
|
timestamp: expect.any(String),
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "row one",
|
|
},
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: expect.any(String),
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "row two",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("getAgent does not expose committed history internals once manager owns the seam", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-boundary-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000138",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
manager.recordUserMessage(snapshot.id, "hello boundary", {
|
|
messageId: "msg-boundary-1",
|
|
emitState: false,
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "history stays behind manager",
|
|
});
|
|
|
|
const live = manager.getAgent(snapshot.id) as Record<string, unknown>;
|
|
expect(live).not.toBeNull();
|
|
expect("timeline" in live).toBe(false);
|
|
expect("timelineRows" in live).toBe(false);
|
|
expect("timelineNextSeq" in live).toBe(false);
|
|
|
|
expect(manager.getTimeline(snapshot.id)).toEqual([
|
|
{
|
|
type: "user_message",
|
|
text: "hello boundary",
|
|
messageId: "msg-boundary-1",
|
|
},
|
|
{
|
|
type: "assistant_message",
|
|
text: "history stays behind manager",
|
|
},
|
|
]);
|
|
|
|
const fetched = await manager.fetchTimeline(snapshot.id, {
|
|
direction: "tail",
|
|
limit: 0,
|
|
});
|
|
expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]);
|
|
});
|
|
|
|
test("coalesces assistant chunks and persists the canonical row", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new StreamingAssistantClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const streamEvents: Array<{
|
|
seq?: number;
|
|
epoch?: string;
|
|
eventType?: string;
|
|
itemType?: string;
|
|
text?: string;
|
|
}> = [];
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_stream") {
|
|
return;
|
|
}
|
|
streamEvents.push({
|
|
seq: event.seq,
|
|
epoch: event.epoch,
|
|
eventType: event.event.type,
|
|
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
|
|
text:
|
|
event.event.type === "timeline" && event.event.item.type === "assistant_message"
|
|
? event.event.item.text
|
|
: undefined,
|
|
});
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
|
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
|
while (true) {
|
|
const next = await stream.next();
|
|
if (next.done) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const assistantTimelineEvents = streamEvents.filter(
|
|
(event) => event.itemType === "assistant_message",
|
|
);
|
|
expect(assistantTimelineEvents).toHaveLength(1);
|
|
expect(assistantTimelineEvents[0]).toMatchObject({
|
|
eventType: "timeline",
|
|
itemType: "assistant_message",
|
|
text: "final reply",
|
|
seq: 1,
|
|
epoch: expect.any(String),
|
|
});
|
|
|
|
expect(manager.getTimeline(snapshot.id)).toEqual([
|
|
{
|
|
type: "assistant_message",
|
|
text: "final reply",
|
|
},
|
|
]);
|
|
const fetched = await manager.fetchTimeline(snapshot.id, {
|
|
direction: "tail",
|
|
limit: 0,
|
|
});
|
|
expect(fetched.rows).toHaveLength(1);
|
|
expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch);
|
|
expect(fetched.rows[0]?.item).toEqual({
|
|
type: "assistant_message",
|
|
text: "final reply",
|
|
});
|
|
});
|
|
|
|
test("fetchTimeline supports older-history pagination with before seq", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-before-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000119",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "first",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "second",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "third",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "fourth",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "fifth",
|
|
});
|
|
|
|
const result = await manager.fetchTimeline(snapshot.id, {
|
|
direction: "before",
|
|
cursor: {
|
|
seq: 5,
|
|
},
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.rows).toHaveLength(2);
|
|
expect(result.rows[0]?.seq).toBe(3);
|
|
expect(result.rows[1]?.seq).toBe(4);
|
|
expect(result.hasOlder).toBe(true);
|
|
expect(result.hasNewer).toBe(true);
|
|
});
|
|
|
|
test("does not trim committed history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-unbounded-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "first",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "second",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
text: "third",
|
|
});
|
|
|
|
const fetched = await manager.fetchTimeline(snapshot.id, {
|
|
direction: "tail",
|
|
limit: 0,
|
|
});
|
|
expect(fetched.rows).toHaveLength(3);
|
|
expect(fetched.window.minSeq).toBe(1);
|
|
expect(fetched.window.maxSeq).toBe(3);
|
|
});
|
|
|
|
test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class ChunkedAssistantHistorySession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "chunk one " },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "chunk two" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "reasoning", text: "internal" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call-history-1",
|
|
name: "shell",
|
|
status: "completed",
|
|
detail: {
|
|
type: "shell",
|
|
command: "echo hi",
|
|
output: "hi\n",
|
|
exitCode: 0,
|
|
},
|
|
error: null,
|
|
},
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "final answer" },
|
|
};
|
|
}
|
|
}
|
|
|
|
class ChunkedAssistantHistoryClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ChunkedAssistantHistorySession(config);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ChunkedAssistantHistoryClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
|
|
expect(manager.getTimeline(snapshot.id)).toEqual([
|
|
{ type: "assistant_message", text: "chunk one " },
|
|
{ type: "assistant_message", text: "chunk two" },
|
|
{ type: "reasoning", text: "internal" },
|
|
{
|
|
type: "tool_call",
|
|
callId: "call-history-1",
|
|
name: "shell",
|
|
status: "completed",
|
|
detail: {
|
|
type: "shell",
|
|
command: "echo hi",
|
|
output: "hi\n",
|
|
exitCode: 0,
|
|
},
|
|
error: null,
|
|
},
|
|
{ type: "assistant_message", text: "final answer" },
|
|
]);
|
|
});
|
|
|
|
test("hydrateTimeline preserves reasoning between assistant chunks", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class ReasoningInterleavedHistorySession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "before reasoning " },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "reasoning", text: "internal step" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "after reasoning" },
|
|
};
|
|
}
|
|
}
|
|
|
|
class ReasoningInterleavedHistoryClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ReasoningInterleavedHistorySession(config);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ReasoningInterleavedHistoryClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000122",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
|
|
expect(manager.getTimeline(snapshot.id)).toEqual([
|
|
{
|
|
type: "assistant_message",
|
|
text: "before reasoning ",
|
|
},
|
|
{ type: "reasoning", text: "internal step" },
|
|
{ type: "assistant_message", text: "after reasoning" },
|
|
]);
|
|
});
|
|
|
|
test("createAgent fails when generated agent ID is not a UUID", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "not-a-uuid",
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
}),
|
|
).rejects.toThrow("createAgent: agentId must be a UUID");
|
|
});
|
|
|
|
test("createAgent fails when explicit agent ID is not a UUID", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(
|
|
manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
"not-a-uuid",
|
|
),
|
|
).rejects.toThrow("createAgent: agentId must be a UUID");
|
|
});
|
|
|
|
test("createAgent persists provided title before returning", async () => {
|
|
const agentId = "00000000-0000-4000-8000-000000000102";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Fix Login Bug",
|
|
});
|
|
|
|
expect(snapshot.id).toBe(agentId);
|
|
expect(snapshot.lifecycle).toBe("idle");
|
|
|
|
const persisted = await storage.get(agentId);
|
|
expect(persisted?.title).toBe("Fix Login Bug");
|
|
expect(persisted?.id).toBe(agentId);
|
|
});
|
|
|
|
test("createAgent populates runtimeInfo after session creation", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.2-codex",
|
|
modeId: "full-access",
|
|
});
|
|
|
|
expect(snapshot.runtimeInfo).toBeDefined();
|
|
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
|
expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.persistence?.sessionId);
|
|
});
|
|
|
|
test("runAgent refreshes runtimeInfo after completion", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4");
|
|
|
|
await manager.runAgent(snapshot.id, "hello");
|
|
|
|
const refreshed = manager.getAgent(snapshot.id);
|
|
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
|
});
|
|
|
|
test("waitForAgentEvent does not resolve idle until foreground turn is finalized", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const releaseTurnCompleted = deferred<void>();
|
|
|
|
class SlowTerminalSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.interrupted = false;
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
void (async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
await releaseTurnCompleted.promise;
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
})();
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class SlowTerminalClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new SlowTerminalSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new SlowTerminalClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000124",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
|
const consumePromise = (async () => {
|
|
for await (const _event of stream) {
|
|
// Drain events so manager lifecycle progresses naturally.
|
|
}
|
|
})();
|
|
|
|
// Wait for the turn to start
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
|
|
const waitPromise = manager.waitForAgentEvent(snapshot.id);
|
|
|
|
// Should still be pending because turn_completed hasn't arrived
|
|
const earlyResolution = await Promise.race([
|
|
waitPromise.then(() => "resolved"),
|
|
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
|
|
]);
|
|
expect(earlyResolution).toBe("pending");
|
|
|
|
// Release the turn_completed event
|
|
releaseTurnCompleted.resolve();
|
|
const waited = await waitPromise;
|
|
expect(waited.status).toBe("idle");
|
|
|
|
await consumePromise;
|
|
});
|
|
|
|
test("waitForAgentRunStart resolves while a foreground run is still only pending", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fast-start-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000124",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const run = manager.streamAgent(snapshot.id, "fast");
|
|
const drainRun = (async () => {
|
|
for await (const _event of run) {
|
|
// Drain the fast foreground turn.
|
|
}
|
|
})();
|
|
|
|
await expect(manager.waitForAgentRunStart(snapshot.id)).resolves.toBeUndefined();
|
|
|
|
await drainRun;
|
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
|
});
|
|
|
|
test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const allowFirstRunToEnd = deferred<void>();
|
|
const allowSecondRunToEnd = deferred<void>();
|
|
|
|
class ReplaceRunSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.interrupted = false;
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
const turnNum = this.turnIdCounter;
|
|
|
|
void (async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
if (turnNum === 1) {
|
|
await allowFirstRunToEnd.promise;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "interrupted",
|
|
turnId,
|
|
});
|
|
} else {
|
|
await allowSecondRunToEnd.promise;
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
})();
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interrupted = true;
|
|
allowFirstRunToEnd.resolve();
|
|
}
|
|
}
|
|
|
|
class ReplaceRunClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ReplaceRunSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ReplaceRunClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000125",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const lifecycleUpdates: string[] = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
|
return;
|
|
}
|
|
lifecycleUpdates.push(event.agent.lifecycle);
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
|
|
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
|
const firstRunDrain = (async () => {
|
|
for await (const _event of firstRun) {
|
|
// Drain events so lifecycle updates are applied.
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
|
|
const waitPromise = manager.waitForAgentEvent(snapshot.id);
|
|
const secondRun = manager.replaceAgentRun(snapshot.id, "second run");
|
|
const secondRunDrain = (async () => {
|
|
for await (const _event of secondRun) {
|
|
// Drain replacement run.
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
|
|
const prematureResolution = await Promise.race([
|
|
waitPromise.then(() => "resolved"),
|
|
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
|
|
]);
|
|
expect(prematureResolution).toBe("pending");
|
|
|
|
const runningIndexes = lifecycleUpdates.reduce<number[]>((indexes, status, index) => {
|
|
if (status === "running") {
|
|
indexes.push(index);
|
|
}
|
|
return indexes;
|
|
}, []);
|
|
expect(runningIndexes.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const firstReplacementRunningIndex = runningIndexes[1]!;
|
|
expect(lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")).toBe(false);
|
|
|
|
allowSecondRunToEnd.resolve();
|
|
|
|
const waited = await waitPromise;
|
|
expect(waited.status).toBe("idle");
|
|
|
|
await firstRunDrain;
|
|
await secondRunDrain;
|
|
unsubscribe();
|
|
});
|
|
|
|
test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const secondStartEntered = deferred<void>();
|
|
const interruptStarted = deferred<void>();
|
|
const allowInterruptToFinish = deferred<void>();
|
|
const allowSecondStartToResolve = deferred<void>();
|
|
let capturedSession: StaleReplacementSession | null = null;
|
|
|
|
class StaleReplacementSession extends TestAgentSession {
|
|
private localTurnCounter = 0;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `turn-${++this.localTurnCounter}`;
|
|
const turnNum = this.localTurnCounter;
|
|
|
|
if (turnNum === 1) {
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
secondStartEntered.resolve();
|
|
await allowSecondStartToResolve.promise;
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
interruptStarted.resolve();
|
|
await allowInterruptToFinish.promise;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "Interrupted",
|
|
turnId: "turn-1",
|
|
});
|
|
}
|
|
}
|
|
|
|
class StaleReplacementClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
capturedSession = new StaleReplacementSession(config);
|
|
return capturedSession;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new StaleReplacementClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const stateUpdates: Array<{ lifecycle: string; updatedAt: number }> = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
|
return;
|
|
}
|
|
stateUpdates.push({
|
|
lifecycle: event.agent.lifecycle,
|
|
updatedAt: event.agent.updatedAt.getTime(),
|
|
});
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
|
|
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
|
const firstRunDrain = (async () => {
|
|
for await (const _event of firstRun) {
|
|
// Drain events so lifecycle updates are applied.
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
|
|
const replaceUpdatesStart = stateUpdates.length;
|
|
const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0;
|
|
const secondRun = manager.replaceAgentRun(snapshot.id, "replacement run");
|
|
const secondRunDrain = (async () => {
|
|
for await (const _event of secondRun) {
|
|
// Drain replacement run.
|
|
}
|
|
})();
|
|
|
|
await interruptStarted.promise;
|
|
const replacementUpdates = stateUpdates.slice(replaceUpdatesStart);
|
|
expect(
|
|
replacementUpdates.some(
|
|
(update) => update.lifecycle === "running" && update.updatedAt > beforeReplaceUpdatedAt,
|
|
),
|
|
).toBe(true);
|
|
expect(replacementUpdates.map((update) => update.lifecycle)).not.toContain("idle");
|
|
allowInterruptToFinish.resolve();
|
|
|
|
await secondStartEntered.promise;
|
|
|
|
const replaceGapSnapshot = manager.getAgent(snapshot.id) as
|
|
| { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string }
|
|
| undefined;
|
|
expect(replaceGapSnapshot?.pendingReplacement).toBe(false);
|
|
expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull();
|
|
expect(replaceGapSnapshot?.lifecycle).toBe("running");
|
|
|
|
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("running");
|
|
expect(stateUpdates.at(-1)?.lifecycle).toBe("running");
|
|
expect(stateUpdates.slice(replaceUpdatesStart).map((update) => update.lifecycle)).not.toContain(
|
|
"idle",
|
|
);
|
|
|
|
allowSecondStartToResolve.resolve();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
await firstRunDrain;
|
|
await secondRunDrain;
|
|
unsubscribe();
|
|
});
|
|
|
|
test("applies live autonomous events while no foreground run is active", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
let capturedSession: TestAgentSession | null = null;
|
|
|
|
class LiveEventClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
capturedSession = new TestAgentSession(config);
|
|
return capturedSession;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new LiveEventClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000125",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const lifecycleUpdates: string[] = [];
|
|
let sawRunningState = false;
|
|
let resolveSettled!: () => void;
|
|
const settled = new Promise<void>((resolve) => {
|
|
resolveSettled = resolve;
|
|
});
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
|
lifecycleUpdates.push(event.agent.lifecycle);
|
|
if (event.agent.lifecycle === "running") {
|
|
sawRunningState = true;
|
|
}
|
|
if (sawRunningState && event.agent.lifecycle === "idle") {
|
|
resolveSettled();
|
|
}
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
|
|
// Push autonomous events through the session's subscribe() callbacks
|
|
const autonomousTurnId = "autonomous-turn-1";
|
|
capturedSession!.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
await settled;
|
|
|
|
const updated = manager.getAgent(snapshot.id);
|
|
expect(updated?.lifecycle).toBe("idle");
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "AUTONOMOUS_PUMP_MESSAGE",
|
|
});
|
|
expect(lifecycleUpdates).toContain("running");
|
|
expect(lifecycleUpdates).toContain("idle");
|
|
});
|
|
|
|
test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class LiveInterruptSession extends TestAgentSession {
|
|
public interruptCount = 0;
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCount += 1;
|
|
}
|
|
}
|
|
|
|
class LiveInterruptClient extends TestAgentClient {
|
|
lastSession: LiveInterruptSession | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new LiveInterruptSession(config);
|
|
this.lastSession = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const client = new LiveInterruptClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000129",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const capturedSession = client.lastSession!;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state") {
|
|
return;
|
|
}
|
|
if (event.agent.id !== snapshot.id) {
|
|
return;
|
|
}
|
|
if (event.agent.lifecycle !== "running") {
|
|
return;
|
|
}
|
|
unsubscribe();
|
|
resolve();
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
capturedSession.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: "autonomous-cancel-1",
|
|
});
|
|
});
|
|
|
|
const beforeCancel = manager.getAgent(snapshot.id);
|
|
expect(beforeCancel?.lifecycle).toBe("running");
|
|
expect(beforeCancel?.activeForegroundTurnId).toBeNull();
|
|
|
|
const cancelled = await manager.cancelAgentRun(snapshot.id);
|
|
expect(cancelled).toBe(true);
|
|
expect(client.lastSession?.interruptCount).toBe(1);
|
|
});
|
|
|
|
test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
let capturedSession: TestAgentSession | null = null;
|
|
|
|
class LiveEventClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new TestAgentSession(config);
|
|
capturedSession = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new LiveEventClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const autonomousTurnId = "autonomous-wait-1";
|
|
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
|
|
capturedSession!.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
|
|
const result = await waitPromise;
|
|
expect(result.status).toBe("idle");
|
|
});
|
|
|
|
test("autonomous events arriving during foreground run are processed via subscribe", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-during-fg-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const releaseForeground = deferred<void>();
|
|
|
|
let capturedSession: TestAgentSession | null = null;
|
|
|
|
class ForegroundSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "fg-turn-1";
|
|
setTimeout(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
await releaseForeground.promise;
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class ForegroundClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new ForegroundSession(config);
|
|
capturedSession = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ForegroundClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000127",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const foreground = manager.streamAgent(snapshot.id, "foreground run");
|
|
const foregroundResults = (async () => {
|
|
const events: AgentStreamEvent[] = [];
|
|
for await (const event of foreground) {
|
|
events.push(event);
|
|
}
|
|
return events;
|
|
})();
|
|
|
|
// Wait for the foreground turn to start (lifecycle -> running)
|
|
await new Promise<void>((resolve) => {
|
|
const unsub = manager.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_state" &&
|
|
event.agent.id === snapshot.id &&
|
|
event.agent.lifecycle === "running"
|
|
) {
|
|
unsub();
|
|
resolve();
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: true },
|
|
);
|
|
});
|
|
|
|
// Push autonomous events while foreground is active
|
|
const autonomousTurnId = "autonomous-during-fg-1";
|
|
capturedSession!.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" },
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
|
|
releaseForeground.resolve();
|
|
const foregroundEvents = await foregroundResults;
|
|
|
|
// Foreground stream should contain its own turn events but NOT autonomous events
|
|
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
|
expect(
|
|
foregroundEvents.some(
|
|
(event) =>
|
|
event.type === "timeline" &&
|
|
event.item.type === "assistant_message" &&
|
|
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND"),
|
|
),
|
|
).toBe(false);
|
|
|
|
// Autonomous timeline item should still be recorded in the agent timeline
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "AUTONOMOUS_DURING_FOREGROUND",
|
|
});
|
|
});
|
|
|
|
test("subscribe error isolation: throwing subscriber does not break event flow", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-subscribe-isolation-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
let capturedSession: TestAgentSession | null = null;
|
|
|
|
class IsolationClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new TestAgentSession(config);
|
|
capturedSession = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new IsolationClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const receivedEvents: string[] = [];
|
|
const settled = new Promise<void>((resolve) => {
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_state" &&
|
|
event.agent.id === snapshot.id &&
|
|
event.agent.lifecycle === "idle"
|
|
) {
|
|
resolve();
|
|
}
|
|
if (event.type === "agent_stream" && event.agentId === snapshot.id) {
|
|
receivedEvents.push(event.event.type);
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
});
|
|
|
|
const autonomousTurnId = "autonomous-isolation-1";
|
|
capturedSession!.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" },
|
|
turnId: autonomousTurnId,
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: autonomousTurnId,
|
|
});
|
|
|
|
await settled;
|
|
|
|
expect(receivedEvents).toContain("turn_started");
|
|
expect(receivedEvents).toContain("timeline");
|
|
expect(receivedEvents).toContain("turn_completed");
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "EVENT_AFTER_ERROR",
|
|
});
|
|
});
|
|
|
|
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
|
try {
|
|
manager.recordUserMessage(snapshot.id, "hello");
|
|
const afterMessage = manager.getAgent(snapshot.id);
|
|
expect(afterMessage).toBeDefined();
|
|
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
|
|
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
|
// Advance the generator so startTurn runs and lifecycle transitions to running
|
|
await stream.next();
|
|
const afterRunStart = manager.getAgent(snapshot.id);
|
|
expect(afterRunStart).toBeDefined();
|
|
expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt);
|
|
|
|
// Drain the rest of the stream
|
|
while (true) {
|
|
const next = await stream.next();
|
|
if (next.done) break;
|
|
}
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const lifecycleUpdates: string[] = [];
|
|
manager.subscribe((event) => {
|
|
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
|
return;
|
|
}
|
|
lifecycleUpdates.push(event.agent.lifecycle);
|
|
});
|
|
lifecycleUpdates.length = 0;
|
|
|
|
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
|
|
|
|
expect(lifecycleUpdates).toEqual([]);
|
|
});
|
|
|
|
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const expectedFinalText =
|
|
'```json\n{"message":"Reserve space for archive button in sidebar agent list"}\n```';
|
|
|
|
class ChunkedAssistantSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private subs = new Set<(event: AgentStreamEvent) => void>();
|
|
private turnCounter = 0;
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return {
|
|
sessionId: this.id,
|
|
finalText: "",
|
|
timeline: [],
|
|
};
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `chunked-turn-${++this.turnCounter}`;
|
|
setTimeout(() => {
|
|
for (const cb of this.subs) {
|
|
cb({ type: "turn_started", provider: this.provider, turnId });
|
|
cb({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "assistant_message",
|
|
text: '```json\n{"message":"Reserve space for archive button in side',
|
|
},
|
|
turnId,
|
|
});
|
|
cb({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "assistant_message",
|
|
text: 'bar agent list"}\n```',
|
|
},
|
|
turnId,
|
|
});
|
|
cb({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subs.add(callback);
|
|
return () => {
|
|
this.subs.delete(callback);
|
|
};
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: null,
|
|
modeId: null,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
};
|
|
}
|
|
|
|
async interrupt(): Promise<void> {}
|
|
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class ChunkedAssistantClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
return new ChunkedAssistantSession();
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
return new ChunkedAssistantSession();
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new ChunkedAssistantClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000113",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const result = await manager.runAgent(snapshot.id, "generate commit message");
|
|
expect(result.finalText).toBe(expectedFinalText);
|
|
});
|
|
|
|
test("listAgents excludes internal agents", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const generatedAgentIds = [
|
|
"00000000-0000-4000-8000-000000000105",
|
|
"00000000-0000-4000-8000-000000000106",
|
|
];
|
|
let agentCounter = 0;
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(),
|
|
});
|
|
|
|
// Create a normal agent
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Normal Agent",
|
|
});
|
|
|
|
// Create an internal agent
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
});
|
|
|
|
const agents = manager.listAgents();
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0]?.config.title).toBe("Normal Agent");
|
|
});
|
|
|
|
test("getAgent returns internal agents by ID", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => internalAgentId,
|
|
});
|
|
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
});
|
|
|
|
const agent = manager.getAgent(internalAgentId);
|
|
expect(agent).not.toBeNull();
|
|
expect(agent?.internal).toBe(true);
|
|
});
|
|
|
|
test("subscribe does not emit state events for internal agents to global subscribers", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const generatedAgentIds = [
|
|
"00000000-0000-4000-8000-000000000108",
|
|
"00000000-0000-4000-8000-000000000109",
|
|
];
|
|
let agentCounter = 0;
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(),
|
|
});
|
|
|
|
const receivedEvents: string[] = [];
|
|
manager.subscribe((event) => {
|
|
if (event.type === "agent_state") {
|
|
receivedEvents.push(event.agent.id);
|
|
}
|
|
});
|
|
|
|
// Create a normal agent - should emit
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Normal Agent",
|
|
});
|
|
|
|
// Create an internal agent - should NOT emit to global subscriber
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
});
|
|
|
|
// Should only have events from the normal agent
|
|
expect(receivedEvents.filter((id) => id === generatedAgentIds[0]).length).toBeGreaterThan(0);
|
|
expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0);
|
|
});
|
|
|
|
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000110";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => internalAgentId,
|
|
});
|
|
|
|
const receivedEvents: string[] = [];
|
|
// Subscribe specifically to the internal agent
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state") {
|
|
receivedEvents.push(event.agent.id);
|
|
}
|
|
},
|
|
{ agentId: internalAgentId, replayState: false },
|
|
);
|
|
|
|
await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
});
|
|
|
|
// Should receive events when subscribed by specific agentId
|
|
expect(receivedEvents.filter((id) => id === internalAgentId).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("subscribe fails when filter agentId is not a UUID", () => {
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
logger,
|
|
});
|
|
|
|
expect(() =>
|
|
manager.subscribe(() => {}, {
|
|
agentId: "invalid-agent-id",
|
|
}),
|
|
).toThrow("subscribe: agentId must be a UUID");
|
|
});
|
|
|
|
test("onAgentAttention is not called for internal agents", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000111";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const attentionCalls: string[] = [];
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => internalAgentId,
|
|
onAgentAttention: ({ agentId }) => {
|
|
attentionCalls.push(agentId);
|
|
},
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
});
|
|
|
|
// Run and complete the agent (which normally triggers attention)
|
|
await manager.runAgent(agent.id, "hello");
|
|
|
|
// Should NOT have triggered attention callback for internal agent
|
|
expect(attentionCalls).toHaveLength(0);
|
|
});
|
|
|
|
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class FailingSession extends TestAgentSession {
|
|
private attempt = 0;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.attempt += 1;
|
|
const attempt = this.attempt;
|
|
const turnId = `fail-turn-${attempt}`;
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "turn_failed",
|
|
provider: this.provider,
|
|
error: `boom-${attempt}`,
|
|
turnId,
|
|
});
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class FailingClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new FailingSession(config);
|
|
}
|
|
|
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
|
return new FailingSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new FailingClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
|
onAgentAttention: ({ reason }) => {
|
|
attentionReasons.push(reason);
|
|
},
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Attention transition test",
|
|
});
|
|
|
|
await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1");
|
|
await manager.flush();
|
|
|
|
const afterFirstFailure = manager.getAgent(agent.id);
|
|
expect(afterFirstFailure?.lifecycle).toBe("error");
|
|
expect(afterFirstFailure?.attention.requiresAttention).toBe(true);
|
|
expect(afterFirstFailure?.attention).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
|
|
const persistedAfterFirstFailure = await storage.get(agent.id);
|
|
expect(persistedAfterFirstFailure?.lastStatus).toBe("error");
|
|
expect(persistedAfterFirstFailure?.requiresAttention).toBe(true);
|
|
expect(persistedAfterFirstFailure?.attentionReason).toBe("error");
|
|
|
|
await manager.clearAgentAttention(agent.id);
|
|
manager.notifyAgentState(agent.id);
|
|
await manager.flush();
|
|
|
|
const afterClear = manager.getAgent(agent.id);
|
|
expect(afterClear?.lifecycle).toBe("error");
|
|
expect(afterClear?.attention).toEqual({ requiresAttention: false });
|
|
|
|
const persistedAfterClear = await storage.get(agent.id);
|
|
expect(persistedAfterClear?.lastStatus).toBe("error");
|
|
expect(persistedAfterClear?.requiresAttention).toBe(false);
|
|
expect(persistedAfterClear?.attentionReason).toBeNull();
|
|
|
|
await expect(manager.runAgent(agent.id, "fail again")).rejects.toThrow("boom-2");
|
|
await manager.flush();
|
|
|
|
const afterSecondFailure = manager.getAgent(agent.id);
|
|
expect(afterSecondFailure?.lifecycle).toBe("error");
|
|
expect(afterSecondFailure?.attention).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
expect(attentionReasons).toEqual(["error", "error"]);
|
|
|
|
const persistedAfterSecondFailure = await storage.get(agent.id);
|
|
expect(persistedAfterSecondFailure?.lastStatus).toBe("error");
|
|
expect(persistedAfterSecondFailure?.requiresAttention).toBe(true);
|
|
expect(persistedAfterSecondFailure?.attentionReason).toBe("error");
|
|
});
|
|
|
|
test("streamAgent clears pending run when startTurn fails before a turn id exists", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-turn-failure-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class FailsOnceBeforeTurnSession extends TestAgentSession {
|
|
private attempt = 0;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.attempt += 1;
|
|
if (this.attempt === 1) {
|
|
throw new Error("Invalid request: missing field `text`");
|
|
}
|
|
return super.startTurn();
|
|
}
|
|
}
|
|
|
|
class FailsOnceClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly session = new FailsOnceBeforeTurnSession({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new FailsOnceClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Start turn failure cleanup",
|
|
});
|
|
|
|
await expect(manager.runAgent(agent.id, "fail before turn id")).rejects.toThrow(
|
|
"Invalid request: missing field `text`",
|
|
);
|
|
|
|
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
|
|
expect.objectContaining({
|
|
sessionId: expect.any(String),
|
|
canceled: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Archive target",
|
|
});
|
|
|
|
const lifecycles: string[] = [];
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === agent.id) {
|
|
lifecycles.push(event.agent.lifecycle);
|
|
}
|
|
},
|
|
{ agentId: agent.id, replayState: false },
|
|
);
|
|
|
|
const { archivedAt } = await manager.archiveAgent(agent.id);
|
|
const stored = await storage.get(agent.id);
|
|
|
|
expect(stored).toMatchObject({
|
|
id: agent.id,
|
|
archivedAt,
|
|
lastStatus: "closed",
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
});
|
|
expect(
|
|
Math.abs(new Date(stored!.updatedAt).getTime() - new Date(archivedAt).getTime()),
|
|
).toBeLessThanOrEqual(5);
|
|
expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]);
|
|
});
|
|
|
|
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class TurnFailedSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-failed-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "turn_failed",
|
|
provider: this.provider,
|
|
error: "invalid model id",
|
|
turnId,
|
|
});
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class TurnFailedClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new TurnFailedSession(config);
|
|
}
|
|
|
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
|
return new TurnFailedSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TurnFailedClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Turn failed test",
|
|
});
|
|
|
|
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("invalid model id");
|
|
|
|
const snapshot = manager.getAgent(agent.id);
|
|
expect(snapshot?.lifecycle).toBe("error");
|
|
expect(snapshot?.lastError).toBe("invalid model id");
|
|
|
|
const systemErrors = manager
|
|
.getTimeline(agent.id)
|
|
.filter(
|
|
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
|
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
|
);
|
|
expect(systemErrors).toHaveLength(1);
|
|
expect(systemErrors[0]?.text).toContain("invalid model id");
|
|
});
|
|
|
|
test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-detail-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class DetailedFailureSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-detailed-fail-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "turn_failed",
|
|
provider: this.provider,
|
|
error: "Provider execution failed",
|
|
code: "126",
|
|
diagnostic: "No preset version installed for command claude",
|
|
turnId,
|
|
});
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class DetailedFailureClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new DetailedFailureSession(config);
|
|
}
|
|
|
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
|
return new DetailedFailureSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new DetailedFailureClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000132",
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Detailed failure test",
|
|
});
|
|
|
|
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
|
|
|
|
const systemError = manager
|
|
.getTimeline(agent.id)
|
|
.find(
|
|
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
|
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
|
);
|
|
expect(systemError?.text).toContain("Provider execution failed");
|
|
expect(systemError?.text).toContain("code: 126");
|
|
expect(systemError?.text).toContain("No preset version installed for command claude");
|
|
});
|
|
|
|
test("permission request notifies once without forcing unread attention state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const releasePermissionResolution = deferred<void>();
|
|
|
|
class PermissionSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-perm-1";
|
|
setTimeout(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "permission_requested",
|
|
provider: this.provider,
|
|
request: {
|
|
id: "perm-1",
|
|
provider: this.provider,
|
|
kind: "tool",
|
|
name: "Read file",
|
|
},
|
|
turnId,
|
|
});
|
|
await releasePermissionResolution.promise;
|
|
this.pushEvent({
|
|
type: "permission_resolved",
|
|
provider: this.provider,
|
|
requestId: "perm-1",
|
|
resolution: { behavior: "allow" },
|
|
turnId,
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class PermissionClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new PermissionSession(config);
|
|
}
|
|
|
|
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
|
return new PermissionSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new PermissionClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
|
onAgentAttention: ({ reason }) => {
|
|
attentionReasons.push(reason);
|
|
},
|
|
});
|
|
|
|
const agent = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Permission transition test",
|
|
});
|
|
|
|
const stream = manager.streamAgent(agent.id, "permission flow");
|
|
await stream.next(); // turn_started
|
|
await stream.next(); // permission_requested
|
|
|
|
const withPermissionPending = manager.getAgent(agent.id);
|
|
expect(withPermissionPending?.pendingPermissions.size).toBe(1);
|
|
expect(withPermissionPending?.attention).toEqual({ requiresAttention: false });
|
|
|
|
// Release permission resolution and drain the rest of the stream
|
|
releasePermissionResolution.resolve();
|
|
while (!(await stream.next()).done) {
|
|
// no-op
|
|
}
|
|
|
|
expect(attentionReasons).toContain("permission");
|
|
});
|
|
|
|
test("respondToPermission updates currentModeId after plan approval", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
// Create a session that simulates plan approval mode change
|
|
let sessionMode = "plan";
|
|
class PlanModeTestSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private subs = new Set<(event: AgentStreamEvent) => void>();
|
|
private turnCounter = 0;
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `plan-turn-${++this.turnCounter}`;
|
|
setTimeout(() => {
|
|
for (const cb of this.subs) {
|
|
cb({ type: "turn_started", provider: this.provider, turnId });
|
|
cb({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subs.add(callback);
|
|
return () => {
|
|
this.subs.delete(callback);
|
|
};
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return { provider: this.provider, sessionId: this.id, model: null, modeId: sessionMode };
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [
|
|
{ id: "plan", label: "Plan" },
|
|
{ id: "acceptEdits", label: "Accept Edits" },
|
|
];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return sessionMode;
|
|
}
|
|
|
|
async setMode(modeId: string): Promise<void> {
|
|
sessionMode = modeId;
|
|
}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(_requestId: string, response: { behavior: string }): Promise<void> {
|
|
// Simulate what claude-agent.ts does: when plan permission is approved,
|
|
// it calls setMode("acceptEdits") internally
|
|
if (response.behavior === "allow") {
|
|
sessionMode = "acceptEdits";
|
|
}
|
|
}
|
|
|
|
describePersistence() {
|
|
return { provider: this.provider, sessionId: this.id };
|
|
}
|
|
|
|
async interrupt(): Promise<void> {}
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class PlanModeTestClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
return new PlanModeTestSession();
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
return new PlanModeTestSession();
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new PlanModeTestClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000112",
|
|
});
|
|
|
|
// Create agent in plan mode
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
modeId: "plan",
|
|
});
|
|
|
|
expect(snapshot.currentModeId).toBe("plan");
|
|
|
|
// Simulate a pending plan permission request
|
|
const agent = manager.getAgent(snapshot.id)!;
|
|
const permissionRequest = {
|
|
id: "perm-123",
|
|
provider: "codex" as const,
|
|
name: "ExitPlanMode",
|
|
kind: "plan" as const,
|
|
input: { plan: "Test plan" },
|
|
};
|
|
agent.pendingPermissions.set(permissionRequest.id, permissionRequest);
|
|
|
|
// Approve the plan permission
|
|
await manager.respondToPermission(snapshot.id, "perm-123", {
|
|
behavior: "allow",
|
|
});
|
|
|
|
// The session's mode has changed to "acceptEdits" internally
|
|
// The manager should have updated currentModeId to reflect this
|
|
const updatedAgent = manager.getAgent(snapshot.id);
|
|
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
|
|
|
await manager.flush();
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.lastModeId).toBe("acceptEdits");
|
|
});
|
|
|
|
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class RefreshingPermissionSession extends TestAgentSession {
|
|
private featureState: AgentFeature[] = [
|
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
|
];
|
|
private modeId = "auto";
|
|
private pending = [
|
|
{
|
|
id: "perm-plan-1",
|
|
provider: "codex" as const,
|
|
name: "CodexPlanApproval",
|
|
kind: "plan" as const,
|
|
input: { plan: "- Implement the feature" },
|
|
},
|
|
];
|
|
|
|
get features(): AgentFeature[] {
|
|
return this.featureState;
|
|
}
|
|
|
|
override async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: "gpt-5.4",
|
|
modeId: this.modeId,
|
|
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
|
};
|
|
}
|
|
|
|
override async getCurrentMode() {
|
|
return this.modeId;
|
|
}
|
|
|
|
override getPendingPermissions() {
|
|
return this.pending;
|
|
}
|
|
|
|
override async respondToPermission(): Promise<void> {
|
|
this.modeId = "auto";
|
|
this.pending = [];
|
|
this.featureState = [
|
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
|
];
|
|
}
|
|
}
|
|
|
|
class RefreshingPermissionClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new RefreshingPermissionSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new RefreshingPermissionClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const agent = manager.getAgent(snapshot.id);
|
|
if (!agent) {
|
|
throw new Error("Expected managed agent");
|
|
}
|
|
agent.pendingPermissions.set("perm-plan-1", {
|
|
id: "perm-plan-1",
|
|
provider: "codex",
|
|
name: "CodexPlanApproval",
|
|
kind: "plan",
|
|
input: { plan: "- Implement the feature" },
|
|
});
|
|
|
|
await manager.respondToPermission(snapshot.id, "perm-plan-1", {
|
|
behavior: "allow",
|
|
selectedActionId: "implement",
|
|
});
|
|
|
|
const updated = manager.getAgent(snapshot.id);
|
|
expect(updated?.pendingPermissions.size).toBe(0);
|
|
expect(updated?.features).toEqual([
|
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
|
]);
|
|
expect(updated?.runtimeInfo).toMatchObject({
|
|
model: "gpt-5.4",
|
|
extra: { collaborationMode: "Code" },
|
|
});
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.features).toEqual([
|
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
|
]);
|
|
});
|
|
|
|
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class OrderedPermissionSession extends TestAgentSession {
|
|
private featureState: AgentFeature[] = [
|
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
];
|
|
private modeId = "plan";
|
|
private pending = [
|
|
{
|
|
id: "perm-order-1",
|
|
provider: "codex" as const,
|
|
name: "ExitPlanMode",
|
|
kind: "plan" as const,
|
|
input: { plan: "- Do the work" },
|
|
},
|
|
];
|
|
|
|
get features(): AgentFeature[] {
|
|
return this.featureState;
|
|
}
|
|
|
|
override async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: "gpt-5.4",
|
|
modeId: this.modeId,
|
|
};
|
|
}
|
|
|
|
override async getCurrentMode() {
|
|
return this.modeId;
|
|
}
|
|
|
|
override getPendingPermissions() {
|
|
return this.pending;
|
|
}
|
|
|
|
override async respondToPermission(): Promise<void> {
|
|
this.pushEvent({
|
|
type: "permission_resolved",
|
|
provider: this.provider,
|
|
requestId: "perm-order-1",
|
|
resolution: { behavior: "allow" },
|
|
});
|
|
this.modeId = "acceptEdits";
|
|
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
|
this.pending = [];
|
|
}
|
|
}
|
|
|
|
class OrderedPermissionClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new OrderedPermissionSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new OrderedPermissionClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000134",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const seen: string[] = [];
|
|
manager.subscribe((event) => {
|
|
if ("agentId" in event && event.agentId !== snapshot.id) {
|
|
return;
|
|
}
|
|
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
|
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
|
seen.push(
|
|
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
|
);
|
|
return;
|
|
}
|
|
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
|
|
seen.push(`resolved:${event.event.requestId}`);
|
|
}
|
|
});
|
|
|
|
await manager.respondToPermission(snapshot.id, "perm-order-1", {
|
|
behavior: "allow",
|
|
});
|
|
|
|
const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false");
|
|
const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1");
|
|
expect(refreshedStateIndex).toBeGreaterThanOrEqual(0);
|
|
expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex);
|
|
});
|
|
|
|
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CloseRaceSession implements AgentSession {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly id = randomUUID();
|
|
private threadId: string | null = this.id;
|
|
private closed = false;
|
|
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
|
private turnIdCounter = 0;
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
// Push turn_started, then block until closed
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
// The turn will be canceled when close() is called
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subscribers.add(callback);
|
|
return () => {
|
|
this.subscribers.delete(callback);
|
|
};
|
|
}
|
|
|
|
private pushEvent(event: AgentStreamEvent): void {
|
|
for (const cb of this.subscribers) {
|
|
try {
|
|
cb(event);
|
|
} catch {
|
|
/* isolation */
|
|
}
|
|
}
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.threadId,
|
|
model: null,
|
|
modeId: null,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence() {
|
|
if (!this.threadId) {
|
|
return null;
|
|
}
|
|
return { provider: this.provider, sessionId: this.threadId };
|
|
}
|
|
|
|
async interrupt(): Promise<void> {
|
|
this.closed = true;
|
|
// Push turn_canceled for any active turn
|
|
if (this.turnIdCounter > 0) {
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "interrupted",
|
|
turnId: `turn-${this.turnIdCounter}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.closed = true;
|
|
this.threadId = null;
|
|
// Push turn_canceled for any active turn
|
|
if (this.turnIdCounter > 0) {
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
reason: "closed",
|
|
turnId: `turn-${this.turnIdCounter}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
class CloseRaceClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
return new CloseRaceSession();
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
return new CloseRaceSession();
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new CloseRaceClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000113",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
|
await stream.next();
|
|
|
|
await manager.closeAgent(snapshot.id);
|
|
|
|
// Drain stream finalizer path after close().
|
|
while (true) {
|
|
const next = await stream.next();
|
|
if (next.done) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
await manager.flush();
|
|
await storage.flush();
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId);
|
|
});
|
|
|
|
test("closeAgent persists one final closed snapshot", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-no-persist-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const applySnapshotSpy = vi.spyOn(storage, "applySnapshot");
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000112",
|
|
});
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.flush();
|
|
const persistCountBeforeClose = applySnapshotSpy.mock.calls.length;
|
|
|
|
await manager.closeAgent(snapshot.id);
|
|
await manager.flush();
|
|
|
|
expect(applySnapshotSpy).toHaveBeenCalledTimes(persistCountBeforeClose + 1);
|
|
} finally {
|
|
applySnapshotSpy.mockRestore();
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
// Session whose streamHistory yields user_message + assistant_message items.
|
|
// This simulates Codex provider replaying its thread history on resume.
|
|
class HistoryWithUserMessagesSession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "hello from user", messageId: "msg_client_1" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "hi there" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "second question", messageId: "msg_client_2" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "second answer" },
|
|
};
|
|
}
|
|
}
|
|
|
|
class HistoryUserMessageClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
// Fresh session yields history with user messages (simulates Codex resume)
|
|
return new HistoryWithUserMessagesSession(config);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new HistoryUserMessageClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000200",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
// Simulate canonical user messages already recorded by recordUserMessage
|
|
// (the path that session.ts takes when user sends a message)
|
|
manager.recordUserMessage(snapshot.id, "hello from user", {
|
|
messageId: "msg_client_1",
|
|
});
|
|
manager.recordUserMessage(snapshot.id, "second question", {
|
|
messageId: "msg_client_2",
|
|
});
|
|
|
|
const beforeHydrate = manager.getTimeline(snapshot.id);
|
|
const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message");
|
|
expect(userMessagesBefore).toHaveLength(2);
|
|
|
|
// hydrateTimeline replays provider history which includes user_message
|
|
// items. These should NOT create duplicate rows since recordUserMessage
|
|
// already created canonical entries.
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
|
|
const afterHydrate = manager.getTimeline(snapshot.id);
|
|
const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message");
|
|
|
|
// Should still have exactly 2 user messages, not 4
|
|
expect(userMessagesAfter).toHaveLength(2);
|
|
|
|
// Non-user_message items from history should still be replayed
|
|
const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message");
|
|
expect(assistantMessages).toHaveLength(2);
|
|
});
|
|
|
|
test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class HistoryWithUserMessagesSession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "hello from user", messageId: "msg_history_1" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "hi there" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "second question", messageId: "msg_history_2" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "second answer" },
|
|
};
|
|
}
|
|
}
|
|
|
|
class HistoryUserMessageClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new HistoryWithUserMessagesSession(config);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new HistoryUserMessageClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000203",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
|
expect(userMessages).toHaveLength(2);
|
|
expect(assistantMessages).toHaveLength(2);
|
|
});
|
|
|
|
test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class HistoryWithMixedUserMessagesSession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "user_message",
|
|
text: "hello from user",
|
|
messageId: "msg_client_hello",
|
|
},
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "hi there" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "user_message",
|
|
text: "hello from user",
|
|
messageId: "msg_provider_distinct",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
class HistoryMixedClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new HistoryWithMixedUserMessagesSession(config);
|
|
}
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new HistoryMixedClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000204",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
// Canonical user message that should dedupe the matching history item.
|
|
manager.recordUserMessage(snapshot.id, "hello from user", {
|
|
messageId: "msg_client_hello",
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id);
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
expect(userMessages).toHaveLength(2);
|
|
expect(
|
|
userMessages.map(
|
|
(item) => (item as Extract<AgentTimelineItem, { type: "user_message" }>).messageId,
|
|
),
|
|
).toEqual(["msg_client_hello", "msg_provider_distinct"]);
|
|
expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]);
|
|
});
|
|
|
|
test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-blank-msgid-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000201",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
// Empty string should be treated as missing
|
|
manager.recordUserMessage(snapshot.id, "test empty", {
|
|
messageId: "",
|
|
});
|
|
|
|
// Whitespace-only should be treated as missing
|
|
manager.recordUserMessage(snapshot.id, "test whitespace", {
|
|
messageId: " ",
|
|
});
|
|
|
|
// Valid messageId should be preserved
|
|
manager.recordUserMessage(snapshot.id, "test valid", {
|
|
messageId: "msg_valid_123",
|
|
});
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter(
|
|
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
|
item.type === "user_message",
|
|
);
|
|
|
|
expect(userMessages).toHaveLength(3);
|
|
// Empty string → undefined (not empty string)
|
|
expect(userMessages[0]!.messageId).toBeUndefined();
|
|
// Whitespace → undefined
|
|
expect(userMessages[1]!.messageId).toBeUndefined();
|
|
// Valid → preserved
|
|
expect(userMessages[2]!.messageId).toBe("msg_valid_123");
|
|
});
|
|
|
|
test("recordUserMessage preserves provided messageId in timeline item and dispatched event", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-msgid-passthrough-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000202",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
const events: AgentStreamEvent[] = [];
|
|
manager.subscribe((event) => {
|
|
if (event.type === "agent_stream") {
|
|
events.push(event.event);
|
|
}
|
|
});
|
|
|
|
const clientMsgId = "msg_abc_123_def";
|
|
manager.recordUserMessage(snapshot.id, "hello", {
|
|
messageId: clientMsgId,
|
|
});
|
|
|
|
// Timeline item should have the messageId
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMsg = timeline.find(
|
|
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
|
item.type === "user_message",
|
|
);
|
|
expect(userMsg).toBeDefined();
|
|
expect(userMsg!.messageId).toBe(clientMsgId);
|
|
|
|
// Dispatched stream event should also carry the messageId
|
|
const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message");
|
|
expect(streamEvent).toBeDefined();
|
|
if (streamEvent?.type === "timeline") {
|
|
expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe(
|
|
clientMsgId,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("live provider user_message echo is suppressed when recordUserMessage was called first", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-echo-dedup-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
// Session whose live turn echoes the user message (as Claude does)
|
|
class EchoUserMessageSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-echo-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
// Provider echoes user message during live run
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "user_message",
|
|
text: "hello from user",
|
|
messageId: "msg_client_echo_1",
|
|
},
|
|
turnId,
|
|
});
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "hello from assistant" },
|
|
turnId,
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class EchoClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new EchoUserMessageSession(config);
|
|
}
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("unused");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new EchoClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000400",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
// Canonical recording (what session.ts does before starting stream)
|
|
manager.recordUserMessage(snapshot.id, "hello from user", {
|
|
messageId: "msg_client_echo_1",
|
|
});
|
|
|
|
// Run triggers startTurn(), which echoes user_message
|
|
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
|
|
// Should be exactly 1 (canonical), not 2 (canonical + provider echo)
|
|
expect(userMessages).toHaveLength(1);
|
|
// The canonical one must carry the client messageId for optimistic matching
|
|
expect((userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).messageId).toBe(
|
|
"msg_client_echo_1",
|
|
);
|
|
|
|
// Assistant messages from the run should still appear
|
|
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
|
expect(assistantMessages).toHaveLength(1);
|
|
});
|
|
|
|
test("live provider user_message with different messageId is NOT suppressed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-different-msgid-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class DifferentMessageIdSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-diff-msgid-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "user_message",
|
|
text: "hello from user",
|
|
messageId: "msg_provider_other",
|
|
},
|
|
turnId,
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class DifferentMessageIdClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new DifferentMessageIdSession(config);
|
|
}
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("unused");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new DifferentMessageIdClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000402",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
manager.recordUserMessage(snapshot.id, "hello from user", {
|
|
messageId: "msg_client_echo_2",
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter(
|
|
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
|
item.type === "user_message",
|
|
);
|
|
expect(userMessages).toHaveLength(2);
|
|
expect(userMessages.map((item) => item.messageId)).toEqual([
|
|
"msg_client_echo_2",
|
|
"msg_provider_other",
|
|
]);
|
|
});
|
|
|
|
test("live provider user_message without messageId is NOT suppressed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-no-msgid-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class NoMessageIdSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-no-msgid-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "hello from user" },
|
|
turnId,
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class NoMessageIdClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new NoMessageIdSession(config);
|
|
}
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("unused");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new NoMessageIdClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000403",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
manager.recordUserMessage(snapshot.id, "hello from user", {
|
|
messageId: "msg_client_echo_3",
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
expect(userMessages).toHaveLength(2);
|
|
});
|
|
|
|
test("provider user_message is NOT suppressed when no prior recordUserMessage", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
// Session whose live turn yields a user_message without prior canonical recording
|
|
class UnexpectedUserMessageSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-unexpected-1";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
// Provider yields user_message (e.g., system continuation)
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "user_message", text: "continuation prompt" },
|
|
turnId,
|
|
});
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "continuation reply" },
|
|
turnId,
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class UnexpectedUserMsgClient implements AgentClient {
|
|
readonly provider = "codex" as const;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new UnexpectedUserMessageSession(config);
|
|
}
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("unused");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new UnexpectedUserMsgClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000401",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
// No recordUserMessage — run directly
|
|
await manager.runAgent(snapshot.id, { text: "do something" });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
|
|
// Provider's user_message should be recorded (no canonical to dedup against)
|
|
expect(userMessages).toHaveLength(1);
|
|
expect((userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).text).toBe(
|
|
"continuation prompt",
|
|
);
|
|
});
|
|
|
|
test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const allowSecondRunToEnd = deferred<void>();
|
|
|
|
// Session where the first foreground turn never emits a terminal event
|
|
// (simulates the claude-agent pendingInterruptAbort suppression bug),
|
|
// and interrupt() does not produce events either.
|
|
class StaleForegroundSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.interrupted = false;
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
const turnNum = this.turnIdCounter;
|
|
|
|
setTimeout(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
if (turnNum === 1) {
|
|
// First turn: emit turn_started but NEVER emit a terminal event.
|
|
// This simulates the provider suppressing the result.
|
|
} else {
|
|
// Subsequent turns: complete normally
|
|
await allowSecondRunToEnd.promise;
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interrupted = true;
|
|
// No events produced — the terminal event was suppressed
|
|
}
|
|
}
|
|
|
|
class StaleForegroundClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new StaleForegroundSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new StaleForegroundClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000500",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
|
|
|
// Start first foreground run — it will hang (no terminal event)
|
|
const firstRun = manager.streamAgent(snapshot.id, "hanging prompt");
|
|
const firstRunDrain = (async () => {
|
|
for await (const _event of firstRun) {
|
|
// Draining — will hang until force-cleaned
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
|
|
const beforeReplace = manager.getAgent(snapshot.id);
|
|
expect(beforeReplace?.lifecycle).toBe("running");
|
|
expect(beforeReplace?.activeForegroundTurnId).toBe("turn-1");
|
|
|
|
// Replace the hung run. cancelAgentRun will time out after 2s because
|
|
// no terminal event arrives. After the fix, it should force-clear the
|
|
// stale foreground state so streamAgent can proceed.
|
|
const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt");
|
|
const collectedEvents: AgentStreamEvent[] = [];
|
|
const secondRunDrain = (async () => {
|
|
for await (const event of secondRun) {
|
|
collectedEvents.push(event);
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
allowSecondRunToEnd.resolve();
|
|
|
|
await secondRunDrain;
|
|
await firstRunDrain;
|
|
|
|
expect(collectedEvents.some((e) => e.type === "turn_completed")).toBe(true);
|
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
|
expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull();
|
|
}, 10_000);
|