mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
13545 lines
424 KiB
TypeScript
13545 lines
424 KiB
TypeScript
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
|
import {
|
|
prepareAgentMessage,
|
|
formatAgentMessage,
|
|
formatSystemNotificationPrompt,
|
|
} from "./agent-messages/index.js";
|
|
import { expect, test, vi, onTestFinished } from "vitest";
|
|
import { spawn } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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,
|
|
AgentManagerShuttingDownError,
|
|
commandMayHaveChangedExternalState,
|
|
type AgentManagerEvent,
|
|
type ManagedAgent,
|
|
} from "./agent-manager.js";
|
|
import { AgentStorage } from "./agent-storage.js";
|
|
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
|
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
|
import { toAgentPayload } from "./agent-projections.js";
|
|
import { projectTimelineRows } from "./timeline-projection.js";
|
|
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
|
import { startAgentRun } from "./agent-prompt.js";
|
|
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
|
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
|
import type { StoredAgentRecord } from "./agent-storage.js";
|
|
import type {
|
|
AgentTimelineFetchOptions,
|
|
AgentTimelineFetchResult,
|
|
AgentTimelineRow,
|
|
AgentTimelineStore,
|
|
} from "./agent-timeline-store-types.js";
|
|
import type {
|
|
AgentClient,
|
|
AgentCreateSessionOptions,
|
|
AgentFeature,
|
|
AgentLaunchContext,
|
|
AgentPromptInput,
|
|
AgentProvider,
|
|
AgentPersistenceHandle,
|
|
AgentRunOptions,
|
|
AgentResumeSessionOptions,
|
|
AgentRunResult,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentSlashCommand,
|
|
AgentStreamEvent,
|
|
AgentTimelineItem,
|
|
ImportProviderSessionInput,
|
|
ImportProviderSessionContext,
|
|
ResolveAgentDefaultModeInput,
|
|
} from "./agent-sdk-types.js";
|
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
|
import type { ProviderDefinition } from "./provider-registry.js";
|
|
|
|
import { OpenCodeV2AgentClient } from "./providers/opencode/v2/agent.js";
|
|
import { V2Harness } from "./providers/opencode/test-utils/v2-harness.js";
|
|
|
|
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
|
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
|
|
|
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 };
|
|
}
|
|
|
|
async function drainAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<void> {
|
|
for await (const _ of generator) {
|
|
// Drain provider events while AgentManager subscribers observe them.
|
|
}
|
|
}
|
|
|
|
function waitForAgentLifecycle(
|
|
manager: AgentManager,
|
|
agentId: string,
|
|
lifecycle: ManagedAgent["lifecycle"],
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_state" &&
|
|
event.agent.id === agentId &&
|
|
event.agent.lifecycle === lifecycle
|
|
) {
|
|
unsubscribe();
|
|
resolve();
|
|
}
|
|
},
|
|
{ agentId, replayState: false },
|
|
);
|
|
});
|
|
}
|
|
|
|
const TEST_CAPABILITIES = {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: false,
|
|
supportsSessionListing: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
} as const;
|
|
|
|
class RecordingTimelineStore implements AgentTimelineStore {
|
|
readonly writes: AgentTimelineRow[][] = [];
|
|
private readonly memory = new InMemoryAgentTimelineStore();
|
|
|
|
private ensure(agentId: string): void {
|
|
if (!this.memory.has(agentId)) this.memory.initialize(agentId);
|
|
}
|
|
|
|
async appendCommitted(
|
|
agentId: string,
|
|
item: AgentTimelineItem,
|
|
options?: { timestamp?: string; turnId?: string },
|
|
): Promise<AgentTimelineRow> {
|
|
this.ensure(agentId);
|
|
return this.memory.append(agentId, item, options);
|
|
}
|
|
|
|
async fetchCommitted(
|
|
agentId: string,
|
|
options?: AgentTimelineFetchOptions,
|
|
): Promise<AgentTimelineFetchResult> {
|
|
this.ensure(agentId);
|
|
return this.memory.fetch(agentId, options);
|
|
}
|
|
|
|
async getLatestCommittedSeq(agentId: string): Promise<number> {
|
|
return this.memory.has(agentId) ? (this.memory.getRows(agentId).at(-1)?.seq ?? 0) : 0;
|
|
}
|
|
|
|
async getCommittedRows(agentId: string): Promise<AgentTimelineRow[]> {
|
|
return this.memory.has(agentId) ? this.memory.getRows(agentId) : [];
|
|
}
|
|
|
|
async getLastItem(agentId: string): Promise<AgentTimelineItem | null> {
|
|
return this.memory.has(agentId) ? this.memory.getLastItem(agentId) : null;
|
|
}
|
|
|
|
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
|
return this.memory.has(agentId) ? this.memory.getLastAssistantMessage(agentId) : null;
|
|
}
|
|
|
|
async deleteAgent(agentId: string): Promise<void> {
|
|
this.memory.delete(agentId);
|
|
}
|
|
|
|
async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
|
|
this.writes.push(rows.map((row) => ({ ...row })));
|
|
this.ensure(agentId);
|
|
for (const row of rows) {
|
|
this.memory.append(agentId, row.item, {
|
|
timestamp: row.timestamp,
|
|
turnId: row.turnId,
|
|
});
|
|
}
|
|
}
|
|
|
|
async updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise<void> {
|
|
this.ensure(agentId);
|
|
const rows = this.memory.getRows(agentId);
|
|
const index = rows.findIndex((candidate) => candidate.seq === row.seq);
|
|
if (index >= 0) {
|
|
rows[index] = row;
|
|
this.memory.initialize(agentId, { rows });
|
|
}
|
|
}
|
|
}
|
|
|
|
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
|
return {
|
|
type: "toggle",
|
|
id: args.id,
|
|
label: args.label,
|
|
value: args.value,
|
|
};
|
|
}
|
|
|
|
function expectArchivedAgentRecord(
|
|
record: StoredAgentRecord | null,
|
|
expectedLastStatus: "closed" | "idle",
|
|
): void {
|
|
expect(record).not.toBeNull();
|
|
expect(record?.archivedAt).toEqual(
|
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
|
|
);
|
|
expect(record?.lastStatus).toBe(expectedLastStatus);
|
|
expect(record?.requiresAttention).toBe(false);
|
|
expect(record?.attentionReason).toBeNull();
|
|
expect(record?.attentionTimestamp).toBeNull();
|
|
}
|
|
|
|
class TestAgentClient implements AgentClient {
|
|
readonly provider: AgentProvider;
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
readonly createdConfigs: AgentSessionConfig[] = [];
|
|
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
|
|
|
|
constructor(provider: AgentProvider = "codex") {
|
|
this.provider = provider;
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createdConfigs.push(config);
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
async fetchCatalog() {
|
|
return {
|
|
models: [
|
|
{
|
|
provider: this.provider,
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
isDefault: true,
|
|
},
|
|
{
|
|
provider: this.provider,
|
|
id: "gpt-5.4-mini",
|
|
label: "GPT-5.4 Mini",
|
|
},
|
|
{
|
|
provider: this.provider,
|
|
id: "gpt-5.2-codex",
|
|
label: "GPT-5.2 Codex",
|
|
},
|
|
],
|
|
modes: [],
|
|
};
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
_launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.resumeOverrides.push(config);
|
|
return new TestAgentSession({
|
|
provider: this.provider,
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
|
|
});
|
|
}
|
|
}
|
|
|
|
class SessionRecordingAgentClient extends TestAgentClient {
|
|
readonly sessions: TestAgentSession[] = [];
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new TestAgentSession(config);
|
|
this.sessions.push(session);
|
|
return session;
|
|
}
|
|
}
|
|
|
|
class HeldAgentCreationClient extends TestAgentClient {
|
|
private readonly creationStarted = deferred<void>();
|
|
private readonly creationAllowed = deferred<void>();
|
|
createdSessionClosed = false;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const recordSessionClosed = () => {
|
|
this.createdSessionClosed = true;
|
|
};
|
|
const session = new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
recordSessionClosed();
|
|
}
|
|
})(config);
|
|
this.creationStarted.resolve();
|
|
await this.creationAllowed.promise;
|
|
return session;
|
|
}
|
|
|
|
waitForCreationToStart(): Promise<void> {
|
|
return this.creationStarted.promise;
|
|
}
|
|
|
|
finishCreating(): void {
|
|
this.creationAllowed.resolve();
|
|
}
|
|
}
|
|
|
|
class HeldAgentCreationAndCloseClient extends TestAgentClient {
|
|
private readonly creationStarted = deferred<void>();
|
|
private readonly creationAllowed = deferred<void>();
|
|
private readonly closeStarted = deferred<void>();
|
|
private readonly closeAllowed = deferred<void>();
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.creationStarted.resolve();
|
|
await this.creationAllowed.promise;
|
|
const signalCloseStarted = () => this.closeStarted.resolve();
|
|
const waitForClose = () => this.closeAllowed.promise;
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
signalCloseStarted();
|
|
await waitForClose();
|
|
}
|
|
})(config);
|
|
}
|
|
|
|
waitForCreationToStart(): Promise<void> {
|
|
return this.creationStarted.promise;
|
|
}
|
|
|
|
finishCreating(): void {
|
|
this.creationAllowed.resolve();
|
|
}
|
|
|
|
waitForCloseToStart(): Promise<void> {
|
|
return this.closeStarted.promise;
|
|
}
|
|
|
|
finishClosing(): void {
|
|
this.closeAllowed.resolve();
|
|
}
|
|
}
|
|
|
|
class HeldReloadCloseClient extends TestAgentClient {
|
|
private readonly closeStarted = deferred<void>();
|
|
private readonly closeAllowed = deferred<void>();
|
|
originalSessionClosed = false;
|
|
replacementSessionClosed = false;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const signalCloseStarted = () => this.closeStarted.resolve();
|
|
const waitForClose = () => this.closeAllowed.promise;
|
|
const recordOriginalClosed = () => {
|
|
this.originalSessionClosed = true;
|
|
};
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
signalCloseStarted();
|
|
await waitForClose();
|
|
recordOriginalClosed();
|
|
}
|
|
})(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
const recordReplacementClosed = () => {
|
|
this.replacementSessionClosed = true;
|
|
};
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
recordReplacementClosed();
|
|
}
|
|
})({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
|
|
waitForCloseToStart(): Promise<void> {
|
|
return this.closeStarted.promise;
|
|
}
|
|
|
|
finishClosing(): void {
|
|
this.closeAllowed.resolve();
|
|
}
|
|
}
|
|
|
|
class NativeArchiveRecordingClient extends TestAgentClient {
|
|
readonly archivedHandles: AgentPersistenceHandle[] = [];
|
|
readonly unarchivedHandles: AgentPersistenceHandle[] = [];
|
|
readArchivedAtDuringUnarchive: (() => Promise<string | null | undefined>) | null = null;
|
|
archivedAtDuringUnarchive: string | null | undefined;
|
|
unarchiveFailure: Error | null = null;
|
|
|
|
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
|
this.archivedHandles.push(handle);
|
|
}
|
|
|
|
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
|
this.unarchivedHandles.push(handle);
|
|
if (this.readArchivedAtDuringUnarchive) {
|
|
this.archivedAtDuringUnarchive = await this.readArchivedAtDuringUnarchive();
|
|
}
|
|
if (this.unarchiveFailure) {
|
|
throw this.unarchiveFailure;
|
|
}
|
|
}
|
|
}
|
|
|
|
class EnvProbeAgentClient extends TestAgentClient {
|
|
probe: Promise<{ probe: string | null; agentId: string | null }> | null = null;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
const script = `
|
|
process.stdout.write(JSON.stringify({
|
|
probe: process.env.CHUNK14_PROBE ?? null,
|
|
agentId: process.env.PASEO_AGENT_ID ?? null
|
|
}));
|
|
`;
|
|
const child = spawn(process.execPath, ["-e", script], {
|
|
cwd: config.cwd,
|
|
env: { ...process.env, ...launchContext?.env },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
this.probe = new Promise((resolve, reject) => {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`env probe exited ${code}: ${stderr}`));
|
|
return;
|
|
}
|
|
resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null });
|
|
});
|
|
});
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
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 ResumeTrackingTestAgentClient extends TestAgentClient {
|
|
private readonly retryStarted = deferred<void>();
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumeOverrides.push(config);
|
|
const signalRetryStarted = () => this.retryStarted.resolve();
|
|
return new (class extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
signalRetryStarted();
|
|
return await super.startTurn();
|
|
}
|
|
})({
|
|
provider: this.provider,
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
|
|
});
|
|
}
|
|
|
|
waitForRetryStart(): Promise<void> {
|
|
return this.retryStarted.promise;
|
|
}
|
|
}
|
|
|
|
class McpCapableTestAgentSession extends TestAgentSession {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
};
|
|
}
|
|
|
|
class CloseRecordingTestAgentSession extends TestAgentSession {
|
|
closed = false;
|
|
|
|
override async close(): Promise<void> {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
class SteeringTestSession extends TestAgentSession {
|
|
interruptCount = 0;
|
|
startCount = 0;
|
|
steerCount = 0;
|
|
steerResult: "accepted" | "unavailable" | Error = "accepted";
|
|
startPrompts: AgentPromptInput[] = [];
|
|
|
|
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
|
this.startPrompts.push(prompt);
|
|
const turnId = `active-turn-${++this.startCount}`;
|
|
setTimeout(() => this.pushEvent({ type: "turn_started", provider: this.provider, turnId }), 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCount += 1;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
turnId: `active-turn-${this.startCount}`,
|
|
});
|
|
}
|
|
|
|
async steerActiveTurn(
|
|
prompt: AgentPromptInput,
|
|
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
|
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
|
this.steerCount += 1;
|
|
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
|
|
return { status: "unavailable" };
|
|
}
|
|
if (this.steerResult instanceof Error) throw this.steerResult;
|
|
if (this.steerResult === "unavailable") return { status: "unavailable" };
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: {
|
|
type: "user_message",
|
|
text: typeof prompt === "string" ? prompt : "structured",
|
|
clientMessageId: options.clientMessageId,
|
|
messageId: `provider-${options.clientMessageId}`,
|
|
},
|
|
});
|
|
return { status: "accepted" };
|
|
}
|
|
}
|
|
|
|
class UnsupportedSteeringSession extends TestAgentSession {
|
|
interruptCount = 0;
|
|
startCount = 0;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `unsupported-turn-${++this.startCount}`;
|
|
setTimeout(() => this.pushEvent({ type: "turn_started", provider: this.provider, turnId }), 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCount += 1;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
turnId: `unsupported-turn-${this.startCount}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function startAndSteerThroughManager(
|
|
session: AgentSession,
|
|
behavior: "steer" | "interrupt" = "steer",
|
|
): Promise<{ manager: AgentManager; agentId: string; workdir: string }> {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-dispatch-"));
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const run = manager.streamAgent(agent.id, "initial");
|
|
void (async () => {
|
|
for await (const _event of run) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
await startAgentRun(manager, agent.id, "replacement", logger, {
|
|
replaceRunning: true,
|
|
activeTurnBehavior: behavior,
|
|
runOptions: { clientMessageId: "replacement-client" },
|
|
});
|
|
return { manager, agentId: agent.id, workdir };
|
|
}
|
|
|
|
test("stops an OpenCode execution already active when its conversation is reopened", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-resume-"));
|
|
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
|
|
const harness = new V2Harness();
|
|
harness.info.location.directory = workdir;
|
|
harness.startExecution();
|
|
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
|
|
const manager = new AgentManager({ clients: { opencode: client }, logger });
|
|
const agent = await manager.resumeAgentFromPersistence({
|
|
provider: "opencode",
|
|
sessionId: "session",
|
|
nativeHandle: "session",
|
|
metadata: { cwd: workdir },
|
|
});
|
|
try {
|
|
expect(await manager.cancelAgentRun(agent.id)).toEqual({ status: "settled" });
|
|
expect(harness.active).toBe(false);
|
|
for await (const _event of manager.streamAgent(agent.id, "continue after restart")) {
|
|
// Drain the next turn through the same interface used by callers.
|
|
}
|
|
expect(harness.prompts).toEqual(["continue after restart"]);
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
await client.shutdown();
|
|
}
|
|
});
|
|
|
|
test("a completed OpenCode model switch does not depend on another session read", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-model-"));
|
|
const harness = new V2Harness();
|
|
harness.info.location.directory = workdir;
|
|
harness.info.model = { providerID: "provider", id: "source" };
|
|
let observationUnavailable = false;
|
|
harness.api.model.list = async () => ({
|
|
location: { directory: workdir },
|
|
data: [
|
|
{
|
|
providerID: "provider",
|
|
id: "target",
|
|
modelID: "target",
|
|
name: "Target",
|
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
enabled: true,
|
|
variants: [],
|
|
time: { released: 1 },
|
|
cost: [],
|
|
status: "active",
|
|
limit: { context: 200000, output: 10000 },
|
|
},
|
|
],
|
|
});
|
|
harness.api.session.switchModel = async ({ model }) => {
|
|
harness.info.model = model;
|
|
observationUnavailable = true;
|
|
};
|
|
harness.api.session.get = async () => {
|
|
if (observationUnavailable) throw new Error("Transport");
|
|
return harness.info;
|
|
};
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
|
|
const manager = new AgentManager({ clients: { opencode: client }, registry: storage, logger });
|
|
const agent = await manager.resumeAgentFromPersistence({
|
|
provider: "opencode",
|
|
sessionId: "session",
|
|
nativeHandle: "session",
|
|
metadata: { cwd: workdir },
|
|
});
|
|
try {
|
|
await manager.setAgentModel(agent.id, "provider/target");
|
|
await manager.flush();
|
|
expect(harness.info.model?.id).toBe("target");
|
|
expect(manager.getAgent(agent.id)?.config.model).toBe("provider/target");
|
|
expect(manager.getAgent(agent.id)?.runtimeInfo?.model).toBe("provider/target");
|
|
const stored = await storage.get(agent.id);
|
|
expect(stored?.config?.model).toBe("provider/target");
|
|
expect(stored?.runtimeInfo?.model).toBe("provider/target");
|
|
expect(stored?.persistence?.metadata?.model).toBe("provider/target");
|
|
observationUnavailable = false;
|
|
for await (const _event of manager.streamAgent(agent.id, "continue after switching")) {
|
|
// A successful mutation leaves a usable session.
|
|
}
|
|
expect(harness.prompts).toEqual(["continue after switching"]);
|
|
} finally {
|
|
observationUnavailable = false;
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
await client.shutdown();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("uses an injected timeline store without making it a production requirement", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-"));
|
|
const store = new RecordingTimelineStore();
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
durableTimelineStore: store,
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "stored" });
|
|
await manager.flush();
|
|
|
|
expect(store.writes.flat()).toContainEqual(
|
|
expect.objectContaining({ item: { type: "assistant_message", text: "stored" } }),
|
|
);
|
|
await expect(manager.getTimelineRows(agent.id)).resolves.toContainEqual(
|
|
expect.objectContaining({ item: { type: "assistant_message", text: "stored" } }),
|
|
);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
|
|
const store = new RecordingTimelineStore();
|
|
class HistorySession extends TestAgentSession {
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "user_message", text: "continue" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "done" },
|
|
};
|
|
}
|
|
}
|
|
class HistoryClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new HistorySession(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new HistoryClient() },
|
|
durableTimelineStore: store,
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
|
|
// What session.handleRefreshAgentRequest does for a loaded agent, twice over.
|
|
for (let refresh = 0; refresh < 2; refresh += 1) {
|
|
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
|
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
|
await manager.flush();
|
|
}
|
|
|
|
const rows = await manager.getTimelineRows(agent.id);
|
|
expect(rows.map((row) => row.item)).toEqual([
|
|
{ type: "user_message", text: "continue" },
|
|
{ type: "assistant_message", text: "done" },
|
|
]);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a failed history replay leaves the committed timeline intact", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-replay-fail-"));
|
|
const store = new RecordingTimelineStore();
|
|
let failReplay = false;
|
|
class FlakyHistorySession extends TestAgentSession {
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "user_message", text: "continue" },
|
|
};
|
|
if (failReplay) {
|
|
throw new Error("history stream closed");
|
|
}
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "done" },
|
|
};
|
|
}
|
|
}
|
|
class FlakyHistoryClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new FlakyHistorySession(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new FlakyHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new FlakyHistoryClient() },
|
|
durableTimelineStore: store,
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
|
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
|
await manager.flush();
|
|
const committed = await manager.getTimelineRows(agent.id);
|
|
|
|
failReplay = true;
|
|
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
|
await expect(
|
|
manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }),
|
|
).rejects.toThrow("history stream closed");
|
|
await manager.flush();
|
|
|
|
expect(await manager.getTimelineRows(agent.id)).toEqual(committed);
|
|
|
|
// A retry once the provider recovers replaces that history rather than stacking on it.
|
|
failReplay = false;
|
|
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
|
await manager.flush();
|
|
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
|
|
{ type: "user_message", text: "continue" },
|
|
{ type: "assistant_message", text: "done" },
|
|
]);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retries provider history hydration after a stream failure", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-retry-"));
|
|
let attempts = 0;
|
|
class RetryingHistorySession extends TestAgentSession {
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error("history interrupted");
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "provider history" },
|
|
};
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new RetryingHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.resumeAgentFromPersistence(
|
|
{ provider: "codex", sessionId: "retry-history" },
|
|
{ cwd: workdir },
|
|
);
|
|
agentId = agent.id;
|
|
await expect(manager.hydrateTimelineFromProvider(agent.id)).rejects.toThrow(
|
|
"history interrupted",
|
|
);
|
|
expect(manager.getAgent(agent.id)?.historyPrimed).toBe(false);
|
|
|
|
await manager.hydrateTimelineFromProvider(agent.id);
|
|
expect(attempts).toBe(2);
|
|
expect(manager.getTimeline(agent.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "provider history",
|
|
});
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("notification batches share the run they start or join", async () => {
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const batch = (prefix: string) =>
|
|
["one", "two"]
|
|
.map((id) =>
|
|
formatAgentMessage({
|
|
id: `${prefix}-${id}`,
|
|
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
|
text: "Worker finished",
|
|
}),
|
|
)
|
|
.join("\n\n");
|
|
const consume = async (prompt: string, clientMessageId: string) => {
|
|
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
|
}
|
|
};
|
|
try {
|
|
const first = consume("human", "human");
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
await expect(
|
|
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
|
|
).resolves.toEqual({ status: "accepted" });
|
|
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
|
await first;
|
|
const second = consume(batch("idle"), "idle-one");
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
|
|
await second;
|
|
expect(
|
|
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
|
|
turnId: row.turnId,
|
|
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
|
|
})),
|
|
).toEqual([
|
|
{ turnId: "active-turn-1", id: "user_message" },
|
|
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
|
|
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
|
|
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
|
|
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
|
|
]);
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("unavailable steer interrupts once and starts one replacement turn", async () => {
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
|
session.steerResult = "unavailable";
|
|
const { manager, agentId, workdir } = await startAndSteerThroughManager(session);
|
|
try {
|
|
expect(session.interruptCount).toBe(1);
|
|
expect(session.startCount).toBe(2);
|
|
expect(manager.getTimeline(agentId)).toContainEqual(
|
|
expect.objectContaining({ type: "user_message", clientMessageId: "replacement-client" }),
|
|
);
|
|
} finally {
|
|
await manager.closeAgent(agentId);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("steers a send that arrives while the previous turn is still starting", async () => {
|
|
const startHeld = deferred<void>();
|
|
class SlowStartSteeringSession extends SteeringTestSession {
|
|
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
|
await startHeld.promise;
|
|
return await super.startTurn(prompt);
|
|
}
|
|
}
|
|
const session = new SlowStartSteeringSession({
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
});
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const steerOptions = (clientMessageId: string) => ({
|
|
replaceRunning: true,
|
|
activeTurnBehavior: "steer" as const,
|
|
runOptions: { clientMessageId },
|
|
});
|
|
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
|
const second = startAgentRun(
|
|
manager,
|
|
agent.id,
|
|
"SECOND",
|
|
logger,
|
|
steerOptions("second-client"),
|
|
);
|
|
|
|
startHeld.resolve();
|
|
await expect(second).resolves.toEqual({ disposition: "steered" });
|
|
|
|
expect(session.startPrompts).toEqual(["FIRST"]);
|
|
expect(session.interruptCount).toBe(0);
|
|
expect(
|
|
manager
|
|
.fetchTimeline(agent.id, { limit: 0 })
|
|
.rows.filter((row) => row.item.type === "user_message")
|
|
.map((row) => ({
|
|
text: row.item.type === "user_message" ? row.item.text : null,
|
|
turnId: row.turnId,
|
|
})),
|
|
).toEqual([
|
|
{ text: "FIRST", turnId: "active-turn-1" },
|
|
{ text: "SECOND", turnId: "active-turn-1" },
|
|
]);
|
|
} finally {
|
|
startHeld.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
|
|
const startHeld = deferred<void>();
|
|
class FailingFirstStartSession extends SteeringTestSession {
|
|
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
|
if (prompt === "FIRST") {
|
|
this.startPrompts.push(prompt);
|
|
await startHeld.promise;
|
|
throw new Error("first start failed");
|
|
}
|
|
return await super.startTurn(prompt);
|
|
}
|
|
}
|
|
const session = new FailingFirstStartSession({
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
});
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const steerOptions = (clientMessageId: string) => ({
|
|
replaceRunning: true,
|
|
activeTurnBehavior: "steer" as const,
|
|
runOptions: { clientMessageId },
|
|
});
|
|
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
|
const second = startAgentRun(
|
|
manager,
|
|
agent.id,
|
|
"SECOND",
|
|
logger,
|
|
steerOptions("second-client"),
|
|
);
|
|
|
|
startHeld.resolve();
|
|
await expect(second).resolves.toEqual({ disposition: "turn_started" });
|
|
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
|
|
expect(session.interruptCount).toBe(0);
|
|
} finally {
|
|
startHeld.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
|
const entered = deferred<void>();
|
|
const release = deferred<void>();
|
|
class HeldAcceptedSteerSession extends SteeringTestSession {
|
|
override async steerActiveTurn(
|
|
_prompt: AgentPromptInput,
|
|
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
|
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
|
this.steerCount += 1;
|
|
expect(options.expectedTurnId).toBe("active-turn-1");
|
|
entered.resolve();
|
|
await release.promise;
|
|
return { status: "accepted" };
|
|
}
|
|
}
|
|
const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-order-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const run = manager.streamAgent(agent.id, "initial");
|
|
const consume = (async () => {
|
|
for await (const _event of run) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
|
|
const steer = manager.steerAgentRun(agent.id, "hello", {
|
|
clientMessageId: "hello-client",
|
|
});
|
|
await entered.promise;
|
|
session.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
turnId: "active-turn-1",
|
|
item: { type: "assistant_message", text: "terminal output" },
|
|
});
|
|
session.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: "active-turn-1",
|
|
});
|
|
expect(manager.getTimeline(agent.id)).not.toContainEqual(
|
|
expect.objectContaining({ type: "assistant_message", text: "terminal output" }),
|
|
);
|
|
|
|
release.resolve();
|
|
await expect(steer).resolves.toEqual({ status: "accepted" });
|
|
await consume;
|
|
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => {
|
|
return (
|
|
(row.item.type === "user_message" && row.item.text === "hello") ||
|
|
(row.item.type === "assistant_message" && row.item.text === "terminal output")
|
|
);
|
|
});
|
|
expect(rows).toMatchObject([
|
|
{ item: { type: "user_message", text: "hello" }, turnId: "active-turn-1" },
|
|
{
|
|
item: { type: "assistant_message", text: "terminal output" },
|
|
turnId: "active-turn-1",
|
|
},
|
|
]);
|
|
} finally {
|
|
release.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("orders buffered pre-steer output before an immediately accepted steer", async () => {
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-buffer-order-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
agentStreamCoalesceWindowMs: 60_000,
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const run = manager.streamAgent(agent.id, "initial");
|
|
void (async () => {
|
|
for await (const _event of run) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
|
|
session.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
turnId: "active-turn-1",
|
|
item: { type: "assistant_message", text: "pre-steer output" },
|
|
});
|
|
await expect(
|
|
manager.steerAgentRun(agent.id, "hello", { clientMessageId: "hello-client" }),
|
|
).resolves.toEqual({ status: "accepted" });
|
|
|
|
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => {
|
|
return (
|
|
(row.item.type === "assistant_message" && row.item.text === "pre-steer output") ||
|
|
(row.item.type === "user_message" && row.item.text === "hello")
|
|
);
|
|
});
|
|
expect(rows).toMatchObject([
|
|
{
|
|
item: { type: "assistant_message", text: "pre-steer output" },
|
|
turnId: "active-turn-1",
|
|
},
|
|
{ item: { type: "user_message", text: "hello" }, turnId: "active-turn-1" },
|
|
]);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a steer the provider names without echoing it can be rewound to", async () => {
|
|
// Claude takes a steered message into the running turn without echoing it back as a user
|
|
// message, so the id it minted for the message reaches the manager only through the admission.
|
|
class SilentSteeringSession extends SteeringTestSession {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsRewindFiles: true,
|
|
};
|
|
rewindMessageIds: string[] = [];
|
|
|
|
override async steerActiveTurn(
|
|
_prompt: AgentPromptInput,
|
|
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
|
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
|
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
|
|
return { status: "unavailable" };
|
|
}
|
|
return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` };
|
|
}
|
|
|
|
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
|
|
this.rewindMessageIds.push(messageId);
|
|
}
|
|
}
|
|
const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const run = manager.streamAgent(agent.id, "initial");
|
|
void (async () => {
|
|
for await (const _event of run) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
|
|
await expect(
|
|
manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }),
|
|
).resolves.toMatchObject({ status: "accepted" });
|
|
const row = manager
|
|
.fetchTimeline(agent.id, { limit: 0 })
|
|
.rows.find((candidate) => candidate.item.type === "user_message");
|
|
expect(row).toMatchObject({
|
|
providerMessageId: "provider-steer-client",
|
|
item: { text: "steered", clientMessageId: "steer-client" },
|
|
});
|
|
|
|
await manager.rewind(agent.id, "steer-client", "files");
|
|
expect(session.rewindMessageIds).toEqual(["provider-steer-client"]);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
|
const entered = deferred<void>();
|
|
const release = deferred<void>();
|
|
class HeldAcceptedSteerSession extends SteeringTestSession {
|
|
override async steerActiveTurn(
|
|
_prompt: AgentPromptInput,
|
|
_options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
|
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
|
this.steerCount += 1;
|
|
entered.resolve();
|
|
await release.promise;
|
|
return { status: "accepted" };
|
|
}
|
|
}
|
|
const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-replace-order-"));
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})(),
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const initial = manager.streamAgent(agent.id, "initial");
|
|
const consumeInitial = (async () => {
|
|
for await (const _event of initial) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
|
|
const steer = manager.steerAgentRun(agent.id, "hello", {
|
|
clientMessageId: "hello-client",
|
|
});
|
|
await entered.promise;
|
|
const replacement = manager.replaceAgentRun(agent.id, "replacement", {
|
|
clientMessageId: "replacement-client",
|
|
});
|
|
await Promise.resolve();
|
|
expect(session.interruptCount).toBe(0);
|
|
|
|
release.resolve();
|
|
await expect(steer).resolves.toEqual({ status: "accepted" });
|
|
const replacementRun = await replacement;
|
|
void (async () => {
|
|
for await (const _event of replacementRun) {
|
|
}
|
|
})();
|
|
await consumeInitial;
|
|
await vi.waitFor(() => expect(session.startCount).toBe(2));
|
|
|
|
expect(session.interruptCount).toBe(1);
|
|
expect(
|
|
manager
|
|
.fetchTimeline(agent.id, { limit: 0 })
|
|
.rows.filter((row) => row.item.type === "user_message")
|
|
.map((row) => row.item),
|
|
).toMatchObject([
|
|
{ text: "hello", clientMessageId: "hello-client" },
|
|
{ text: "replacement", clientMessageId: "replacement-client" },
|
|
]);
|
|
} finally {
|
|
release.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("does not replace a newer foreground turn after unavailable steer fallback is admitted", async () => {
|
|
const entered = deferred<void>();
|
|
const release = deferred<void>();
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
|
session.steerResult = "unavailable";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-steer-"));
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession() {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
beforeSteerUnavailableFallback: async () => {
|
|
entered.resolve();
|
|
await release.promise;
|
|
},
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
let consumeB: Promise<void> | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const a = manager.streamAgent(agent.id, "A");
|
|
const consumeA = (async () => {
|
|
for await (const _ of a) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
const send = startAgentRun(manager, agent.id, "hello", logger, {
|
|
replaceRunning: true,
|
|
activeTurnBehavior: "steer",
|
|
runOptions: { clientMessageId: "hello-client" },
|
|
});
|
|
const rejected = expect(send).rejects.toThrow(
|
|
"Active turn changed before steering could be delivered",
|
|
);
|
|
await entered.promise;
|
|
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
|
await consumeA;
|
|
const b = manager.streamAgent(agent.id, "B");
|
|
consumeB = (async () => {
|
|
for await (const _ of b) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
release.resolve();
|
|
await rejected;
|
|
expect(manager.getAgent(agent.id)?.activeForegroundTurnId).toBe("active-turn-2");
|
|
expect(session.interruptCount).toBe(0);
|
|
expect(session.startPrompts).not.toContain("hello");
|
|
expect(
|
|
manager
|
|
.getTimeline(agent.id)
|
|
.some((item) => item.type === "user_message" && item.clientMessageId === "hello-client"),
|
|
).toBe(false);
|
|
} finally {
|
|
release.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
await Promise.race([
|
|
consumeB ?? Promise.resolve(),
|
|
new Promise((resolve) => setTimeout(resolve, 100)),
|
|
]);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("steers a tracked autonomous turn without creating a replacement run", async () => {
|
|
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession() {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { claude: client }, logger });
|
|
let agentId: string | null = null;
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "claude", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
session.pushEvent({ type: "turn_started", provider: "claude", turnId: "active-turn-0" });
|
|
await vi.waitFor(() => expect(manager.getAgent(agent.id)?.activeTurnId).toBe("active-turn-0"));
|
|
|
|
const result = await startAgentRun(manager, agent.id, "autonomous follow-up", logger, {
|
|
activeTurnBehavior: "steer",
|
|
runOptions: { clientMessageId: "autonomous-follow-up-client" },
|
|
});
|
|
|
|
expect(result).toEqual({ disposition: "steered" });
|
|
expect(session.steerCount).toBe(1);
|
|
expect(session.interruptCount).toBe(0);
|
|
expect(manager.getAgent(agent.id)?.activeTurnId).toBe("active-turn-0");
|
|
expect(manager.getAgent(agent.id)?.activeForegroundTurnId).toBeNull();
|
|
expect(manager.getTimeline(agent.id)).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "user_message",
|
|
text: "autonomous follow-up",
|
|
clientMessageId: "autonomous-follow-up-client",
|
|
}),
|
|
);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("isolated rewind falls back from steering to the normal replacement path", async () => {
|
|
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
|
|
session.steerResult = "unavailable";
|
|
const { manager, agentId, workdir } = await startAndSteerThroughManager(session);
|
|
try {
|
|
await startAgentRun(manager, agentId, "/rewind submitted-message-id", logger, {
|
|
replaceRunning: true,
|
|
activeTurnBehavior: "steer",
|
|
runOptions: { clientMessageId: "rewind-client" },
|
|
});
|
|
await manager.waitForAgentRunStart(agentId);
|
|
expect(session.interruptCount).toBe(2);
|
|
expect(session.startPrompts).toContain("/rewind submitted-message-id");
|
|
expect(manager.getTimeline(agentId)).toContainEqual(
|
|
expect.objectContaining({ type: "user_message", clientMessageId: "rewind-client" }),
|
|
);
|
|
} finally {
|
|
await manager.closeAgent(agentId);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("missing steer operation interrupts once and starts one replacement turn", async () => {
|
|
const session = new UnsupportedSteeringSession({ provider: "codex", cwd: process.cwd() });
|
|
const { manager, agentId, workdir } = await startAndSteerThroughManager(session);
|
|
try {
|
|
expect(session.interruptCount).toBe(1);
|
|
expect(session.startCount).toBe(2);
|
|
expect(manager.getTimeline(agentId)).toContainEqual(
|
|
expect.objectContaining({ type: "user_message", clientMessageId: "replacement-client" }),
|
|
);
|
|
} finally {
|
|
await manager.closeAgent(agentId);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("ambiguous steer failure leaves the active turn untouched", async () => {
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
|
session.steerResult = new Error("connection lost after delivery");
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-ambiguous-"));
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const run = manager.streamAgent(agent.id, "initial");
|
|
void (async () => {
|
|
for await (const _event of run) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
await expect(
|
|
startAgentRun(manager, agent.id, "must not resend", logger, {
|
|
replaceRunning: true,
|
|
activeTurnBehavior: "steer",
|
|
runOptions: { clientMessageId: "ambiguous-client" },
|
|
}),
|
|
).rejects.toThrow("connection lost after delivery");
|
|
expect(session.interruptCount).toBe(0);
|
|
expect(session.startCount).toBe(1);
|
|
expect(manager.getTimeline(agent.id)).not.toContainEqual(
|
|
expect.objectContaining({ clientMessageId: "ambiguous-client" }),
|
|
);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("steering records concurrent early echoes as canonical submitted prompts", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-"));
|
|
const session = new SteeringTestSession({ provider: "codex", cwd: workdir });
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
let agentId: string | null = null;
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = agent.id;
|
|
const stream = manager.streamAgent(agent.id, "start");
|
|
void (async () => {
|
|
for await (const _event of stream) {
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
await Promise.all([
|
|
manager.steerAgentRun(agent.id, "one", { clientMessageId: "client-one" }),
|
|
manager.steerAgentRun(agent.id, "two", { clientMessageId: "client-two" }),
|
|
]);
|
|
const rows = manager.getTimeline(agent.id).filter((item) => item.type === "user_message");
|
|
expect(rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ messageId: "client-one", clientMessageId: "client-one" }),
|
|
expect.objectContaining({ messageId: "client-two", clientMessageId: "client-two" }),
|
|
]),
|
|
);
|
|
expect(rows.filter((item) => item.clientMessageId === "client-one")).toHaveLength(1);
|
|
expect(rows.filter((item) => item.clientMessageId === "client-two")).toHaveLength(1);
|
|
} finally {
|
|
if (agentId) await manager.closeAgent(agentId);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
class McpCapableTestAgentClient extends TestAgentClient {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
};
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createdConfigs.push(config);
|
|
return new McpCapableTestAgentSession(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumeOverrides.push(config);
|
|
return new McpCapableTestAgentSession({
|
|
...config,
|
|
provider: this.provider,
|
|
cwd: config?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
}
|
|
|
|
class ControlledInterruptSession extends TestAgentSession {
|
|
interruptCalled = false;
|
|
|
|
constructor(
|
|
config: AgentSessionConfig,
|
|
readonly turnId: string,
|
|
private readonly interruptBehavior: (session: ControlledInterruptSession) => Promise<void>,
|
|
) {
|
|
super(config);
|
|
}
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId: this.turnId });
|
|
}, 0);
|
|
return { turnId: this.turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCalled = true;
|
|
await this.interruptBehavior(this);
|
|
}
|
|
}
|
|
|
|
interface ControlledInterruptFixture {
|
|
agentId: string;
|
|
manager: AgentManager;
|
|
session: ControlledInterruptSession;
|
|
startForegroundRun(): Promise<void>;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
async function createControlledInterruptFixture(options: {
|
|
name: string;
|
|
agentId: string;
|
|
turnId: string;
|
|
interrupt: (session: ControlledInterruptSession) => Promise<void>;
|
|
}): Promise<ControlledInterruptFixture> {
|
|
const workdir = mkdtempSync(join(tmpdir(), `agent-manager-${options.name}-`));
|
|
const session = new ControlledInterruptSession(
|
|
{ provider: "codex", cwd: workdir },
|
|
options.turnId,
|
|
options.interrupt,
|
|
);
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
logger,
|
|
rescueTimeouts: { interruptSessionMs: 10 },
|
|
idFactory: () => options.agentId,
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
return {
|
|
agentId: agent.id,
|
|
manager,
|
|
session,
|
|
async startForegroundRun() {
|
|
const run = manager.streamAgent(agent.id, "exercise cancellation");
|
|
void (async () => {
|
|
for await (const _event of run) {
|
|
// Keep the foreground stream subscribed until the controlled turn settles.
|
|
}
|
|
})();
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
},
|
|
async cleanup() {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
class HeldRuntimeInfoSession extends TestAgentSession {
|
|
private readonly runtimeInfoRequested = deferred<void>();
|
|
private readonly runtimeInfoAllowed = deferred<void>();
|
|
|
|
override async getRuntimeInfo() {
|
|
this.runtimeInfoRequested.resolve();
|
|
await this.runtimeInfoAllowed.promise;
|
|
return await super.getRuntimeInfo();
|
|
}
|
|
|
|
waitForRuntimeInfo(): Promise<void> {
|
|
return this.runtimeInfoRequested.promise;
|
|
}
|
|
|
|
finishRuntimeInfo(): void {
|
|
this.runtimeInfoAllowed.resolve();
|
|
}
|
|
}
|
|
|
|
class HeldRuntimeInfoClient extends TestAgentClient {
|
|
private readonly sessionCreated = deferred<HeldRuntimeInfoSession>();
|
|
private session: HeldRuntimeInfoSession | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.session = new HeldRuntimeInfoSession(config);
|
|
this.sessionCreated.resolve(this.session);
|
|
return this.session;
|
|
}
|
|
|
|
async waitForRuntimeInfo(): Promise<void> {
|
|
const session = await this.sessionCreated.promise;
|
|
await session.waitForRuntimeInfo();
|
|
}
|
|
|
|
finishRuntimeInfo(): void {
|
|
this.requireSession().finishRuntimeInfo();
|
|
}
|
|
|
|
private requireSession(): HeldRuntimeInfoSession {
|
|
if (!this.session) {
|
|
throw new Error("Expected a created session");
|
|
}
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
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(),
|
|
});
|
|
}
|
|
}
|
|
|
|
interface FakeCodexEmitterArgs {
|
|
turnItems?: AgentTimelineItem[];
|
|
historyItems?: AgentTimelineItem[];
|
|
}
|
|
|
|
function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
|
const turnItems = args.turnItems ?? [];
|
|
const historyItems = args.historyItems ?? [];
|
|
|
|
class FakeCodexSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "turn-fake-codex";
|
|
setTimeout(() => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
for (const item of turnItems) {
|
|
this.pushEvent({ type: "timeline", provider: this.provider, item, turnId });
|
|
}
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
for (const item of historyItems) {
|
|
yield { type: "timeline", provider: this.provider, item };
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
provider: "codex",
|
|
capabilities: TEST_CAPABILITIES,
|
|
async isAvailable() {
|
|
return true;
|
|
},
|
|
async createSession(config: AgentSessionConfig) {
|
|
return new FakeCodexSession(config);
|
|
},
|
|
async resumeSession() {
|
|
throw new Error("unused");
|
|
},
|
|
};
|
|
}
|
|
|
|
const logger = createTestLogger();
|
|
|
|
test("does not register a session that finishes starting after shutdown begins", async () => {
|
|
const client = new HeldAgentCreationClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000100",
|
|
});
|
|
|
|
const creation = manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await client.waitForCreationToStart();
|
|
|
|
manager.prepareForShutdown();
|
|
client.finishCreating();
|
|
|
|
await expect(creation).rejects.toThrow("Agent manager is shutting down");
|
|
expect({ agents: manager.listAgents(), sessionClosed: client.createdSessionClosed }).toEqual({
|
|
agents: [],
|
|
sessionClosed: true,
|
|
});
|
|
});
|
|
|
|
test("flush waits for rejected session cleanup that starts after shutdown", async () => {
|
|
const client = new HeldAgentCreationAndCloseClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000098",
|
|
});
|
|
|
|
const creation = manager
|
|
.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: process.cwd(),
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
)
|
|
.catch((error: unknown) => error);
|
|
await client.waitForCreationToStart();
|
|
|
|
manager.prepareForShutdown();
|
|
let flushResolved = false;
|
|
const flushing = manager.flushForShutdown().then(() => {
|
|
flushResolved = true;
|
|
return undefined;
|
|
});
|
|
client.finishCreating();
|
|
await client.waitForCloseToStart();
|
|
|
|
try {
|
|
expect(flushResolved).toBe(false);
|
|
} finally {
|
|
client.finishClosing();
|
|
}
|
|
|
|
expect(await creation).toBeInstanceOf(AgentManagerShuttingDownError);
|
|
await flushing;
|
|
expect(manager.listAgents()).toEqual([]);
|
|
});
|
|
|
|
test("does not persist an initializing session after shutdown closes it", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-register-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldRuntimeInfoClient();
|
|
const agentId = "00000000-0000-4000-8000-000000000099";
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
});
|
|
|
|
try {
|
|
const creation = manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await client.waitForRuntimeInfo();
|
|
|
|
manager.prepareForShutdown();
|
|
const closing = manager.closeAgent(agentId);
|
|
client.finishRuntimeInfo();
|
|
|
|
await expect(creation).rejects.toBeInstanceOf(AgentManagerShuttingDownError);
|
|
await closing;
|
|
await storage.flush();
|
|
expect({ agents: manager.listAgents(), record: await storage.get(agentId) }).toMatchObject({
|
|
agents: [],
|
|
record: { lastStatus: "closed" },
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("reload leaves a closed durable snapshot when shutdown starts during the swap", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-reload-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const agentId = "00000000-0000-4000-8000-000000000097";
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
});
|
|
|
|
try {
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
|
|
await client.waitForCloseToStart();
|
|
|
|
manager.prepareForShutdown();
|
|
const closing = Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
|
client.finishClosing();
|
|
|
|
await closing;
|
|
expect(await reload).toBeInstanceOf(AgentManagerShuttingDownError);
|
|
await manager.flush();
|
|
await storage.flush();
|
|
expect({
|
|
agents: manager.listAgents(),
|
|
record: await storage.get(agentId),
|
|
replacementSessionClosed: client.replacementSessionClosed,
|
|
}).toMatchObject({
|
|
agents: [],
|
|
record: { lastStatus: "closed" },
|
|
replacementSessionClosed: false,
|
|
});
|
|
} finally {
|
|
client.finishClosing();
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("reload does not create a replacement when the closed snapshot cannot be persisted", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const agentId = "00000000-0000-4000-8000-000000000096";
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
});
|
|
|
|
try {
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await storage.flush();
|
|
rmSync(storagePath, { recursive: true, force: true });
|
|
writeFileSync(storagePath, "blocks the storage directory");
|
|
|
|
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
|
|
await client.waitForCloseToStart();
|
|
client.finishClosing();
|
|
|
|
expect(await reload).toBeInstanceOf(Error);
|
|
await manager.flushForShutdown();
|
|
expect({
|
|
agents: manager.listAgents(),
|
|
originalSessionClosed: client.originalSessionClosed,
|
|
replacementSessionClosed: client.replacementSessionClosed,
|
|
}).toEqual({
|
|
agents: [],
|
|
originalSessionClosed: true,
|
|
replacementSessionClosed: false,
|
|
});
|
|
} finally {
|
|
client.finishClosing();
|
|
await manager.flushForShutdown().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("normalizeConfig injects the provider default model while leaving mode 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(snapshot.config.model).toBe("gpt-5.4");
|
|
expect(snapshot.config.modeId).toBeUndefined();
|
|
});
|
|
|
|
test("normalizeConfig leaves Claude mode omitted", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-claude-default-test-"));
|
|
const manager = new AgentManager({
|
|
clients: { claude: new TestAgentClient("claude") },
|
|
logger,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "claude", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
expect(snapshot.config.modeId).toBeUndefined();
|
|
});
|
|
|
|
test("normalizeConfig does not ask the provider to synthesize an omitted mode", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mode-default-test-"));
|
|
class CapabilityAwareClient extends TestAgentClient {
|
|
resolveDefaultModeCalls = 0;
|
|
|
|
override async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
|
|
this.resolveDefaultModeCalls += 1;
|
|
return input.env?.CLAUDE_CODE_USE_BEDROCK === "1" ? "default" : "auto";
|
|
}
|
|
}
|
|
const client = new CapabilityAwareClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
logger,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
|
});
|
|
|
|
expect(snapshot.config.modeId).toBeUndefined();
|
|
expect(client.resolveDefaultModeCalls).toBe(0);
|
|
});
|
|
|
|
test("createAgent forwards request env into the spawned provider process", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-"));
|
|
const client = new EnvProbeAgentClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-00000000e001",
|
|
});
|
|
|
|
try {
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{
|
|
env: {
|
|
CHUNK14_PROBE: "expected",
|
|
},
|
|
workspaceId: undefined,
|
|
},
|
|
);
|
|
|
|
await expect(client.probe).resolves.toEqual({
|
|
probe: "expected",
|
|
agentId: "00000000-0000-4000-8000-00000000e001",
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(snapshot.config.model).toBe("gpt-5.4");
|
|
expect(snapshot.config.modeId).toBeUndefined();
|
|
});
|
|
|
|
test("listDraftCommands returns no commands without guessing a missing model", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-commands-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
class DraftCommandClient extends TestAgentClient {
|
|
fetchCatalogCalls = 0;
|
|
createSessionCalls = 0;
|
|
availabilityCalls = 0;
|
|
|
|
override async isAvailable(): Promise<boolean> {
|
|
this.availabilityCalls += 1;
|
|
return true;
|
|
}
|
|
|
|
override async fetchCatalog() {
|
|
this.fetchCatalogCalls += 1;
|
|
return await super.fetchCatalog();
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
}
|
|
const client = new DraftCommandClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.listDraftCommands({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
|
|
|
expect(client.fetchCatalogCalls).toBe(0);
|
|
expect(client.createSessionCalls).toBe(0);
|
|
expect(client.availabilityCalls).toBe(0);
|
|
});
|
|
|
|
test("listDraftCommands uses explicit model config without default model fetching", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-commands-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const draftCommand: AgentSlashCommand = {
|
|
name: "review",
|
|
description: "Review changes",
|
|
argumentHint: "",
|
|
kind: "command",
|
|
};
|
|
class DraftCommandSession extends TestAgentSession {
|
|
override async listCommands(): Promise<AgentSlashCommand[]> {
|
|
return [draftCommand];
|
|
}
|
|
}
|
|
class DraftCommandClient extends TestAgentClient {
|
|
fetchCatalogCalls = 0;
|
|
createSessionCalls = 0;
|
|
readonly commandConfigs: AgentSessionConfig[] = [];
|
|
|
|
override async fetchCatalog() {
|
|
this.fetchCatalogCalls += 1;
|
|
return await super.fetchCatalog();
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
this.commandConfigs.push(config);
|
|
return new DraftCommandSession(config);
|
|
}
|
|
}
|
|
const client = new DraftCommandClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const commands = await manager.listDraftCommands({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
});
|
|
|
|
expect(commands).toEqual([draftCommand]);
|
|
expect(client.fetchCatalogCalls).toBe(0);
|
|
expect(client.createSessionCalls).toBe(1);
|
|
expect(client.commandConfigs).toEqual([
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
class DraftFeatureClient extends TestAgentClient {
|
|
fetchCatalogCalls = 0;
|
|
createSessionCalls = 0;
|
|
availabilityCalls = 0;
|
|
|
|
override async isAvailable(): Promise<boolean> {
|
|
this.availabilityCalls += 1;
|
|
return true;
|
|
}
|
|
|
|
override async fetchCatalog() {
|
|
this.fetchCatalogCalls += 1;
|
|
return await super.fetchCatalog();
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
}
|
|
const client = new DraftFeatureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
|
|
|
expect(client.fetchCatalogCalls).toBe(0);
|
|
expect(client.createSessionCalls).toBe(0);
|
|
expect(client.availabilityCalls).toBe(0);
|
|
});
|
|
|
|
test("listDraftFeatures uses client feature listing without a model", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
|
class DraftFeatureClient extends TestAgentClient {
|
|
fetchCatalogCalls = 0;
|
|
createSessionCalls = 0;
|
|
availabilityCalls = 0;
|
|
readonly featureConfigs: AgentSessionConfig[] = [];
|
|
|
|
override async isAvailable(): Promise<boolean> {
|
|
this.availabilityCalls += 1;
|
|
return true;
|
|
}
|
|
|
|
override async fetchCatalog() {
|
|
this.fetchCatalogCalls += 1;
|
|
return await super.fetchCatalog();
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
|
|
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
|
this.featureConfigs.push(config);
|
|
return [draftFeature];
|
|
}
|
|
}
|
|
const client = new DraftFeatureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
|
draftFeature,
|
|
]);
|
|
|
|
expect(client.fetchCatalogCalls).toBe(0);
|
|
expect(client.createSessionCalls).toBe(0);
|
|
expect(client.availabilityCalls).toBe(1);
|
|
expect(client.featureConfigs).toEqual([
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
|
|
class DraftFeatureClient extends TestAgentClient {
|
|
fetchCatalogCalls = 0;
|
|
createSessionCalls = 0;
|
|
readonly featureConfigs: AgentSessionConfig[] = [];
|
|
|
|
override async fetchCatalog() {
|
|
this.fetchCatalogCalls += 1;
|
|
return await super.fetchCatalog();
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
|
|
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
|
this.featureConfigs.push(config);
|
|
return [draftFeature];
|
|
}
|
|
}
|
|
const client = new DraftFeatureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const features = await manager.listDraftFeatures({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
});
|
|
|
|
expect(features).toEqual([draftFeature]);
|
|
expect(client.fetchCatalogCalls).toBe(0);
|
|
expect(client.createSessionCalls).toBe(0);
|
|
expect(client.featureConfigs).toEqual([
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("createAgent injects daemon append system prompt at runtime only", 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,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
appendSystemPrompt: " Daemon instructions. ",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
systemPrompt: "Agent instructions.",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const record = await storage.get(snapshot.id);
|
|
|
|
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
|
|
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
|
expect(snapshot.config).not.toHaveProperty("daemonAppendSystemPrompt");
|
|
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
|
|
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
|
|
});
|
|
|
|
test("daemon append system prompt is injected into Pi configs", 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: {
|
|
pi: client as unknown as AgentClient,
|
|
},
|
|
providerDefinitions: {
|
|
pi: { enabled: true },
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
appendSystemPrompt: "Daemon instructions.",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
|
});
|
|
|
|
await manager.createAgent(
|
|
{
|
|
provider: "pi",
|
|
cwd: workdir,
|
|
systemPrompt: "Agent instructions.",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
|
});
|
|
|
|
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 fetchCatalog() {
|
|
return {
|
|
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
|
|
modes: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("reload releases the original writer before resuming the same session", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-writer-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
class ExclusiveWriterClient extends TestAgentClient {
|
|
current: CloseRecordingTestAgentSession | undefined;
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.current = new CloseRecordingTestAgentSession(config);
|
|
return this.current;
|
|
}
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
if (this.current && !this.current.closed) {
|
|
throw new Error("thread already has an active writer");
|
|
}
|
|
this.current = new CloseRecordingTestAgentSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? workdir,
|
|
});
|
|
this.current.describePersistence = () => ({ provider: "codex", sessionId: handle.sessionId });
|
|
return this.current;
|
|
}
|
|
}
|
|
const client = new ExclusiveWriterClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
for (let i = 0; i < 3; i++) {
|
|
const reloaded = await manager.reloadAgentSession(created.id, undefined, {
|
|
rehydrateFromDisk: true,
|
|
});
|
|
expect(reloaded.id).toBe(created.id);
|
|
expect(reloaded.persistence?.sessionId).toBe(created.persistence?.sessionId);
|
|
}
|
|
await manager.closeAgent(created.id);
|
|
} finally {
|
|
await client.current?.close();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("opening during reload waits for the replacement", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-open-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const reloading = manager.reloadAgentSession(created.id);
|
|
await client.waitForCloseToStart();
|
|
let opened = false;
|
|
const opening = ensureAgentLoaded(created.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
}).then((agent) => {
|
|
opened = true;
|
|
return agent;
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(opened).toBe(false);
|
|
client.finishClosing();
|
|
const replacement = await reloading;
|
|
expect((await opening).session).toBe(replacement.session);
|
|
await manager.closeAgent(created.id);
|
|
} finally {
|
|
client.finishClosing();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("closing during reload leaves the replacement closed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const reloading = manager.reloadAgentSession(created.id);
|
|
await client.waitForCloseToStart();
|
|
const closing = manager.closeAgent(created.id);
|
|
client.finishClosing();
|
|
await Promise.all([reloading, closing]);
|
|
expect(manager.getAgent(created.id)).toBeNull();
|
|
expect(client.replacementSessionClosed).toBe(true);
|
|
expect(await storage.get(created.id)).toMatchObject({ lastStatus: "closed" });
|
|
} finally {
|
|
client.finishClosing();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("every failed reload raises error attention, replacing unread finished attention", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-error-attention-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { reloadSessionCloseMs: 10 },
|
|
onAgentAttention: ({ reason }) => attentionReasons.push(reason),
|
|
});
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.markAgentUnread(created.id);
|
|
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
|
attentionReason: "finished",
|
|
});
|
|
|
|
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing");
|
|
|
|
expect(manager.getAgent(created.id)?.lifecycle).toBe("error");
|
|
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
expect(attentionReasons).toEqual(["error"]);
|
|
|
|
await manager.clearAgentAttention(created.id);
|
|
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing");
|
|
expect(manager.getAgent(created.id)?.attention).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
expect(attentionReasons).toEqual(["error", "error"]);
|
|
|
|
client.finishClosing();
|
|
await manager.closeAgent(created.id);
|
|
} finally {
|
|
client.finishClosing();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retrying a timed-out reload waits for the original close to finish", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new HeldReloadCloseClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { reloadSessionCloseMs: 10 },
|
|
});
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing");
|
|
expect(manager.getAgent(created.id)?.lifecycle).toBe("error");
|
|
const retry = manager.reloadAgentSession(created.id);
|
|
client.finishClosing();
|
|
expect((await retry).id).toBe(created.id);
|
|
expect(client.originalSessionClosed).toBe(true);
|
|
await manager.closeAgent(created.id);
|
|
} finally {
|
|
client.finishClosing();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("failed reload retains the closed agent for a later resume", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-recovery-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
class FailingResumeClient extends TestAgentClient {
|
|
fail = true;
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
if (this.fail) throw new Error("resume unavailable");
|
|
return super.resumeSession(handle, config);
|
|
}
|
|
}
|
|
const client = new FailingResumeClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const created = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Keep me" },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("resume unavailable");
|
|
expect(manager.getAgent(created.id)).toBeNull();
|
|
expect(await storage.get(created.id)).toMatchObject({
|
|
lastStatus: "closed",
|
|
title: "Keep me",
|
|
persistence: created.persistence,
|
|
});
|
|
client.fail = false;
|
|
const recovered = await ensureAgentLoaded(created.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
expect(recovered.id).toBe(created.id);
|
|
expect((await storage.get(created.id))?.title).toBe("Keep me");
|
|
await manager.closeAgent(created.id);
|
|
} finally {
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.each(["hang", "reject"])(
|
|
"reload does not resume when the previous session close fails: %s",
|
|
async (failure) => {
|
|
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;
|
|
if (failure === "reject") throw new Error("close rejected");
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
await expect(manager.reloadAgentSession(snapshot.id)).rejects.toThrow(
|
|
failure === "hang" ? "Timed out closing previous session" : "close rejected",
|
|
);
|
|
}
|
|
expect(client.firstSession.closeCalled).toBe(true);
|
|
expect(client.resumeSessionCalls).toBe(0);
|
|
expect(manager.getAgent(snapshot.id)?.session).toBe(client.firstSession);
|
|
} finally {
|
|
await manager.flush();
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("cancelAgentRun preserves running state when the provider interrupt hangs", async () => {
|
|
const fixture = await createControlledInterruptFixture({
|
|
name: "interrupt-timeout",
|
|
agentId: "00000000-0000-4000-8000-000000000303",
|
|
turnId: "hanging-interrupt-turn",
|
|
interrupt: async () => await new Promise(() => {}),
|
|
});
|
|
|
|
try {
|
|
const running = waitForAgentLifecycle(fixture.manager, fixture.agentId, "running");
|
|
fixture.session.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: "hanging-interrupt-turn",
|
|
});
|
|
await running;
|
|
|
|
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
|
status: "refused",
|
|
});
|
|
expect(fixture.session.interruptCalled).toBe(true);
|
|
expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running");
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelAgentRun preserves the active turn when the provider rejects the interrupt", async () => {
|
|
const fixture = await createControlledInterruptFixture({
|
|
name: "interrupt-rejected",
|
|
agentId: "00000000-0000-4000-8000-000000000304",
|
|
turnId: "provider-still-active-turn",
|
|
interrupt: async () => {
|
|
throw new Error("A foreground turn is already active");
|
|
},
|
|
});
|
|
|
|
try {
|
|
await fixture.startForegroundRun();
|
|
|
|
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
|
status: "refused",
|
|
});
|
|
expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({
|
|
lifecycle: "running",
|
|
activeForegroundTurnId: "provider-still-active-turn",
|
|
});
|
|
|
|
fixture.session.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: "provider-still-active-turn",
|
|
});
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelAgentRun succeeds when the foreground turn finishes before the provider rejects the interrupt", async () => {
|
|
let fixture!: ControlledInterruptFixture;
|
|
fixture = await createControlledInterruptFixture({
|
|
name: "interrupt-after-completion",
|
|
agentId: "00000000-0000-4000-8000-000000000305",
|
|
turnId: "naturally-completed-turn",
|
|
interrupt: async (session) => {
|
|
const settled = waitForAgentLifecycle(fixture.manager, fixture.agentId, "idle");
|
|
session.pushEvent({
|
|
type: "turn_completed",
|
|
provider: session.provider,
|
|
turnId: "naturally-completed-turn",
|
|
});
|
|
await settled;
|
|
throw new Error("turn already completed");
|
|
},
|
|
});
|
|
|
|
try {
|
|
await fixture.startForegroundRun();
|
|
|
|
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
|
status: "settled",
|
|
});
|
|
expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({
|
|
lifecycle: "idle",
|
|
activeForegroundTurnId: null,
|
|
});
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelAgentRun succeeds when the provider queues completion before rejecting the interrupt", async () => {
|
|
const fixture = await createControlledInterruptFixture({
|
|
name: "interrupt-queued-completion",
|
|
agentId: "00000000-0000-4000-8000-000000000306",
|
|
turnId: "queued-completion-turn",
|
|
interrupt: async (session) => {
|
|
session.pushEvent({
|
|
type: "turn_completed",
|
|
provider: session.provider,
|
|
turnId: "queued-completion-turn",
|
|
});
|
|
throw new Error("turn already completed");
|
|
},
|
|
});
|
|
|
|
try {
|
|
await fixture.startForegroundRun();
|
|
|
|
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
|
status: "settled",
|
|
});
|
|
expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({
|
|
lifecycle: "idle",
|
|
activeForegroundTurnId: null,
|
|
});
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(client.lastConfig).toEqual({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.4",
|
|
});
|
|
expect(client.lastLaunchContext).toEqual({
|
|
agentId: snapshot.id,
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
{ provider: "codex", hasHandle: true },
|
|
{ provider: "opencode", hasHandle: true },
|
|
{ provider: "codex", hasHandle: false },
|
|
{ provider: "opencode", hasHandle: false },
|
|
])(
|
|
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
|
|
async ({ provider, hasHandle }) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
|
|
const storagePath = join(workdir, "agents");
|
|
class LaunchEnvClient extends TestAgentClient {
|
|
lastLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
context?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastLaunchContext = context;
|
|
const session = await super.createSession(config);
|
|
session.describePersistence = () => ({ provider, sessionId: session.id });
|
|
return session;
|
|
}
|
|
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
context?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastLaunchContext = context;
|
|
const session = await super.resumeSession(handle, config, context);
|
|
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const client = new LaunchEnvClient(provider);
|
|
let storage = new AgentStorage(storagePath, logger);
|
|
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
|
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
|
|
const expectedEnv = { ...env };
|
|
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
env,
|
|
});
|
|
onTestFinished(async () => {
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
env.XDG_DATA_HOME = "caller-mutated";
|
|
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
|
|
if (!hasHandle) {
|
|
const record = await storage.get(agent.id);
|
|
if (!record) throw new Error("Created agent was not persisted");
|
|
await storage.upsert({ ...record, persistence: null });
|
|
}
|
|
|
|
// Fresh storage and manager instances must read the environment from disk.
|
|
for (let restart = 0; restart < 2; restart += 1) {
|
|
storage = new AgentStorage(storagePath, logger);
|
|
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
|
const resumed = await ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
expect(client.lastLaunchContext).toEqual({
|
|
agentId: agent.id,
|
|
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
|
});
|
|
await manager.reloadAgentSession(agent.id);
|
|
expect(client.lastLaunchContext).toEqual({
|
|
agentId: agent.id,
|
|
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
|
});
|
|
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
|
|
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
}
|
|
},
|
|
);
|
|
|
|
test("createAgent and resume give the agent its workspace id", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
createLaunchContext: AgentLaunchContext | undefined;
|
|
resumeLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.createLaunchContext = launchContext;
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.resumeLaunchContext = launchContext;
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir });
|
|
}
|
|
}
|
|
|
|
const client = new CaptureClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000140",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: "ws-agent-home",
|
|
});
|
|
expect(client.createLaunchContext?.env).toEqual({
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
PASEO_WORKSPACE_ID: "ws-agent-home",
|
|
});
|
|
|
|
// The stored record supplies the workspace when the caller doesn't.
|
|
const handle: AgentPersistenceHandle = {
|
|
provider: "codex",
|
|
sessionId: "workspace-env-session",
|
|
metadata: { provider: "codex", cwd: workdir },
|
|
};
|
|
const expectedEnv = {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
PASEO_WORKSPACE_ID: "ws-agent-home",
|
|
};
|
|
|
|
await manager.closeAgent(snapshot.id);
|
|
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
|
|
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
|
|
expect(resumed.workspaceId).toBe("ws-agent-home");
|
|
expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home");
|
|
|
|
// Each restart reads what the previous resume stored.
|
|
client.resumeLaunchContext = undefined;
|
|
await manager.closeAgent(snapshot.id);
|
|
await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
|
|
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
|
|
});
|
|
|
|
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, workspaceId: undefined },
|
|
);
|
|
|
|
expect(client.lastCreateOptions).toEqual({ persistSession: false });
|
|
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("createAgent persists workspaceId on the stored record and emits it in the snapshot", 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-0000000000a1",
|
|
});
|
|
|
|
try {
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: "wks_owner" },
|
|
);
|
|
|
|
expect(agent.workspaceId).toBe("wks_owner");
|
|
expect(toAgentPayload(agent).workspaceId).toBe("wks_owner");
|
|
|
|
const record = await storage.get(agent.id);
|
|
expect(record?.workspaceId).toBe("wks_owner");
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("createAgent injects paseo MCP server only into provider launch 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 McpCapableTestAgentSession(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",
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(snapshot.config.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
expect(client.lastConfig?.mcpServers).toEqual({
|
|
paseo: {
|
|
type: "http",
|
|
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
|
},
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
|
|
const stored = await storage.get(snapshot.id);
|
|
expect(stored?.config?.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("createAgent closes and rejects a provider session that cannot honor MCP servers", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let sessionClosed = false;
|
|
let promptStarted = false;
|
|
|
|
class UnsupportedMcpSession extends TestAgentSession {
|
|
override async run(): Promise<AgentRunResult> {
|
|
promptStarted = true;
|
|
return super.run();
|
|
}
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
promptStarted = true;
|
|
return super.startTurn();
|
|
}
|
|
|
|
override async close(): Promise<void> {
|
|
sessionClosed = true;
|
|
}
|
|
}
|
|
|
|
class UnsupportedMcpClient extends TestAgentClient {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
};
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new UnsupportedMcpSession(config);
|
|
}
|
|
}
|
|
|
|
const agentId = "00000000-0000-4000-8000-000000000107";
|
|
const manager = new AgentManager({
|
|
clients: { codex: new UnsupportedMcpClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
hub: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:3000/api/executions/test/mcp",
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).rejects.toThrow("Provider 'codex' does not support MCP servers");
|
|
|
|
expect(sessionClosed).toBe(true);
|
|
expect(promptStarted).toBe(false);
|
|
expect(manager.getAgent(agentId)).toBeNull();
|
|
expect(await storage.get(agentId)).toBeNull();
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resumeAgentFromPersistence closes and rejects a session that cannot honor external MCP", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const replacement = new CloseRecordingTestAgentSession({ provider: "codex", cwd: workdir });
|
|
|
|
class UnsupportedResumeClient extends TestAgentClient {
|
|
override async resumeSession(): Promise<AgentSession> {
|
|
return replacement;
|
|
}
|
|
}
|
|
|
|
const agentId = "00000000-0000-4000-8000-000000000108";
|
|
const manager = new AgentManager({
|
|
clients: { codex: new UnsupportedResumeClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const handle: AgentPersistenceHandle = {
|
|
provider: "codex",
|
|
sessionId: "persist-unsupported-mcp",
|
|
metadata: { cwd: workdir },
|
|
};
|
|
|
|
try {
|
|
await expect(
|
|
manager.resumeAgentFromPersistence(
|
|
handle,
|
|
{
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
hub: { type: "http", url: "https://hub.test/mcp/executions/resume" },
|
|
},
|
|
},
|
|
agentId,
|
|
),
|
|
).rejects.toThrow("Provider 'codex' does not support MCP servers");
|
|
|
|
expect(replacement.closed).toBe(true);
|
|
expect(manager.getAgent(agentId)).toBeNull();
|
|
expect(await storage.get(agentId)).toBeNull();
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("reloadAgentSession preserves the live session when its replacement cannot honor external MCP", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const original = new CloseRecordingTestAgentSession({ provider: "codex", cwd: workdir });
|
|
const replacement = new CloseRecordingTestAgentSession({ provider: "codex", cwd: workdir });
|
|
|
|
class UnsupportedReloadClient extends TestAgentClient {
|
|
override async createSession(): Promise<AgentSession> {
|
|
return original;
|
|
}
|
|
|
|
override async resumeSession(): Promise<AgentSession> {
|
|
return replacement;
|
|
}
|
|
}
|
|
|
|
let paseoToolPolicy = { disabledTools: ["list_agents"] };
|
|
const manager = new AgentManager({
|
|
clients: { codex: new UnsupportedReloadClient() },
|
|
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
logger,
|
|
resolvePaseoToolPolicy: () => paseoToolPolicy,
|
|
});
|
|
|
|
try {
|
|
const created = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000109",
|
|
{ workspaceId: undefined },
|
|
);
|
|
paseoToolPolicy = { disabledTools: ["create_agent"] };
|
|
|
|
await expect(
|
|
manager.reloadAgentSession(created.id, {
|
|
mcpServers: {
|
|
hub: { type: "http", url: "https://hub.test/mcp/executions/reload" },
|
|
},
|
|
}),
|
|
).rejects.toThrow("Provider 'codex' does not support MCP servers");
|
|
|
|
expect(replacement.closed).toBe(false);
|
|
expect(original.closed).toBe(false);
|
|
expect(manager.getAgent(created.id)?.session).toBe(original);
|
|
expect(manager.getAgent(created.id)?.lifecycle).toBe("idle");
|
|
expect(manager.getPaseoToolPolicy(created.id)).toEqual({
|
|
disabledTools: ["list_agents"],
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("createAgent passes native Paseo tools through launch context without internal MCP", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const paseoTools: PaseoToolCatalog = {
|
|
tools: new Map(),
|
|
getTool: () => undefined,
|
|
executeTool: async () => {
|
|
throw new Error("No tools registered in test catalog");
|
|
},
|
|
};
|
|
|
|
class NativeToolsClient extends TestAgentClient {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
supportsNativePaseoTools: true,
|
|
};
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
lastLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
this.lastLaunchContext = launchContext;
|
|
return new McpCapableTestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const client = new NativeToolsClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
paseoToolCatalogFactory: () => paseoTools,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000106",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(client.lastLaunchContext?.paseoTools).toBe(paseoTools);
|
|
expect(client.lastConfig?.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
expect(snapshot.config.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
|
|
const stored = await storage.get(snapshot.id);
|
|
expect(stored?.config?.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("createAgent allows best-effort internal MCP when the provider session reports no support", 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",
|
|
mcpAuthToken: "cap-token",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(manager.getMcpAuthToken()).toBe("cap-token");
|
|
expect(client.lastConfig?.mcpServers?.paseo).toEqual({
|
|
type: "http",
|
|
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
|
headers: { Authorization: "Bearer cap-token" },
|
|
});
|
|
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("uses each provider's current policy for new sessions and snapshots it by agent", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const policies = new Map<AgentProvider, { enabled?: boolean; disabledTools?: string[] }>([
|
|
["codex", { disabledTools: ["list_agents"] }],
|
|
["claude", { enabled: false }],
|
|
]);
|
|
|
|
class CaptureClient extends TestAgentClient {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
supportsNativePaseoTools: true,
|
|
};
|
|
readonly launchContexts: AgentLaunchContext[] = [];
|
|
readonly configs: AgentSessionConfig[] = [];
|
|
|
|
override async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
this.configs.push(config);
|
|
if (launchContext) this.launchContexts.push(launchContext);
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const codex = new CaptureClient("codex");
|
|
const claude = new CaptureClient("claude");
|
|
const policyInputs: Array<{ callerAgentId?: string; paseoToolPolicy?: unknown }> = [];
|
|
const paseoTools: PaseoToolCatalog = {
|
|
tools: new Map(),
|
|
getTool: () => undefined,
|
|
executeTool: async () => {
|
|
throw new Error("No tools registered in test catalog");
|
|
},
|
|
};
|
|
const manager = new AgentManager({
|
|
clients: { codex, claude },
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
resolvePaseoToolPolicy: (provider) => policies.get(provider),
|
|
paseoToolCatalogFactory: async (context) => {
|
|
policyInputs.push(context);
|
|
return paseoTools;
|
|
},
|
|
});
|
|
|
|
const codexAgent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000107",
|
|
{ workspaceId: undefined },
|
|
);
|
|
const claudeAgent = await manager.createAgent(
|
|
{ provider: "claude", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000108",
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(policyInputs).toEqual([
|
|
{ callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } },
|
|
]);
|
|
expect(codex.launchContexts[0]?.paseoTools).toBe(paseoTools);
|
|
expect(claude.launchContexts[0]?.paseoTools).toBeUndefined();
|
|
expect(codex.configs[0]?.mcpServers?.paseo).toBeUndefined();
|
|
expect(claude.configs[0]?.mcpServers).toBeUndefined();
|
|
expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({
|
|
disabledTools: ["list_agents"],
|
|
});
|
|
expect(manager.getPaseoToolPolicy(claudeAgent.id)).toEqual({ enabled: false });
|
|
|
|
policies.set("codex", { disabledTools: ["create_agent"] });
|
|
const nextCodexAgent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000111",
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({
|
|
disabledTools: ["list_agents"],
|
|
});
|
|
expect(manager.getPaseoToolPolicy(nextCodexAgent.id)).toEqual({
|
|
disabledTools: ["create_agent"],
|
|
});
|
|
expect(policyInputs).toEqual([
|
|
{ callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } },
|
|
{
|
|
callerAgentId: nextCodexAgent.id,
|
|
paseoToolPolicy: { disabledTools: ["create_agent"] },
|
|
},
|
|
]);
|
|
|
|
await manager.archiveAgent(claudeAgent.id);
|
|
expect(manager.getPaseoToolPolicy(claudeAgent.id)).toBeUndefined();
|
|
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("keeps the global Paseo-tools gate outside provider policy and MCP injection", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
|
|
class McpClient extends TestAgentClient {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsMcpServers: true,
|
|
};
|
|
lastConfig: AgentSessionConfig | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.lastConfig = config;
|
|
return new TestAgentSession(config);
|
|
}
|
|
}
|
|
|
|
const enabledClient = new McpClient();
|
|
const enabledManager = new AgentManager({
|
|
clients: { codex: enabledClient },
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
resolvePaseoToolPolicy: () => ({ disabledTools: ["list_agents"] }),
|
|
});
|
|
const enabledAgent = await enabledManager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000109",
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(enabledClient.lastConfig?.mcpServers?.paseo).toEqual({
|
|
type: "http",
|
|
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${enabledAgent.id}`,
|
|
});
|
|
|
|
const disabledClient = new McpClient();
|
|
let catalogFactoryCalls = 0;
|
|
const disabledManager = new AgentManager({
|
|
clients: { codex: disabledClient },
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
|
paseoToolsEnabled: false,
|
|
resolvePaseoToolPolicy: () => ({ enabled: true }),
|
|
paseoToolCatalogFactory: () => {
|
|
catalogFactoryCalls += 1;
|
|
return paseoTools;
|
|
},
|
|
});
|
|
const disabledAgent = await disabledManager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000110",
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(disabledClient.lastConfig?.mcpServers).toBeUndefined();
|
|
expect(catalogFactoryCalls).toBe(0);
|
|
expect(disabledManager.getPaseoToolPolicy(disabledAgent.id)).toEqual({ enabled: false });
|
|
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new McpCapableTestAgentClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents",
|
|
idFactory: () => "00000000-0000-4000-8000-000000000105",
|
|
});
|
|
const handle: AgentPersistenceHandle = {
|
|
provider: "codex",
|
|
sessionId: "session-123",
|
|
metadata: {
|
|
cwd: workdir,
|
|
},
|
|
};
|
|
|
|
const snapshot = await manager.resumeAgentFromPersistence(handle, {
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
|
|
},
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(client.resumeOverrides[0]?.mcpServers).toEqual({
|
|
paseo: {
|
|
type: "http",
|
|
url: `http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`,
|
|
},
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
expect(snapshot.config.mcpServers).toEqual({
|
|
custom: {
|
|
type: "stdio",
|
|
command: "custom-mcp",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime injection is disabled", 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,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const handle: AgentPersistenceHandle = {
|
|
provider: "codex",
|
|
sessionId: "session-123",
|
|
metadata: {
|
|
cwd: workdir,
|
|
},
|
|
};
|
|
|
|
const snapshot = await manager.resumeAgentFromPersistence(handle, {
|
|
cwd: workdir,
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(client.resumeOverrides[0]?.mcpServers).toBeUndefined();
|
|
expect(snapshot.config.mcpServers).toBeUndefined();
|
|
});
|
|
|
|
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 McpCapableTestAgentSession(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",
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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"),
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).rejects.toThrow("Provider 'codex' is disabled");
|
|
expect(disabledClient.createSessionCalls).toBe(0);
|
|
expect(await storage.listAll()).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 }, undefined, {
|
|
workspaceId: undefined,
|
|
}),
|
|
).rejects.toThrow("Provider 'codex' is disabled");
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { codex: { enabled: true } },
|
|
clients: { codex: client },
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
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 }, undefined, {
|
|
workspaceId: undefined,
|
|
}),
|
|
).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 }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
expect(snapshot.config.provider).toBe("codex");
|
|
});
|
|
|
|
test("updateProviderRegistry removes providers omitted from the next registry", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const removedProvider = "zai-claude" as AgentProvider;
|
|
class RemovedProviderClient extends TestAgentClient {
|
|
createSessionCalls = 0;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.createSessionCalls += 1;
|
|
return await super.createSession(config);
|
|
}
|
|
}
|
|
|
|
const removedClient = new RemovedProviderClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient(), [removedProvider]: removedClient },
|
|
providerDefinitions: {
|
|
codex: { enabled: true },
|
|
[removedProvider]: { enabled: true },
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
expect(manager.getRegisteredProviderIds()).toContain(removedProvider);
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { codex: { enabled: true } },
|
|
clients: { codex: new TestAgentClient() },
|
|
});
|
|
|
|
expect(manager.getRegisteredProviderIds()).not.toContain(removedProvider);
|
|
await expect(
|
|
manager.createAgent({ provider: removedProvider, cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
}),
|
|
).rejects.toThrow("Unknown provider 'zai-claude'");
|
|
expect(removedClient.createSessionCalls).toBe(0);
|
|
});
|
|
|
|
test("retires loaded agents when their plugin provider is replaced", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const provider = "plugin-provider";
|
|
|
|
class OriginalPluginClient extends TestAgentClient {
|
|
session: CloseRecordingTestAgentSession | null = null;
|
|
|
|
constructor() {
|
|
super(provider);
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new CloseRecordingTestAgentSession(config);
|
|
session.describePersistence = () => ({
|
|
provider,
|
|
sessionId: "plugin-session",
|
|
});
|
|
this.session = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const original = new OriginalPluginClient();
|
|
const replacement = new TestAgentClient(provider);
|
|
const manager = new AgentManager({
|
|
clients: { [provider]: original },
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
try {
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
clients: { [provider]: replacement },
|
|
retiredProviders: [provider],
|
|
});
|
|
|
|
await manager.waitForAgentClose(created.id);
|
|
expect(original.session?.closed).toBe(true);
|
|
expect(manager.getAgent(created.id)).toBeNull();
|
|
|
|
const resumed = await ensureAgentLoaded(created.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
expect(resumed.id).toBe(created.id);
|
|
expect(replacement.resumeOverrides).toEqual([
|
|
expect.objectContaining({ cwd: workdir, provider }),
|
|
]);
|
|
} finally {
|
|
await manager.closeAgent(created.id).catch(() => undefined);
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a prompt after provider replacement reopens the stale session", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const provider = "plugin-provider";
|
|
|
|
class StalePluginSession extends CloseRecordingTestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
throw new StaleProviderSessionError("stale-bridge");
|
|
}
|
|
}
|
|
|
|
class StalePluginClient extends TestAgentClient {
|
|
constructor() {
|
|
super(provider);
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new StalePluginSession(config);
|
|
session.describePersistence = () => ({
|
|
provider,
|
|
sessionId: "plugin-session",
|
|
});
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const staleClient = new StalePluginClient();
|
|
const replacement = new ResumeTrackingTestAgentClient(provider);
|
|
const manager = new AgentManager({
|
|
clients: { [provider]: staleClient },
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
try {
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
clients: { [provider]: replacement },
|
|
});
|
|
|
|
const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger, {
|
|
runOptions: { clientMessageId: "stale-session-retry" },
|
|
});
|
|
expect(dispatch.disposition).toBe("turn_started");
|
|
await replacement.waitForRetryStart();
|
|
const result = await manager.waitForAgentEvent(created.id);
|
|
expect(result.status).toBe("idle");
|
|
expect(replacement.resumeOverrides).toHaveLength(1);
|
|
const timeline = manager.getTimeline(created.id);
|
|
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
|
|
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
|
]);
|
|
expect(
|
|
timeline.some(
|
|
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
await manager.closeAgent(created.id).catch(() => undefined);
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a replacement prompt recovers when the retired session fails to start", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const provider = "plugin-provider";
|
|
|
|
class StaleReplacementSession extends TestAgentSession {
|
|
private starts = 0;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
this.starts += 1;
|
|
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
|
|
return { turnId: "initial-turn" };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
turnId: "initial-turn",
|
|
});
|
|
}
|
|
}
|
|
|
|
class StaleReplacementClient extends TestAgentClient {
|
|
constructor() {
|
|
super(provider);
|
|
}
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new StaleReplacementSession(config);
|
|
session.describePersistence = () => ({ provider, sessionId: "plugin-session" });
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const replacement = new ResumeTrackingTestAgentClient(provider);
|
|
const manager = new AgentManager({
|
|
clients: { [provider]: new StaleReplacementClient() },
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { interruptSessionMs: 20 },
|
|
});
|
|
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
try {
|
|
const initial = manager.streamAgent(created.id, "initial");
|
|
void drainAsyncGenerator(initial);
|
|
await manager.waitForAgentRunStart(created.id);
|
|
|
|
manager.updateProviderRegistry({
|
|
providerDefinitions: { [provider]: { enabled: true } },
|
|
clients: { [provider]: replacement },
|
|
});
|
|
|
|
const dispatch = await startAgentRun(manager, created.id, "replacement", logger, {
|
|
replaceRunning: true,
|
|
});
|
|
expect(dispatch.disposition).toBe("turn_started");
|
|
await replacement.waitForRetryStart();
|
|
const result = await manager.waitForAgentEvent(created.id);
|
|
expect(result.status).toBe("idle");
|
|
expect(replacement.resumeOverrides).toHaveLength(1);
|
|
} finally {
|
|
await manager.closeAgent(created.id).catch(() => undefined);
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
supportsMcpServers: true,
|
|
};
|
|
lastResumeOverrides: Partial<AgentSessionConfig> | undefined;
|
|
lastResumeLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new TestAgentSession(config);
|
|
}
|
|
|
|
async fetchCatalog() {
|
|
return {
|
|
models: [
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
isDefault: true,
|
|
},
|
|
],
|
|
modes: [],
|
|
};
|
|
}
|
|
|
|
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 McpCapableTestAgentSession(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({
|
|
model: "gpt-5.4",
|
|
systemPrompt: "new prompt",
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"],
|
|
},
|
|
},
|
|
});
|
|
expect(client.lastResumeOverrides).not.toHaveProperty("modeId");
|
|
expect(client.lastResumeLaunchContext).toEqual({
|
|
agentId: resumed.id,
|
|
env: {
|
|
PASEO_AGENT_ID: resumed.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("importProviderSession preserves the native model instead of applying the new-session default", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-model-"));
|
|
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const nativeModel = "native-selected-model";
|
|
|
|
class ImportClient extends TestAgentClient {
|
|
importConfig: AgentSessionConfig | undefined;
|
|
|
|
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
|
this.importConfig = context.config;
|
|
// A resumable provider applies explicit selections but otherwise retains its model.
|
|
const session = new TestAgentSession({
|
|
...context.config,
|
|
model: context.config.model ?? nativeModel,
|
|
});
|
|
return {
|
|
session,
|
|
config: context.storedConfig,
|
|
persistence: { provider: this.provider, sessionId: input.providerHandleId },
|
|
timeline: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
const client = new ImportClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
const imported = await manager.importProviderSession({
|
|
provider: "codex",
|
|
providerHandleId: "native-session",
|
|
cwd: workdir,
|
|
workspaceId: "import-model",
|
|
});
|
|
await manager.flush();
|
|
|
|
expect(imported.runtimeInfo?.model).toBe(nativeModel);
|
|
expect(imported.config.model).toBe(nativeModel);
|
|
expect(client.importConfig?.model).toBeUndefined();
|
|
expect((await storage.get(imported.id))?.config.model).toBe(nativeModel);
|
|
|
|
await manager.reloadAgentSession(imported.id);
|
|
expect(client.resumeOverrides.at(-1)?.model).toBe(nativeModel);
|
|
await manager.getAgent(imported.id)?.session?.close();
|
|
});
|
|
|
|
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const session = new TestAgentSession({ provider: "codex", cwd: workdir });
|
|
const events: AgentManagerEvent[] = [];
|
|
|
|
class ImportClient extends TestAgentClient {
|
|
listCalls = 0;
|
|
importInput: unknown = null;
|
|
importLaunchContext: AgentLaunchContext | undefined;
|
|
|
|
async listImportableSessions() {
|
|
this.listCalls += 1;
|
|
return [];
|
|
}
|
|
|
|
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
|
this.importInput = input;
|
|
this.importLaunchContext = context.launchContext;
|
|
return {
|
|
session,
|
|
config: { provider: "codex" as const, cwd: workdir },
|
|
persistence: {
|
|
provider: "codex" as const,
|
|
sessionId: input.providerHandleId,
|
|
nativeHandle: input.providerHandleId,
|
|
metadata: { provider: "codex", cwd: workdir },
|
|
},
|
|
timeline: [
|
|
{
|
|
item: { type: "user_message" as const, text: "Trace provider imports" },
|
|
timestamp: "2026-01-02T00:00:00.000Z",
|
|
},
|
|
{
|
|
item: { type: "assistant_message" as const, text: "Done" },
|
|
timestamp: "2026-01-02T00:00:01.000Z",
|
|
},
|
|
{
|
|
item: {
|
|
type: "tool_call" as const,
|
|
callId: "large-shell-result",
|
|
name: "shell",
|
|
status: "completed" as const,
|
|
error: null,
|
|
detail: {
|
|
type: "shell" as const,
|
|
command: "print output",
|
|
output: "x".repeat(1024 * 1024),
|
|
exitCode: 0,
|
|
},
|
|
},
|
|
timestamp: "2026-01-02T00:00:02.000Z",
|
|
},
|
|
],
|
|
providerSubagentEvents: [
|
|
{
|
|
type: "provider_subagent" as const,
|
|
provider: "codex" as const,
|
|
event: {
|
|
type: "upsert" as const,
|
|
id: "thread-child",
|
|
title: "Imported child",
|
|
status: "completed" as const,
|
|
},
|
|
},
|
|
{
|
|
type: "provider_subagent" as const,
|
|
provider: "codex" as const,
|
|
event: {
|
|
type: "timeline" as const,
|
|
id: "thread-child",
|
|
item: { type: "assistant_message" as const, text: "Child result" },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
const client = new ImportClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
manager.subscribe((event) => events.push(event), { replayState: false });
|
|
|
|
const imported = await manager.importProviderSession({
|
|
provider: "codex",
|
|
providerHandleId: "thread-selected",
|
|
cwd: workdir,
|
|
workspaceId: "ws-imported",
|
|
});
|
|
|
|
expect(client.listCalls).toBe(0);
|
|
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
|
|
expect(client.importLaunchContext).toEqual({
|
|
agentId: imported.id,
|
|
env: {
|
|
PASEO_AGENT_ID: imported.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
PASEO_WORKSPACE_ID: "ws-imported",
|
|
},
|
|
});
|
|
expect(imported.lifecycle).toBe("idle");
|
|
expect(imported.historyPrimed).toBe(true);
|
|
expect(manager.getTimeline(imported.id)).toEqual([
|
|
{ type: "user_message", text: "Trace provider imports" },
|
|
{ type: "assistant_message", text: "Done" },
|
|
limitAgentTimelineItemContent({
|
|
type: "tool_call",
|
|
callId: "large-shell-result",
|
|
name: "shell",
|
|
status: "completed",
|
|
error: null,
|
|
detail: {
|
|
type: "shell",
|
|
command: "print output",
|
|
output: "x".repeat(1024 * 1024),
|
|
exitCode: 0,
|
|
},
|
|
}),
|
|
]);
|
|
expect(manager.listProviderSubagents(imported.id)).toEqual([
|
|
expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }),
|
|
]);
|
|
expect(manager.fetchProviderSubagentTimeline(imported.id, "thread-child").rows).toEqual([
|
|
expect.objectContaining({ item: { type: "assistant_message", text: "Child result" } }),
|
|
]);
|
|
expect(events).toHaveLength(3);
|
|
expect(events[0]).toMatchObject({
|
|
type: "agent_state",
|
|
agent: {
|
|
id: imported.id,
|
|
lifecycle: "idle",
|
|
persistence: { nativeHandle: "thread-selected" },
|
|
},
|
|
});
|
|
expect((await storage.get(imported.id))?.title).toBe("Trace provider imports");
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
expect(client.lastCreateLaunchContext).toEqual({
|
|
agentId: snapshot.id,
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
},
|
|
});
|
|
|
|
await manager.reloadAgentSession(snapshot.id, {
|
|
systemPrompt: "reloaded prompt",
|
|
});
|
|
|
|
expect(client.lastResumeLaunchContext).toEqual({
|
|
agentId: snapshot.id,
|
|
env: {
|
|
PASEO_AGENT_ID: snapshot.id,
|
|
PASEO_AGENT_CWD: workdir,
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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 clears provider children before rehydrating from disk", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-reload-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let activeSession: TestAgentSession | null = null;
|
|
class ProviderChildClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
activeSession = new TestAgentSession(config);
|
|
return activeSession;
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new TestAgentSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? workdir,
|
|
});
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ProviderChildClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000116",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
activeSession?.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" },
|
|
});
|
|
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
|
|
|
|
await manager.reloadAgentSession(snapshot.id, undefined, { rehydrateFromDisk: true });
|
|
|
|
expect(manager.listProviderSubagents(snapshot.id)).toEqual([]);
|
|
});
|
|
|
|
test("reloadAgentSession terminalizes running provider children when preserving history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-hot-reload-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let activeSession: TestAgentSession | null = null;
|
|
class ProviderChildClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
activeSession = new TestAgentSession(config);
|
|
return activeSession;
|
|
}
|
|
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new TestAgentSession({
|
|
provider: "codex",
|
|
cwd: config?.cwd ?? workdir,
|
|
});
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ProviderChildClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000119",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
activeSession?.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: { type: "upsert", id: "running-child", title: "Running child", status: "running" },
|
|
});
|
|
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
|
|
|
|
await manager.reloadAgentSession(snapshot.id);
|
|
|
|
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
|
|
expect.objectContaining({ id: "running-child", status: "canceled" }),
|
|
]);
|
|
});
|
|
|
|
test("hydrateTimelineFromProvider restores and broadcasts provider children from session history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-history-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
class ProviderChildHistorySession extends TestAgentSession {
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: {
|
|
type: "upsert",
|
|
id: "restored-child",
|
|
title: "Restored child",
|
|
status: "completed",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
class ProviderChildHistoryClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ProviderChildHistorySession(config);
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ProviderChildHistoryClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000117",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), {
|
|
agentId: snapshot.id,
|
|
replayState: false,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true });
|
|
|
|
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
|
|
expect.objectContaining({
|
|
id: "restored-child",
|
|
parentAgentId: snapshot.id,
|
|
title: "Restored child",
|
|
status: "completed",
|
|
}),
|
|
]);
|
|
expect(manager.listProviderSubagentActivity()).toEqual([
|
|
expect.objectContaining({
|
|
id: "restored-child",
|
|
parentAgentId: snapshot.id,
|
|
}),
|
|
]);
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
event: {
|
|
type: "upsert",
|
|
subagent: expect.objectContaining({
|
|
id: "restored-child",
|
|
parentAgentId: snapshot.id,
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("force provider hydration removes children absent from current history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-force-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let session: TestAgentSession | null = null;
|
|
class ProviderChildForceClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
session = new TestAgentSession(config);
|
|
return session;
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ProviderChildForceClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000118",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
session?.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: { type: "upsert", id: "removed-by-rewind", status: "completed" },
|
|
});
|
|
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), {
|
|
agentId: snapshot.id,
|
|
replayState: false,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true });
|
|
|
|
expect(manager.listProviderSubagents(snapshot.id)).toEqual([]);
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
event: {
|
|
type: "remove",
|
|
parentAgentId: snapshot.id,
|
|
subagentId: "removed-by-rewind",
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("updateAgentMetadata bumps updatedAt for stored agents", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stored-metadata-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-000000000128",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.closeAgent(snapshot.id);
|
|
|
|
const closed = await storage.get(snapshot.id);
|
|
expect(closed).not.toBeNull();
|
|
const before = { ...closed!, labels: { surface: "mobile" } };
|
|
await storage.upsert(before);
|
|
expect(manager.getAgent(snapshot.id)).toBeNull();
|
|
|
|
const upsertSpy = vi.spyOn(storage, "upsert");
|
|
|
|
await manager.updateAgentMetadata(snapshot.id, {
|
|
title: "Stored title",
|
|
labels: { role: "worker" },
|
|
});
|
|
|
|
expect(upsertSpy).toHaveBeenCalledTimes(1);
|
|
const after = await storage.get(snapshot.id);
|
|
expect(after?.title).toBe("Stored title");
|
|
expect(after?.labels).toEqual({ surface: "mobile", role: "worker" });
|
|
expect(Date.parse(after!.updatedAt)).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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
|
|
class ModelSession extends TestAgentSession {
|
|
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
|
|
async setModel(model: string | null) {
|
|
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
|
|
this.pushEvent({
|
|
type: "thinking_option_changed",
|
|
provider: "codex",
|
|
thinkingOptionId: null,
|
|
});
|
|
}
|
|
override async getRuntimeInfo() {
|
|
return { ...(await super.getRuntimeInfo()), ...this.selection };
|
|
}
|
|
describePersistence() {
|
|
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
|
|
}
|
|
}
|
|
class ModelClient extends TestAgentClient {
|
|
async createSession(config: AgentSessionConfig) {
|
|
return new ModelSession(config);
|
|
}
|
|
}
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ModelClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
try {
|
|
const agent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.setAgentModel(agent.id, "target");
|
|
await manager.flush();
|
|
const stored = await storage.get(agent.id);
|
|
expect(stored?.config?.model).toBe("target");
|
|
expect(stored?.config?.thinkingOptionId).toBeUndefined();
|
|
expect(stored?.persistence?.metadata?.model).toBe("target");
|
|
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
|
|
} finally {
|
|
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
|
class ConfigMutationSession extends TestAgentSession {
|
|
async setModel(): Promise<void> {
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "model changed" },
|
|
});
|
|
this.pushEvent({
|
|
type: "thinking_option_changed",
|
|
provider: "codex",
|
|
thinkingOptionId: "low",
|
|
});
|
|
}
|
|
|
|
async setThinkingOption(): Promise<void> {}
|
|
}
|
|
class ConfigMutationClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ConfigMutationSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ConfigMutationClient() },
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000134",
|
|
});
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
model: "gpt-5.2-codex",
|
|
thinkingOptionId: "off",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.setAgentModel(snapshot.id, "gpt-5.4");
|
|
await manager.setAgentThinkingOption(snapshot.id, "high");
|
|
await manager.flush();
|
|
|
|
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
|
|
});
|
|
|
|
test.each([
|
|
{ requested: "unavailable", effective: "source" },
|
|
{ requested: "alias", effective: "canonical" },
|
|
{ requested: null, effective: null },
|
|
])(
|
|
"setAgentModel stores effective selections and preserves resets for $requested",
|
|
async ({ requested, effective }) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-effective-model-"));
|
|
class SelectingSession extends TestAgentSession {
|
|
private model: string | null = "source";
|
|
|
|
async setModel(model: string | null) {
|
|
if (model && model !== "unavailable") this.model = model === "alias" ? "canonical" : model;
|
|
return { model: this.model };
|
|
}
|
|
|
|
override async getRuntimeInfo() {
|
|
return { ...(await super.getRuntimeInfo()), model: this.model };
|
|
}
|
|
}
|
|
class SelectingClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new SelectingSession(config);
|
|
}
|
|
}
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new SelectingClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
try {
|
|
const snapshot = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, model: "source" },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.setAgentModel(snapshot.id, requested);
|
|
await manager.flush();
|
|
|
|
expect(manager.getAgent(snapshot.id)?.config.model).toBe(effective ?? undefined);
|
|
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe(effective);
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.config?.model).toBe(effective ?? undefined);
|
|
expect(persisted?.runtimeInfo?.model).toBe(effective);
|
|
|
|
await manager.setAgentModel(snapshot.id, "target");
|
|
expect(manager.getAgent(snapshot.id)?.config.model).toBe("target");
|
|
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe("target");
|
|
} finally {
|
|
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
|
|
class ClampingSession extends TestAgentSession {
|
|
async setThinkingOption(): Promise<void> {}
|
|
|
|
override async getRuntimeInfo() {
|
|
return {
|
|
...(await super.getRuntimeInfo()),
|
|
thinkingOptionId: "high",
|
|
};
|
|
}
|
|
}
|
|
class ClampingClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ClampingSession(config);
|
|
}
|
|
}
|
|
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ClampingClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000135",
|
|
});
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
thinkingOptionId: "low",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.setAgentThinkingOption(snapshot.id, "medium");
|
|
await manager.flush();
|
|
|
|
const agent = manager.getAgent(snapshot.id);
|
|
expect(agent?.config.thinkingOptionId).toBe("high");
|
|
expect(agent?.runtimeInfo?.thinkingOptionId).toBe("high");
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.config?.thinkingOptionId).toBe("high");
|
|
});
|
|
|
|
test("setAgentThinkingOption surfaces a failed state read and keeps the previous level", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-read-failure-"));
|
|
class UnreadableStateSession extends TestAgentSession {
|
|
async setThinkingOption(): Promise<void> {}
|
|
|
|
override async getRuntimeInfo(): Promise<never> {
|
|
throw new Error("state unavailable");
|
|
}
|
|
}
|
|
class UnreadableStateClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new UnreadableStateSession(config);
|
|
}
|
|
}
|
|
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new UnreadableStateClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
|
});
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
thinkingOptionId: "low",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await expect(manager.setAgentThinkingOption(snapshot.id, "medium")).rejects.toThrow(
|
|
"state unavailable",
|
|
);
|
|
await manager.flush();
|
|
|
|
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.config?.thinkingOptionId).toBe("low");
|
|
});
|
|
|
|
test("session config drift events update state through the stream channel", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
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(),
|
|
},
|
|
registry: storage,
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
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?.config.modeId).toBe("build");
|
|
expect(agent?.config.thinkingOptionId).toBe("high");
|
|
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([]);
|
|
|
|
const persisted = await storage.get(snapshot.id);
|
|
expect(persisted?.lastModeId).toBe("build");
|
|
expect(persisted?.config?.modeId).toBe("build");
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Child",
|
|
},
|
|
undefined,
|
|
{
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
|
team: "infra",
|
|
},
|
|
workspaceId: undefined,
|
|
},
|
|
);
|
|
const emittedLabels: Array<Record<string, string>> = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === child.id) {
|
|
emittedLabels.push(event.agent.labels);
|
|
}
|
|
},
|
|
{ agentId: child.id, replayState: false },
|
|
);
|
|
|
|
const result = await manager.detachAgent(child.id);
|
|
await manager.flush();
|
|
unsubscribe();
|
|
|
|
expect(result.previousParentAgentId).toBe(parent.id);
|
|
expect(result.live).toBe(true);
|
|
expect(result.record.labels).toEqual({ team: "infra" });
|
|
expect(manager.getAgent(child.id)?.labels).toEqual({ team: "infra" });
|
|
expect((await storage.get(child.id))?.labels).toEqual({ team: "infra" });
|
|
expect(emittedLabels).toContainEqual({ team: "infra" });
|
|
});
|
|
|
|
test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Stored child",
|
|
},
|
|
undefined,
|
|
{
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
|
role: "reviewer",
|
|
},
|
|
workspaceId: undefined,
|
|
},
|
|
);
|
|
await manager.closeAgent(child.id);
|
|
|
|
const result = await manager.detachAgent(child.id);
|
|
|
|
expect(result.previousParentAgentId).toBe(parent.id);
|
|
expect(result.live).toBe(false);
|
|
expect(result.record.labels).toEqual({ role: "reviewer" });
|
|
expect((await storage.get(child.id))?.labels).toEqual({ role: "reviewer" });
|
|
});
|
|
|
|
test("archiveAgent does not cascade to a detached former child", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-cascade-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
|
|
await manager.detachAgent(child.id);
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
|
|
expect((await storage.get(child.id))?.archivedAt).toBeFalsy();
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("closed");
|
|
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("closed");
|
|
expect(persisted?.requiresAttention).toBe(false);
|
|
expect(persisted?.attentionReason).toBeNull();
|
|
expect(persisted?.attentionTimestamp).toBeNull();
|
|
});
|
|
|
|
test("archiveSnapshot dispatches archived state for stored-only agents", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-snapshot-dispatch-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const created = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Stored archive dispatch",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.closeAgent(created.id);
|
|
|
|
const events: ManagedAgent[] = [];
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === created.id) {
|
|
events.push(event.agent);
|
|
}
|
|
},
|
|
{ agentId: created.id, replayState: false },
|
|
);
|
|
|
|
await manager.archiveSnapshot(created.id, new Date().toISOString());
|
|
|
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
const last = events[events.length - 1];
|
|
expect(last.id).toBe(created.id);
|
|
expect(last.lifecycle).toBe("closed");
|
|
});
|
|
|
|
test("markAgentUnread restores error attention on a read errored agent", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-error-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
class ErrorClient extends TestAgentClient {
|
|
readonly session = new TestAgentSession({ provider: "codex", cwd: workdir });
|
|
|
|
override async createSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
}
|
|
const client = new ErrorClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
onTestFinished(async () => {
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const running = waitForAgentLifecycle(manager, agent.id, "running");
|
|
client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" });
|
|
await running;
|
|
const failed = waitForAgentLifecycle(manager, agent.id, "error");
|
|
client.session.pushEvent({
|
|
type: "turn_failed",
|
|
provider: "codex",
|
|
turnId: "failed-turn",
|
|
error: "Selected model is at capacity.",
|
|
});
|
|
await failed;
|
|
await manager.clearAgentAttention(agent.id);
|
|
|
|
await manager.markAgentUnread(agent.id);
|
|
|
|
expect(manager.getAgent(agent.id)?.attention).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
expect(await storage.get(agent.id)).toMatchObject({
|
|
lastStatus: "error",
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
});
|
|
});
|
|
|
|
test("markAgentUnread dispatches stored attention to every subscriber", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const created = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Stored unread dispatch" },
|
|
undefined,
|
|
{ workspaceId: "workspace-1" },
|
|
);
|
|
await manager.closeAgent(created.id);
|
|
|
|
const firstClientEvents: ManagedAgent[] = [];
|
|
const secondClientEvents: ManagedAgent[] = [];
|
|
for (const events of [firstClientEvents, secondClientEvents]) {
|
|
manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === created.id) {
|
|
events.push(event.agent);
|
|
}
|
|
},
|
|
{ replayState: false },
|
|
);
|
|
}
|
|
|
|
await manager.markAgentUnread(created.id);
|
|
|
|
for (const events of [firstClientEvents, secondClientEvents]) {
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({
|
|
id: created.id,
|
|
lifecycle: "closed",
|
|
workspaceId: "workspace-1",
|
|
attention: {
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: expect.any(Date),
|
|
},
|
|
});
|
|
}
|
|
expect(await storage.get(created.id)).toMatchObject({
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: expect.any(String),
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "one",
|
|
text: "one",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "two",
|
|
text: "two",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "three",
|
|
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);
|
|
|
|
const older = manager.fetchTimeline(snapshot.id, {
|
|
direction: "before",
|
|
cursor: {
|
|
epoch: result.epoch,
|
|
seq: result.rows[0]?.seq ?? 0,
|
|
},
|
|
limit: 1,
|
|
});
|
|
|
|
expect(older.reset).toBe(false);
|
|
expect(older.rows).toHaveLength(1);
|
|
expect(older.rows[0]?.seq).toBe(2);
|
|
expect(older.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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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: 2,
|
|
seqStart: 1,
|
|
seqEnd: 2,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
|
collapsed: ["assistant_merge"],
|
|
timestamp: expect.any(String),
|
|
item: { type: "assistant_message", text: "row onerow 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "user_message",
|
|
text: "hello boundary",
|
|
messageId: "msg-boundary-1",
|
|
});
|
|
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("streams coalesced assistant chunks and retains the projected message", 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// The coalescer flushes the first chunk on the leading edge, so "final " ships
|
|
// as its own event and "reply" follows on the trailing window. History retains
|
|
// their complete projected assistant message.
|
|
const assistantTimelineEvents = streamEvents.filter(
|
|
(event) => event.itemType === "assistant_message",
|
|
);
|
|
expect(assistantTimelineEvents).toHaveLength(2);
|
|
expect(assistantTimelineEvents[0]).toMatchObject({
|
|
eventType: "timeline",
|
|
itemType: "assistant_message",
|
|
text: "final ",
|
|
seq: 1,
|
|
epoch: expect.any(String),
|
|
});
|
|
expect(assistantTimelineEvents[1]).toMatchObject({
|
|
eventType: "timeline",
|
|
itemType: "assistant_message",
|
|
text: "reply",
|
|
seq: 2,
|
|
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(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 });
|
|
expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch);
|
|
expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "first",
|
|
text: "first",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "second",
|
|
text: "second",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "third",
|
|
text: "third",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "fourth",
|
|
text: "fourth",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "fifth",
|
|
text: "fifth",
|
|
});
|
|
|
|
const result = await manager.fetchTimeline(snapshot.id, {
|
|
direction: "before",
|
|
cursor: {
|
|
epoch: manager.fetchTimeline(snapshot.id).epoch,
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "first",
|
|
text: "first",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "second",
|
|
text: "second",
|
|
});
|
|
await manager.appendTimelineItem(snapshot.id, {
|
|
type: "assistant_message",
|
|
messageId: "third",
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
|
|
|
expect(manager.getTimeline(snapshot.id)).toEqual([
|
|
{ type: "assistant_message", text: "chunk one 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
),
|
|
).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",
|
|
{ workspaceId: undefined },
|
|
),
|
|
).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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("waitForAgentRunStart ignores a prior turn error while the next run starts", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-error-resume-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const retryStartEntered = deferred<void>();
|
|
const releaseRetryStart = deferred<void>();
|
|
|
|
class ErrorThenResumeSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = `turn-${++this.turnIdCounter}`;
|
|
if (this.turnIdCounter === 1) {
|
|
void (async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "turn_failed",
|
|
provider: this.provider,
|
|
turnId,
|
|
error: "model at capacity",
|
|
});
|
|
})();
|
|
return { turnId };
|
|
}
|
|
|
|
retryStartEntered.resolve();
|
|
await releaseRetryStart.promise;
|
|
void (async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
})();
|
|
return { turnId };
|
|
}
|
|
}
|
|
|
|
class ErrorThenResumeClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new ErrorThenResumeSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new ErrorThenResumeClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
let agentId: string | null = null;
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = snapshot.id;
|
|
|
|
await manager.runAgent(agentId, "fail").catch(() => undefined);
|
|
expect(manager.getAgent(agentId)?.lifecycle).toBe("error");
|
|
|
|
const dispatch = await startAgentRun(manager, agentId, "resume", logger);
|
|
expect(dispatch.disposition).toBe("turn_started");
|
|
const wait = manager.waitForAgentRunStart(agentId);
|
|
let earlyResult: "pending" | "resolved" | "rejected" = "pending";
|
|
void wait.then(
|
|
() => {
|
|
earlyResult = "resolved";
|
|
return earlyResult;
|
|
},
|
|
() => {
|
|
earlyResult = "rejected";
|
|
return earlyResult;
|
|
},
|
|
);
|
|
await retryStartEntered.promise;
|
|
await Promise.resolve();
|
|
|
|
expect(earlyResult).toBe("pending");
|
|
releaseRetryStart.resolve();
|
|
await expect(wait).resolves.toBeUndefined();
|
|
await manager.waitForAgentEvent(agentId);
|
|
} finally {
|
|
releaseRetryStart.resolve();
|
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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 = await 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>();
|
|
const allowSecondTurnToComplete = 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(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
await allowSecondTurnToComplete.promise;
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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", {
|
|
clientMessageId: "first-client-message",
|
|
});
|
|
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 secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run", {
|
|
clientMessageId: "replacement-client-message",
|
|
});
|
|
|
|
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();
|
|
|
|
const secondRun = await secondRunPromise;
|
|
const secondRunDrain = (async () => {
|
|
for await (const _event of secondRun) {
|
|
// Drain replacement run.
|
|
}
|
|
})();
|
|
await secondStartEntered.promise;
|
|
|
|
const replaceGapSnapshot = manager.getAgent(snapshot.id) as
|
|
| { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string }
|
|
| undefined;
|
|
expect(replaceGapSnapshot?.pendingReplacement).toBe(true);
|
|
expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull();
|
|
expect(replaceGapSnapshot?.lifecycle).toBe("running");
|
|
|
|
const replacementStart = manager.waitForAgentRunStart(snapshot.id);
|
|
const prematureStart = await Promise.race([
|
|
replacementStart.then(() => "resolved"),
|
|
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
|
|
]);
|
|
expect(prematureStart).toBe("pending");
|
|
|
|
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 replacementStart;
|
|
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(
|
|
manager
|
|
.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 })
|
|
.rows.find(
|
|
(row) =>
|
|
row.item.type === "user_message" &&
|
|
row.item.clientMessageId === "replacement-client-message",
|
|
)?.item,
|
|
).toMatchObject({ messageId: "replacement-client-message" });
|
|
allowSecondTurnToComplete.resolve();
|
|
await firstRunDrain;
|
|
await secondRunDrain;
|
|
unsubscribe();
|
|
});
|
|
|
|
test("applies live autonomous events and preserves usage omitted from completion", 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
});
|
|
await vi.waitFor(() => {
|
|
const running = manager.getAgent(snapshot.id);
|
|
expect(running?.lifecycle).toBe("running");
|
|
expect(running ? toAgentPayload(running).activeTurn : null).toEqual({
|
|
turnId: autonomousTurnId,
|
|
startedAt: expect.any(String),
|
|
});
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "usage_updated",
|
|
provider: "codex",
|
|
usage: {
|
|
inputTokens: 10,
|
|
contextWindowMaxTokens: 200_000,
|
|
contextWindowUsedTokens: 175,
|
|
},
|
|
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(updated ? toAgentPayload(updated).activeTurn : null).toBeNull();
|
|
expect(updated?.lastUsage).toEqual({
|
|
inputTokens: 10,
|
|
contextWindowMaxTokens: 200_000,
|
|
contextWindowUsedTokens: 175,
|
|
});
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "AUTONOMOUS_PUMP_MESSAGE",
|
|
});
|
|
expect(lifecycleUpdates).toContain("running");
|
|
expect(lifecycleUpdates).toContain("idle");
|
|
});
|
|
|
|
test("ignores stale autonomous terminals without lowering the active turn lifecycle", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-autonomous-terminal-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), 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-000000000135",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
capturedSession!.pushEvent({
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: "prior-untracked-turn",
|
|
usage: { inputTokens: 7 },
|
|
});
|
|
capturedSession!.pushEvent({
|
|
type: "turn_failed",
|
|
provider: "codex",
|
|
turnId: "prior-untracked-turn",
|
|
error: "turn-b marker",
|
|
});
|
|
await vi.waitFor(() => {
|
|
const priorFailure = manager.getAgent(snapshot.id);
|
|
expect(priorFailure?.lastError).toBe("turn-b marker");
|
|
expect(priorFailure?.lastUsage).toEqual({ inputTokens: 7 });
|
|
});
|
|
|
|
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-b" });
|
|
await vi.waitFor(() => {
|
|
const running = manager.getAgent(snapshot.id);
|
|
expect(running?.lifecycle).toBe("running");
|
|
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b");
|
|
expect(running?.lastError).toBeUndefined();
|
|
});
|
|
|
|
const active = manager.getAgent(snapshot.id)!;
|
|
active.pendingPermissions.set("turn-b-permission", {
|
|
id: "turn-b-permission",
|
|
provider: "codex",
|
|
name: "Current turn permission",
|
|
kind: "tool",
|
|
});
|
|
const timelineBeforeStaleTerminals = manager.getTimeline(snapshot.id);
|
|
|
|
const staleTerminals: AgentStreamEvent[] = [
|
|
{
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: "turn-a",
|
|
usage: { inputTokens: 99 },
|
|
},
|
|
{ type: "turn_failed", provider: "codex", turnId: "turn-a", error: "late failure" },
|
|
{ type: "turn_canceled", provider: "codex", turnId: "turn-a", reason: "late cancel" },
|
|
];
|
|
for (const terminal of staleTerminals) {
|
|
const processed = new Promise<void>((resolve) => {
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_stream" &&
|
|
event.agentId === snapshot.id &&
|
|
event.event.type === terminal.type
|
|
) {
|
|
unsubscribe();
|
|
resolve();
|
|
}
|
|
},
|
|
{ agentId: snapshot.id, replayState: false },
|
|
);
|
|
});
|
|
capturedSession!.pushEvent(terminal);
|
|
await processed;
|
|
const stillRunning = manager.getAgent(snapshot.id);
|
|
expect(stillRunning?.lifecycle).toBe("running");
|
|
expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b");
|
|
expect(stillRunning?.lastError).toBeUndefined();
|
|
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
|
|
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
|
|
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
|
|
}
|
|
|
|
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-b" });
|
|
await vi.waitFor(() => {
|
|
const settled = manager.getAgent(snapshot.id);
|
|
expect(settled?.lifecycle).toBe("idle");
|
|
expect(settled ? toAgentPayload(settled).activeTurn : null).toBeNull();
|
|
});
|
|
});
|
|
|
|
test("preserves terminal fallback when no active turn identity was observed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), 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-000000000136",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
capturedSession!.pushEvent({
|
|
type: "turn_failed",
|
|
provider: "codex",
|
|
turnId: "untracked-turn",
|
|
error: "untracked failure",
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
const failed = manager.getAgent(snapshot.id);
|
|
expect(failed?.lifecycle).toBe("error");
|
|
expect(failed?.lastError).toBe("untracked failure");
|
|
});
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "assistant_message",
|
|
text: expect.stringContaining("untracked failure"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", 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;
|
|
readonly interruptCalled = deferred<void>();
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCount += 1;
|
|
this.interruptCalled.resolve(undefined);
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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();
|
|
|
|
let cancelSettled = false;
|
|
const cancelPromise = manager.cancelAgentRun(snapshot.id).finally(() => {
|
|
cancelSettled = true;
|
|
});
|
|
await capturedSession.interruptCalled.promise;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(cancelSettled).toBe(false);
|
|
expect(client.lastSession?.interruptCount).toBe(1);
|
|
|
|
capturedSession.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: "codex",
|
|
turnId: "autonomous-cancel-1",
|
|
reason: "interrupted",
|
|
});
|
|
|
|
await expect(cancelPromise).resolves.toEqual({ status: "settled" });
|
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
|
});
|
|
|
|
test("failed replacement cancellation preserves an autonomous running state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-replace-rejected-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
|
|
class RejectingLiveInterruptSession extends TestAgentSession {
|
|
override async interrupt(): Promise<void> {
|
|
throw new Error("provider still owns the autonomous turn");
|
|
}
|
|
}
|
|
|
|
class RejectingLiveInterruptClient extends TestAgentClient {
|
|
readonly session = new RejectingLiveInterruptSession({
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
});
|
|
|
|
override async createSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
const client = new RejectingLiveInterruptClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
rescueTimeouts: { interruptSessionMs: 10 },
|
|
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
|
});
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const running = waitForAgentLifecycle(manager, agent.id, "running");
|
|
|
|
client.session.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: "autonomous-replace-1",
|
|
});
|
|
await running;
|
|
|
|
await expect(manager.replaceAgentRun(agent.id, "replacement prompt")).rejects.toThrow(
|
|
`Cannot replace agent ${agent.id} because its active run cancellation was not acknowledged`,
|
|
);
|
|
expect(manager.getAgent(agent.id)).toMatchObject({
|
|
lifecycle: "running",
|
|
activeForegroundTurnId: null,
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
});
|
|
await vi.waitFor(() => {
|
|
const running = manager.getAgent(snapshot.id);
|
|
expect(running?.lifecycle).toBe("running");
|
|
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("fg-turn-1");
|
|
});
|
|
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,
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
|
type: "assistant_message",
|
|
text: "AUTONOMOUS_DURING_FOREGROUND",
|
|
}),
|
|
);
|
|
const stillForeground = manager.getAgent(snapshot.id);
|
|
expect(stillForeground?.lifecycle).toBe("running");
|
|
expect(stillForeground ? toAgentPayload(stillForeground).activeTurn?.turnId : null).toBe(
|
|
"fg-turn-1",
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
|
try {
|
|
await manager.appendTimelineItem(snapshot.id, { type: "user_message", text: "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("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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
// Create an internal agent
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const agents = manager.listAgents();
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0]?.config.title).toBe("Normal Agent");
|
|
|
|
// Opting in lists both, and the internal one is marked.
|
|
const everyAgent = manager.listAgents({ includeInternal: true });
|
|
expect(everyAgent.map((agent) => [agent.config.title, agent.internal])).toEqual([
|
|
["Normal Agent", false],
|
|
["Internal Agent", true],
|
|
]);
|
|
});
|
|
|
|
test("workspace ownership does not turn an ordinary agent into a disposable helper", async () => {
|
|
const agentId = "00000000-0000-4000-8000-000000000115";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const beforeHookCalls: string[] = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => agentId,
|
|
pluginLifecycle: {
|
|
emit: () => {},
|
|
before: async (name, request) => {
|
|
beforeHookCalls.push(name);
|
|
return request;
|
|
},
|
|
},
|
|
});
|
|
|
|
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Helper" }, undefined, {
|
|
workspaceId: "wks_internal",
|
|
});
|
|
|
|
expect(manager.getAgent(agentId)?.internal).toBe(false);
|
|
expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]);
|
|
expect(await storage.get(agentId)).not.toBeNull();
|
|
// Workspace ownership preserves ordinary agent lifecycle hooks.
|
|
expect(beforeHookCalls).toContain("agent.create.before");
|
|
});
|
|
|
|
test("global subscribers receive internal agent events only when they opt in", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000116";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), 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 },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const hidden: string[] = [];
|
|
const optedIn: string[] = [];
|
|
const unsubscribeHidden = manager.subscribe((event) => {
|
|
if (event.type === "agent_state") hidden.push(event.agent.id);
|
|
});
|
|
const unsubscribeOptedIn = manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state") optedIn.push(event.agent.id);
|
|
},
|
|
{ includeInternal: true },
|
|
);
|
|
|
|
expect(hidden).toEqual([]);
|
|
expect(optedIn).toEqual([internalAgentId]);
|
|
|
|
await manager.archiveAgent(internalAgentId);
|
|
expect(hidden).toEqual([]);
|
|
expect(optedIn.length).toBeGreaterThan(1);
|
|
unsubscribeHidden();
|
|
unsubscribeOptedIn();
|
|
});
|
|
|
|
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
|
|
const manager = new AgentManager({ clients: {}, logger });
|
|
const removedId = "00000000-0000-4000-8000-000000000901";
|
|
const internalId = "00000000-0000-4000-8000-000000000902";
|
|
const otherId = "00000000-0000-4000-8000-000000000903";
|
|
const publicEvents: AgentManagerEvent[] = [];
|
|
const allEvents: AgentManagerEvent[] = [];
|
|
const scopedEvents: AgentManagerEvent[] = [];
|
|
const internalEvents: AgentManagerEvent[] = [];
|
|
const unrelatedEvents: AgentManagerEvent[] = [];
|
|
const releases = [
|
|
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
|
|
manager.subscribe((event) => allEvents.push(event), {
|
|
replayState: false,
|
|
includeInternal: true,
|
|
}),
|
|
manager.subscribe((event) => scopedEvents.push(event), {
|
|
replayState: false,
|
|
agentId: removedId,
|
|
}),
|
|
manager.subscribe((event) => internalEvents.push(event), {
|
|
replayState: false,
|
|
agentId: internalId,
|
|
}),
|
|
manager.subscribe((event) => unrelatedEvents.push(event), {
|
|
replayState: false,
|
|
agentId: otherId,
|
|
}),
|
|
];
|
|
try {
|
|
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
|
|
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
|
|
const publicRemoval = {
|
|
type: "agent_removed",
|
|
agentId: removedId,
|
|
workspaceId: "workspace",
|
|
internal: false,
|
|
};
|
|
const internalRemoval = {
|
|
type: "agent_removed",
|
|
agentId: internalId,
|
|
workspaceId: "workspace",
|
|
internal: true,
|
|
};
|
|
expect(publicEvents).toEqual([publicRemoval]);
|
|
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
|
|
expect(scopedEvents).toEqual([publicRemoval]);
|
|
expect(internalEvents).toEqual([internalRemoval]);
|
|
expect(unrelatedEvents).toEqual([]);
|
|
} finally {
|
|
for (const release of releases) release();
|
|
}
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const agent = manager.getAgent(internalAgentId);
|
|
expect(agent).not.toBeNull();
|
|
expect(agent?.internal).toBe(true);
|
|
});
|
|
|
|
test("archiveAgent closes an internal agent without writing a record", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000108";
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const { archivedAt } = await manager.archiveAgent(internalAgentId);
|
|
|
|
expect(Number.isNaN(Date.parse(archivedAt))).toBe(false);
|
|
expect(manager.getAgent(internalAgentId)).toBeNull();
|
|
expect(await storage.get(internalAgentId)).toBeNull();
|
|
expect(await storage.listAll()).toEqual([]);
|
|
expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({
|
|
record: expect.objectContaining({
|
|
id: internalAgentId,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
archivedAt,
|
|
lastStatus: "idle",
|
|
}),
|
|
lastMessage: null,
|
|
});
|
|
expect(manager.getRetiredInternalAgent("00000000-0000-4000-8000-000000000999")).toBeNull();
|
|
});
|
|
|
|
test("archiveAgent on an internal parent still archives its attached children", async () => {
|
|
const parentAgentId = "00000000-0000-4000-8000-000000000109";
|
|
const childAgentId = "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 ids = [parentAgentId, childAgentId];
|
|
let agentCounter = 0;
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => ids[agentCounter++] ?? randomUUID(),
|
|
});
|
|
|
|
await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Internal Parent", internal: true },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Child" }, undefined, {
|
|
workspaceId: undefined,
|
|
labels: { "paseo.parent-agent-id": parentAgentId },
|
|
});
|
|
|
|
await manager.archiveAgent(parentAgentId);
|
|
|
|
expect(manager.getAgent(parentAgentId)).toBeNull();
|
|
expect(manager.getAgent(childAgentId)).toBeNull();
|
|
const child = await storage.get(childAgentId);
|
|
expect(child?.archivedAt).toEqual(expect.any(String));
|
|
expect(await storage.get(parentAgentId)).toBeNull();
|
|
});
|
|
|
|
test("archiveAgent on a parent archives its live internal children", async () => {
|
|
const parentAgentId = "00000000-0000-4000-8000-000000000111";
|
|
const childAgentId = "00000000-0000-4000-8000-000000000112";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const ids = [parentAgentId, childAgentId];
|
|
let agentCounter = 0;
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => ids[agentCounter++] ?? randomUUID(),
|
|
});
|
|
|
|
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Parent" }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
// An internal child is never in storage, so only the live scan can reach it.
|
|
await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Internal Child", internal: true },
|
|
undefined,
|
|
{ workspaceId: undefined, labels: { "paseo.parent-agent-id": parentAgentId } },
|
|
);
|
|
expect(manager.getAgent(childAgentId)).not.toBeNull();
|
|
|
|
await manager.archiveAgent(parentAgentId);
|
|
|
|
expect(manager.getAgent(parentAgentId)).toBeNull();
|
|
expect(manager.getAgent(childAgentId)).toBeNull();
|
|
expect(manager.getRetiredInternalAgent(childAgentId)?.record.archivedAt).toEqual(
|
|
expect.any(String),
|
|
);
|
|
expect(await storage.get(childAgentId)).toBeNull();
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
// Create an internal agent - should NOT emit to global subscriber
|
|
await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Internal Agent",
|
|
internal: true,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
// 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 hides provider subagents of internal parents from global subscribers", async () => {
|
|
const internalAgentId = "00000000-0000-4000-8000-000000000117";
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-internal-provider-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const sessionHolder: { current: TestAgentSession | null } = { current: null };
|
|
class InternalProviderChildClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
sessionHolder.current = new TestAgentSession(config);
|
|
return sessionHolder.current;
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new InternalProviderChildClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => internalAgentId,
|
|
});
|
|
const globalEvents: AgentManagerEvent[] = [];
|
|
const scopedEvents: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => globalEvents.push(event), { replayState: false });
|
|
await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Internal Agent", internal: true },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
manager.subscribe((event) => scopedEvents.push(event), {
|
|
agentId: internalAgentId,
|
|
replayState: false,
|
|
});
|
|
|
|
sessionHolder.current?.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: { type: "upsert", id: "hidden-child", title: "Hidden child", status: "running" },
|
|
});
|
|
await manager.flush();
|
|
|
|
expect(globalEvents.filter((event) => event.type === "provider_subagent")).toEqual([]);
|
|
expect(scopedEvents).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "provider_subagent",
|
|
event: expect.objectContaining({
|
|
type: "upsert",
|
|
subagent: expect.objectContaining({
|
|
id: "hidden-child",
|
|
parentAgentId: internalAgentId,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
expect(() => manager.listProviderSubagents(internalAgentId)).toThrow(
|
|
`Unknown agent '${internalAgentId}'`,
|
|
);
|
|
expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow(
|
|
`Unknown agent '${internalAgentId}'`,
|
|
);
|
|
expect(() => manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child")).toThrow(
|
|
`Unknown agent '${internalAgentId}'`,
|
|
);
|
|
expect(manager.listProviderSubagentActivity()).toEqual([]);
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
// 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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
// 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("onAgentAttention is not called for delegated child agents", async () => {
|
|
const childAgentId = "00000000-0000-4000-8000-000000000112";
|
|
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: () => childAgentId,
|
|
onAgentAttention: ({ agentId }) => {
|
|
attentionCalls.push(agentId);
|
|
},
|
|
});
|
|
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Delegated Child Agent",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, workspaceId: undefined },
|
|
);
|
|
|
|
await manager.runAgent(agent.id, "hello");
|
|
|
|
expect(attentionCalls).toEqual([]);
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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.each([
|
|
["autonomous", "turn_completed", "idle", "finished", undefined],
|
|
["autonomous", "turn_failed", "error", "error", "Retry failed."],
|
|
["foreground", "turn_completed", "idle", "finished", undefined],
|
|
["foreground", "turn_failed", "error", "error", "Retry failed."],
|
|
] as const)(
|
|
"%s recovery clears error attention before %s",
|
|
async (mode, terminal, status, expectedReason, lastError) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-recovery-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
class RecoverySession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
return { turnId: "recovered-turn" };
|
|
}
|
|
}
|
|
|
|
class RecoveryClient extends TestAgentClient {
|
|
readonly session = new RecoverySession({ provider: "codex", cwd: workdir });
|
|
|
|
override async createSession(): Promise<AgentSession> {
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
const client = new RecoveryClient();
|
|
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
onAgentAttention: ({ reason }) => attentionReasons.push(reason),
|
|
});
|
|
onTestFinished(async () => {
|
|
await manager.closeAgent(agent.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
const running = waitForAgentLifecycle(manager, agent.id, "running");
|
|
client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" });
|
|
await running;
|
|
const failed = waitForAgentLifecycle(manager, agent.id, "error");
|
|
client.session.pushEvent({
|
|
type: "turn_failed",
|
|
provider: "codex",
|
|
turnId: "failed-turn",
|
|
error: "Selected model is at capacity.",
|
|
});
|
|
await failed;
|
|
await vi.waitFor(() => expect(manager.hasInFlightRun(agent.id)).toBe(false));
|
|
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
|
status: "error",
|
|
attentionReason: "error",
|
|
lastError: "Selected model is at capacity.",
|
|
});
|
|
|
|
const recovered = waitForAgentLifecycle(manager, agent.id, "running");
|
|
const stream =
|
|
mode === "foreground"
|
|
? drainAsyncGenerator(manager.streamAgent(agent.id, "Continue after capacity recovers."))
|
|
: null;
|
|
if (mode === "autonomous") {
|
|
client.session.pushEvent({
|
|
type: "turn_started",
|
|
provider: "codex",
|
|
turnId: "recovered-turn",
|
|
});
|
|
}
|
|
await recovered;
|
|
await manager.flush();
|
|
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
|
status: "running",
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
});
|
|
expect(manager.getAgent(agent.id)?.lastError).toBeUndefined();
|
|
expect(await storage.get(agent.id)).toMatchObject({
|
|
lastStatus: "running",
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
});
|
|
expect((await storage.get(agent.id))?.lastError).toBeUndefined();
|
|
|
|
const settled = waitForAgentLifecycle(manager, agent.id, status);
|
|
client.session.pushEvent({
|
|
type: terminal,
|
|
provider: "codex",
|
|
turnId: "recovered-turn",
|
|
...(terminal === "turn_failed" ? { error: "Retry failed." } : {}),
|
|
});
|
|
await settled;
|
|
await stream;
|
|
await manager.flush();
|
|
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
|
status,
|
|
requiresAttention: true,
|
|
attentionReason: expectedReason,
|
|
});
|
|
expect(manager.getAgent(agent.id)?.lastError).toBe(lastError);
|
|
expect(await storage.get(agent.id)).toMatchObject({
|
|
lastStatus: status,
|
|
requiresAttention: true,
|
|
attentionReason: expectedReason,
|
|
});
|
|
expect((await storage.get(agent.id))?.lastError).toBe(lastError);
|
|
expect(attentionReasons).toEqual(["error", expectedReason]);
|
|
},
|
|
);
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await expect(
|
|
manager.runAgent(agent.id, "fail before turn id", {
|
|
clientMessageId: "rejected-prompt",
|
|
}),
|
|
).rejects.toThrow("Invalid request: missing field `text`");
|
|
expect(manager.getTimeline(agent.id)).toEqual([
|
|
{
|
|
type: "user_message",
|
|
text: "fail before turn id",
|
|
clientMessageId: "rejected-prompt",
|
|
messageId: "rejected-prompt",
|
|
},
|
|
{ type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" },
|
|
]);
|
|
|
|
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
|
|
expect.objectContaining({
|
|
sessionId: expect.any(String),
|
|
canceled: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("acknowledged cancellation settles a pending run before it has a turn id", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cancel-pending-start-"));
|
|
const startEntered = deferred<void>();
|
|
let rejectStart!: (error: Error) => void;
|
|
const stalledStart = new Promise<{ turnId: string }>((_resolve, reject) => {
|
|
rejectStart = reject;
|
|
});
|
|
|
|
class PendingStartSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
startEntered.resolve();
|
|
return await stalledStart;
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {}
|
|
}
|
|
|
|
class PendingStartClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new PendingStartSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new PendingStartClient() },
|
|
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000132",
|
|
});
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const firstRun = manager.runAgent(agent.id, "start a turn");
|
|
void firstRun.catch(() => undefined);
|
|
await startEntered.promise;
|
|
|
|
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
|
expect(manager.hasInFlightRun(agent.id)).toBe(true);
|
|
|
|
await expect(manager.cancelAgentRun(agent.id)).resolves.toEqual({ status: "settled" });
|
|
expect(manager.hasInFlightRun(agent.id)).toBe(false);
|
|
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
|
|
|
rejectStart(new Error("released canceled start"));
|
|
await expect(firstRun).rejects.toThrow("released canceled start");
|
|
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
|
} finally {
|
|
rejectStart(new Error("test cleanup"));
|
|
if (manager.getAgent("00000000-0000-4000-8000-000000000132")) {
|
|
await manager.closeAgent("00000000-0000-4000-8000-000000000132");
|
|
}
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("fires onAgentArchived for archived parent and cascaded children", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-hook-cascade-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const archivedIds: string[] = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
manager.setAgentArchivedCallback((agentId) => {
|
|
archivedIds.push(agentId);
|
|
});
|
|
|
|
const liveParent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const liveChild = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Child" },
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: liveParent.id }, workspaceId: undefined },
|
|
);
|
|
|
|
await manager.archiveAgent(liveParent.id);
|
|
expect([...archivedIds].sort()).toEqual([liveChild.id, liveParent.id].sort());
|
|
});
|
|
|
|
test("fires onAgentArchived for stored-only snapshot archives", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-hook-snapshot-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const archivedIds: string[] = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
manager.setAgentArchivedCallback((agentId) => {
|
|
archivedIds.push(agentId);
|
|
});
|
|
|
|
const storedOnly = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Stored only",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.closeAgent(storedOnly.id);
|
|
|
|
await manager.archiveSnapshot(storedOnly.id, new Date().toISOString());
|
|
expect(archivedIds).toEqual([storedOnly.id]);
|
|
});
|
|
|
|
test("native archive and restore release the loaded session writer", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-native-archive-writer-"));
|
|
class WriterClient extends NativeArchiveRecordingClient {
|
|
session: CloseRecordingTestAgentSession | undefined;
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.session = new CloseRecordingTestAgentSession(config);
|
|
return this.session;
|
|
}
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return this.createSession({ provider: "codex", cwd: workdir, ...config });
|
|
}
|
|
override async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
|
if (!this.session?.closed) throw new Error("native thread has an active writer");
|
|
await super.archiveNativeSession(handle);
|
|
}
|
|
override async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
|
if (!this.session?.closed) throw new Error("native thread has an active writer");
|
|
await super.unarchiveNativeSession(handle);
|
|
}
|
|
}
|
|
const client = new WriterClient();
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.archiveAgent(agent.id);
|
|
expect(client.archivedHandles).toHaveLength(1);
|
|
// Opening archived history can retain a writer, including records archived by older daemons.
|
|
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
|
|
expect(client.session?.closed).toBe(false);
|
|
await manager.unarchiveSnapshot(agent.id);
|
|
expect(client.unarchivedHandles).toHaveLength(1);
|
|
expect((await storage.get(agent.id))?.archivedAt).toBeNull();
|
|
expect(client.session?.closed).toBe(true);
|
|
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
|
|
expect(client.session?.closed).toBe(false);
|
|
} finally {
|
|
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("unarchiveSnapshot skips native provider unarchive for active records", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-active-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new NativeArchiveRecordingClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Active unarchive target",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
const unarchived = await manager.unarchiveSnapshot(agent.id);
|
|
|
|
expect(unarchived).toBe(false);
|
|
expect(client.unarchivedHandles).toEqual([]);
|
|
});
|
|
|
|
test("unarchiveSnapshot unarchives native provider storage before clearing archivedAt", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new NativeArchiveRecordingClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Native unarchive target",
|
|
},
|
|
undefined,
|
|
{
|
|
workspaceId: undefined,
|
|
labels: { [PARENT_AGENT_ID_LABEL]: "archived-parent", retained: "yes" },
|
|
},
|
|
);
|
|
await manager.archiveAgent(agent.id);
|
|
client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt;
|
|
|
|
const unarchived = await manager.unarchiveSnapshot(agent.id, {
|
|
workspaceId: "ws-restored",
|
|
labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" },
|
|
});
|
|
const stored = await storage.get(agent.id);
|
|
|
|
expect(unarchived).toBe(true);
|
|
expect(client.archivedHandles).toHaveLength(1);
|
|
expect(client.unarchivedHandles).toEqual(client.archivedHandles);
|
|
expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String));
|
|
expect(stored?.archivedAt).toBeNull();
|
|
expect(stored?.workspaceId).toBe("ws-restored");
|
|
expect(stored?.labels).toEqual({ retained: "yes", source: "reimport" });
|
|
});
|
|
|
|
test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-handle-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new NativeArchiveRecordingClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Native unarchive by handle target",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.archiveAgent(agent.id);
|
|
const archived = await storage.get(agent.id);
|
|
if (!archived?.persistence) {
|
|
throw new Error("expected archived snapshot to have persistence");
|
|
}
|
|
|
|
await manager.unarchiveSnapshotByHandle(archived.persistence);
|
|
|
|
const stored = await storage.get(agent.id);
|
|
expect(client.unarchivedHandles).toEqual(client.archivedHandles);
|
|
expect(stored?.archivedAt).toBeNull();
|
|
});
|
|
|
|
test("unarchiveSnapshot keeps the stored record archived when native unarchive fails", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-failure-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const client = new NativeArchiveRecordingClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const agent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Native unarchive failure target",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
await manager.archiveAgent(agent.id);
|
|
client.unarchiveFailure = new Error("provider still archived");
|
|
|
|
await expect(manager.unarchiveSnapshot(agent.id)).rejects.toThrow("provider still archived");
|
|
|
|
const stored = await storage.get(agent.id);
|
|
expect(stored?.archivedAt).toEqual(expect.any(String));
|
|
expect(client.unarchivedHandles).toHaveLength(1);
|
|
});
|
|
|
|
test("archiveAgent cascade archives in-memory children with the full archive contract", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-contract-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
const unrelated = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Unrelated",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
const storedParent = await storage.get(parent.id);
|
|
const storedChild = await storage.get(child.id);
|
|
const storedUnrelated = await storage.get(unrelated.id);
|
|
|
|
expectArchivedAgentRecord(storedParent, "closed");
|
|
expectArchivedAgentRecord(storedChild, "closed");
|
|
expect(storedUnrelated?.archivedAt).toBeUndefined();
|
|
});
|
|
|
|
test("archiveAgent detaches an open same-workspace child instead of cascading", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Parent" },
|
|
undefined,
|
|
{ workspaceId: "workspace-a" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Open child" },
|
|
undefined,
|
|
{
|
|
workspaceId: "workspace-a",
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "true",
|
|
},
|
|
},
|
|
);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
const storedChild = await storage.get(child.id);
|
|
expect(storedChild?.archivedAt).toBeUndefined();
|
|
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
|
expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
|
expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
|
expect(manager.getAgent(child.id)?.id).toBe(child.id);
|
|
});
|
|
|
|
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Parent" },
|
|
undefined,
|
|
{ workspaceId: "workspace-a" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Cross-workspace child" },
|
|
undefined,
|
|
{
|
|
workspaceId: "workspace-b",
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
|
},
|
|
},
|
|
);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
const storedChild = await storage.get(child.id);
|
|
expect(storedChild?.archivedAt).toBeUndefined();
|
|
expect(storedChild?.workspaceId).toBe("workspace-b");
|
|
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
|
});
|
|
|
|
test.each([
|
|
{ workspaceId: "workspace-b", open: "false" },
|
|
{ workspaceId: "workspace-a", open: "true" },
|
|
])(
|
|
"archiveAgent publishes stored survivor detach updates ($workspaceId, open=$open)",
|
|
async ({ workspaceId, open }) => {
|
|
const artifacts = join(process.cwd(), ".artifacts");
|
|
mkdirSync(artifacts, { recursive: true });
|
|
const workdir = mkdtempSync(join(artifacts, "stored-child-detach-"));
|
|
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Parent" },
|
|
undefined,
|
|
{ workspaceId: "workspace-a" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Closed cross-workspace child" },
|
|
undefined,
|
|
{
|
|
workspaceId,
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: open,
|
|
team: "ios",
|
|
},
|
|
},
|
|
);
|
|
await manager.closeAgent(child.id);
|
|
// Unfiltered, as a session subscribes: a closed survivor's update must reach every client.
|
|
const emittedLabels: Array<Record<string, string>> = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === child.id) {
|
|
emittedLabels.push(event.agent.labels);
|
|
}
|
|
},
|
|
{ replayState: false },
|
|
);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
await manager.flush();
|
|
unsubscribe();
|
|
|
|
const expectedLabels = { team: "ios" };
|
|
const storedChild = await storage.get(child.id);
|
|
expect(storedChild?.archivedAt).toBeUndefined();
|
|
expect(storedChild?.labels).toEqual(expectedLabels);
|
|
expect(emittedLabels).toEqual([expectedLabels]);
|
|
|
|
// The child is already detached; repeating detach has nothing to sever.
|
|
const detached = await manager.detachAgent(child.id);
|
|
expect(detached.previousParentAgentId).toBeNull();
|
|
expect(detached.record.labels).toEqual(expectedLabels);
|
|
},
|
|
);
|
|
|
|
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
|
|
|
|
class ChildOpensAfterCascadeListStorage extends AgentStorage {
|
|
childId: string | null = null;
|
|
|
|
override async listActive(): Promise<StoredAgentRecord[]> {
|
|
const records = await super.listAll();
|
|
const parentIsArchived = records.some(
|
|
(record) => record.id !== this.childId && Boolean(record.archivedAt),
|
|
);
|
|
const staleChild = records.find((record) => record.id === this.childId);
|
|
if (parentIsArchived && staleChild) {
|
|
await super.upsert({
|
|
...staleChild,
|
|
labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
|
});
|
|
}
|
|
return records;
|
|
}
|
|
}
|
|
|
|
const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Parent" },
|
|
undefined,
|
|
{ workspaceId: "workspace-a" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Late-open child" },
|
|
undefined,
|
|
{
|
|
workspaceId: "workspace-a",
|
|
labels: {
|
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
|
},
|
|
},
|
|
);
|
|
storage.childId = child.id;
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
const storedChild = await storage.get(child.id);
|
|
expect(storedChild?.archivedAt).toBeUndefined();
|
|
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
|
});
|
|
|
|
test("archiveAgent cannot overtake a received child open-tab update", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-"));
|
|
const markerWriteStarted = deferred<void>();
|
|
const releaseMarkerWrite = deferred<void>();
|
|
|
|
class BlockingOpenMarkerStorage extends AgentStorage {
|
|
childId: string | null = null;
|
|
|
|
override async applySnapshot(
|
|
agent: ManagedAgent,
|
|
options?: { title?: string | null; internal?: boolean },
|
|
): Promise<void> {
|
|
if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") {
|
|
markerWriteStarted.resolve();
|
|
await releaseMarkerWrite.promise;
|
|
}
|
|
await super.applySnapshot(agent, options);
|
|
}
|
|
}
|
|
|
|
const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Parent" },
|
|
undefined,
|
|
{ workspaceId: "workspace-a" },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Opening child" },
|
|
undefined,
|
|
{
|
|
workspaceId: "workspace-a",
|
|
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
|
},
|
|
);
|
|
storage.childId = child.id;
|
|
|
|
const markOpen = manager.updateAgentMetadata(child.id, {
|
|
labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
|
});
|
|
await markerWriteStarted.promise;
|
|
const archiveParent = manager.archiveAgent(parent.id);
|
|
releaseMarkerWrite.resolve();
|
|
await Promise.all([markOpen, archiveParent]);
|
|
|
|
const storedChild = await storage.get(child.id);
|
|
expect(storedChild?.archivedAt).toBeUndefined();
|
|
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
|
});
|
|
|
|
test("archiveAgent cascade closes a running child runtime", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const finishRun = deferred<void>();
|
|
|
|
class RunningChildSession extends TestAgentSession {
|
|
closeCalled = false;
|
|
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "running-child-turn";
|
|
void (async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
await finishRun.promise;
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
})();
|
|
return { turnId };
|
|
}
|
|
|
|
override async close(): Promise<void> {
|
|
this.closeCalled = true;
|
|
}
|
|
}
|
|
|
|
class RunningChildClient extends TestAgentClient {
|
|
readonly sessions: RunningChildSession[] = [];
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = new RunningChildSession(config);
|
|
this.sessions.push(session);
|
|
return session;
|
|
}
|
|
}
|
|
|
|
const client = new RunningChildClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Running Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
const childSession = client.sessions[1];
|
|
const childLifecycleEvents: string[] = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state" && event.agent.id === child.id) {
|
|
childLifecycleEvents.push(event.agent.lifecycle);
|
|
}
|
|
},
|
|
{ agentId: child.id, replayState: false },
|
|
);
|
|
const childRun = manager.streamAgent(child.id, "keep running");
|
|
const drainChildRun = (async () => {
|
|
for await (const _event of childRun) {
|
|
// Drain the foreground turn while archive closes it.
|
|
}
|
|
})();
|
|
|
|
await manager.waitForAgentRunStart(child.id);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
finishRun.resolve();
|
|
await drainChildRun;
|
|
unsubscribe();
|
|
|
|
expect(childSession?.closeCalled).toBe(true);
|
|
expect(manager.getAgent(child.id)).toBeNull();
|
|
expect(childLifecycleEvents).toContain("closed");
|
|
});
|
|
|
|
test("archiveAgent cascade archives off-memory children with the full archive contract", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-off-memory-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Off-memory Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
const managerInternals = manager as unknown as {
|
|
agents: Map<string, unknown>;
|
|
};
|
|
managerInternals.agents.delete(child.id);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
|
|
expectArchivedAgentRecord(await storage.get(child.id), "idle");
|
|
});
|
|
|
|
test("archiveAgent cascade notifies subscribers for in-memory and off-memory children", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-notifications-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const inMemoryChild = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "In-memory Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
const offMemoryChild = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Off-memory Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
const managerInternals = manager as unknown as {
|
|
agents: Map<string, unknown>;
|
|
};
|
|
managerInternals.agents.delete(offMemoryChild.id);
|
|
const cascadedChildEvents: string[] = [];
|
|
const unsubscribe = manager.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state") {
|
|
return;
|
|
}
|
|
if (event.agent.id === inMemoryChild.id || event.agent.id === offMemoryChild.id) {
|
|
cascadedChildEvents.push(event.agent.id);
|
|
}
|
|
},
|
|
{ replayState: false },
|
|
);
|
|
|
|
await manager.archiveAgent(parent.id);
|
|
unsubscribe();
|
|
|
|
expect({
|
|
inMemoryChildNotified: cascadedChildEvents.includes(inMemoryChild.id),
|
|
offMemoryChildNotified: cascadedChildEvents.includes(offMemoryChild.id),
|
|
}).toEqual({
|
|
inMemoryChildNotified: true,
|
|
offMemoryChildNotified: true,
|
|
});
|
|
});
|
|
|
|
test("archiveAgent cascade surfaces partial child archive failures", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-partial-failure-"));
|
|
const storagePath = join(workdir, "agents");
|
|
let failingChildId: string | null = null;
|
|
|
|
class FailingChildArchiveStorage extends AgentStorage {
|
|
override async upsert(record: StoredAgentRecord): Promise<void> {
|
|
if (record.id === failingChildId && record.archivedAt) {
|
|
throw new Error(`Injected cascade archive failure for ${record.id}`);
|
|
}
|
|
await super.upsert(record);
|
|
}
|
|
}
|
|
|
|
const storage = new FailingChildArchiveStorage(storagePath, logger);
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TestAgentClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
const parent = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Parent",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: "Failing Child",
|
|
},
|
|
undefined,
|
|
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
|
|
);
|
|
failingChildId = child.id;
|
|
|
|
await expect(manager.archiveAgent(parent.id)).rejects.toThrow(
|
|
`Injected cascade archive failure for ${child.id}`,
|
|
);
|
|
});
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
|
|
|
|
expect(manager.getAgent(agent.id)?.lastError).toBe("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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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;
|
|
resumedModeId: string | null | undefined;
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
return new PlanModeTestSession();
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumedModeId = overrides?.modeId;
|
|
return new PlanModeTestSession();
|
|
}
|
|
}
|
|
|
|
const client = new PlanModeTestClient();
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: client,
|
|
},
|
|
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",
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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");
|
|
|
|
// The provider-managed switch must also reach config.modeId — otherwise a
|
|
// reload resumes the session with the stale creation-time mode ("plan").
|
|
expect(persisted?.config?.modeId).toBe("acceptEdits");
|
|
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
|
expect(reloaded.config.modeId).toBe("acceptEdits");
|
|
expect(client.resumedModeId).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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
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("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let safe = false;
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const session = await super.createSession(config);
|
|
session.canStopRuntime = async () => safe;
|
|
return session;
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
const collect = runtimeCleanup(manager);
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const now = new Date(Date.now() + 61 * 60_000);
|
|
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
|
safe = true;
|
|
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
|
const later = new Date(now.getTime() + 61 * 60_000);
|
|
expect((await collect({ now: later })).collected).toEqual([
|
|
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
|
|
]);
|
|
expect(manager.getAgent(agent.id)).toBeNull();
|
|
const resumed = await ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
expect(resumed.id).toBe(agent.id);
|
|
expect(resumed.persistence).toEqual(agent.persistence);
|
|
await manager.runAgent(agent.id, "Continue the saved conversation");
|
|
} finally {
|
|
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
|
|
let closeCount = 0;
|
|
let resumeCount = 0;
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const recordClose = () => {
|
|
closeCount += 1;
|
|
};
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
recordClose();
|
|
}
|
|
})(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
resumeCount += 1;
|
|
return super.resumeSession(handle, config, launchContext);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
|
expect(closeCount).toBe(0);
|
|
|
|
await manager.runAgent(agent.id, "Continue on the resident runtime");
|
|
|
|
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
|
expect(resumeCount).toBe(0);
|
|
} finally {
|
|
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
|
() => undefined,
|
|
);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("archiving a closed parent still cascades to its managed children", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const parent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Closed parent" },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const child = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, title: "Managed child" },
|
|
undefined,
|
|
{
|
|
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
|
workspaceId: undefined,
|
|
},
|
|
);
|
|
|
|
await manager.closeAgent(parent.id);
|
|
await manager.archiveSnapshot(parent.id, new Date().toISOString());
|
|
|
|
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
|
|
expect((await storage.get(child.id))?.archivedAt).toEqual(expect.any(String));
|
|
expect(manager.getAgent(child.id)).toBeNull();
|
|
} finally {
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-load-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const manager = new AgentManager({
|
|
clients: { codex: new TestAgentClient() },
|
|
registry: storage,
|
|
logger,
|
|
});
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.closeAgent(agent.id);
|
|
await manager.archiveSnapshot(agent.id, new Date().toISOString());
|
|
|
|
await expect(
|
|
ensureUnarchivedAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
}),
|
|
).rejects.toThrow(`Agent is archived: ${agent.id}`);
|
|
expect(manager.getAgent(agent.id)).toBeNull();
|
|
} finally {
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a stored-only archive waits for an in-flight resume and then closes it", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const resumeStarted = deferred<void>();
|
|
const resumeAllowed = deferred<void>();
|
|
const client = new (class extends TestAgentClient {
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
resumeStarted.resolve();
|
|
await resumeAllowed.promise;
|
|
return super.resumeSession(handle, config, launchContext);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.closeAgent(agent.id);
|
|
|
|
const load = ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
await resumeStarted.promise;
|
|
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
|
|
resumeAllowed.resolve();
|
|
await archive;
|
|
|
|
await expect(load).resolves.toMatchObject({ id: agent.id });
|
|
expect(manager.getAgent(agent.id)).toBeNull();
|
|
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
|
|
} finally {
|
|
resumeAllowed.resolve();
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a stored-only archive closes a shared resume before a later protected load returns", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const resumeStarted = deferred<void>();
|
|
const resumeAllowed = deferred<void>();
|
|
const client = new (class extends TestAgentClient {
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
resumeStarted.resolve();
|
|
await resumeAllowed.promise;
|
|
return super.resumeSession(handle, config, launchContext);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.closeAgent(agent.id);
|
|
|
|
const sharedLoad = ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
await resumeStarted.promise;
|
|
const protectedLoad = ensureUnarchivedAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
|
|
resumeAllowed.resolve();
|
|
await archive;
|
|
|
|
await sharedLoad;
|
|
await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`);
|
|
expect(manager.getAgent(agent.id)).toBeNull();
|
|
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
|
|
} finally {
|
|
resumeAllowed.resolve();
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a shared agent load upgrades provider history hydration to broadcast", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shared-load-broadcast-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const historyStarted = deferred<void>();
|
|
const historyAllowed = deferred<void>();
|
|
const client = new (class extends TestAgentClient {
|
|
override async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new (class extends TestAgentSession {
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
historyStarted.resolve();
|
|
await historyAllowed.promise;
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "Recovered history" },
|
|
};
|
|
}
|
|
})({ provider: "codex", cwd: config?.cwd ?? workdir });
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.closeAgent(agent.id);
|
|
await manager.deleteAgentState(agent.id);
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false });
|
|
|
|
const quietLoad = ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
await historyStarted.promise;
|
|
const broadcastingLoad = ensureAgentLoaded(agent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
broadcastTimeline: true,
|
|
logger,
|
|
});
|
|
historyAllowed.resolve();
|
|
await Promise.all([quietLoad, broadcastingLoad]);
|
|
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "agent_stream",
|
|
agentId: agent.id,
|
|
event: expect.objectContaining({
|
|
type: "timeline",
|
|
item: { type: "assistant_message", text: "Recovered history" },
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
historyAllowed.resolve();
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("explicit close cancels running provider subagents before resume", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new SessionRecordingAgentClient();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
client.sessions[0]!.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: {
|
|
type: "upsert",
|
|
id: "provider-child-running",
|
|
title: "Provider child",
|
|
status: "running",
|
|
},
|
|
});
|
|
client.sessions[0]!.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: {
|
|
type: "upsert",
|
|
id: "provider-child-finishing",
|
|
title: "Finishing provider child",
|
|
status: "running",
|
|
},
|
|
});
|
|
await manager.flush();
|
|
|
|
client.sessions[0]!.pushEvent({
|
|
type: "provider_subagent",
|
|
provider: "codex",
|
|
event: {
|
|
type: "upsert",
|
|
id: "provider-child-finishing",
|
|
status: "completed",
|
|
},
|
|
});
|
|
await manager.closeAgent(parent.id);
|
|
await ensureAgentLoaded(parent.id, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
|
|
expect(manager.getProviderSubagent(parent.id, "provider-child-running")?.status).toBe(
|
|
"canceled",
|
|
);
|
|
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
|
|
"completed",
|
|
);
|
|
} finally {
|
|
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
|
() => undefined,
|
|
);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const closeStarted = deferred<void>();
|
|
const closeAllowed = deferred<void>();
|
|
const client = new (class extends TestAgentClient {
|
|
resumeCount = 0;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
closeStarted.resolve();
|
|
await closeAllowed.promise;
|
|
}
|
|
})(config);
|
|
}
|
|
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
this.resumeCount += 1;
|
|
return super.resumeSession(handle, config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const created = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000216",
|
|
{ workspaceId: undefined },
|
|
);
|
|
const close = manager.closeAgent(created.id);
|
|
await closeStarted.promise;
|
|
const loads = Promise.all([
|
|
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
|
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
|
]);
|
|
|
|
expect(client.resumeCount).toBe(0);
|
|
closeAllowed.resolve();
|
|
const [first, second] = await loads;
|
|
await close;
|
|
|
|
expect(first.id).toBe(created.id);
|
|
expect(second.id).toBe(created.id);
|
|
expect(client.resumeCount).toBe(1);
|
|
} finally {
|
|
closeAllowed.resolve();
|
|
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("concurrent explicit closes tear down the runtime once", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-"));
|
|
const closeStarted = deferred<void>();
|
|
const closeAllowed = deferred<void>();
|
|
let closeCount = 0;
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const recordClose = () => {
|
|
closeCount += 1;
|
|
};
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
recordClose();
|
|
closeStarted.resolve();
|
|
await closeAllowed.promise;
|
|
}
|
|
})(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
|
|
try {
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const firstClose = manager.closeAgent(agent.id);
|
|
await closeStarted.promise;
|
|
const secondClose = manager.closeAgent(agent.id);
|
|
|
|
closeAllowed.resolve();
|
|
await Promise.all([firstClose, secondClose]);
|
|
|
|
expect(closeCount).toBe(1);
|
|
} finally {
|
|
closeAllowed.resolve();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("close drains provider tool cleanup before retiring the runtime", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-cleanup-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const timeline = new InMemoryAgentTimelineStore();
|
|
class CleanupSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
const turnId = "cleanup-turn";
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
for (const callId of ["first-tool", "second-tool"]) {
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: {
|
|
type: "tool_call",
|
|
callId,
|
|
name: "Bash",
|
|
detail: { type: "shell", command: "sleep 10" },
|
|
status: "running",
|
|
error: null,
|
|
},
|
|
});
|
|
}
|
|
return { turnId };
|
|
}
|
|
override async close(): Promise<void> {
|
|
for (const callId of ["first-tool", "second-tool"]) {
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId: "cleanup-turn",
|
|
item: {
|
|
type: "tool_call",
|
|
callId,
|
|
name: "Bash",
|
|
detail: { type: "shell", command: "sleep 10" },
|
|
status: "canceled",
|
|
error: null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new CleanupSession(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
timelineStore: timeline,
|
|
logger,
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const run = drainAsyncGenerator(manager.streamAgent(agent.id, "run both tools"));
|
|
try {
|
|
await manager.waitForAgentRunStart(agent.id);
|
|
await manager.flush();
|
|
await manager.closeAgent(agent.id);
|
|
await run;
|
|
await manager.flush();
|
|
expect(
|
|
timeline
|
|
.getItems(agent.id)
|
|
.filter((item) => item.type === "tool_call")
|
|
.map((item) => ({ callId: item.callId, status: item.status })),
|
|
).toEqual([
|
|
{ callId: "first-tool", status: "canceled" },
|
|
{ callId: "second-tool", status: "canceled" },
|
|
]);
|
|
expect((await storage.get(agent.id))?.lastStatus).toBe("closed");
|
|
} finally {
|
|
await manager.closeAgent(agent.id).catch(() => undefined);
|
|
await run;
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new (class extends TestAgentSession {
|
|
override async close(): Promise<void> {
|
|
throw new Error("provider cleanup failed");
|
|
}
|
|
})(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
|
|
try {
|
|
const created = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir },
|
|
"00000000-0000-4000-8000-000000000217",
|
|
{ workspaceId: undefined },
|
|
);
|
|
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
|
|
expect(manager.getAgent(created.id)?.session).toBe(created.session);
|
|
const stored = await storage.get(created.id);
|
|
expect(stored).toMatchObject({ lastStatus: "idle" });
|
|
expect(stored?.archivedAt).toBeFalsy();
|
|
|
|
await expect(
|
|
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
|
).resolves.toMatchObject({ id: created.id, lifecycle: "idle" });
|
|
} finally {
|
|
await manager.closeAgent("00000000-0000-4000-8000-000000000217").catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
|
|
|
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 preserves provider replay timestamps and marks missing ones untrusted", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-timestamps-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
class TimestampedHistorySession extends TestAgentSession {
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
timestamp: "2026-05-01T10:00:00.000Z",
|
|
item: { type: "user_message", text: "hello", messageId: "msg_history_1" },
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "no original timestamp" },
|
|
};
|
|
}
|
|
}
|
|
|
|
class TimestampedHistoryClient 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 TimestampedHistorySession(config);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error("Not used in this test");
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: {
|
|
codex: new TimestampedHistoryClient(),
|
|
},
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000204",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent(
|
|
{
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
},
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
|
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 }).rows;
|
|
|
|
expect(timeline).toHaveLength(2);
|
|
expect(timeline[0]).toMatchObject({
|
|
timestamp: "2026-05-01T10:00:00.000Z",
|
|
item: { type: "user_message", text: "hello", messageId: "msg_history_1" },
|
|
});
|
|
expect(timeline[1]?.timestamp).toEqual(expect.any(String));
|
|
});
|
|
|
|
test("provider user_message is recorded from the live stream", 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";
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
// Provider yields a staged user_message without client identity (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 });
|
|
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 }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
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].text).toBe("continuation prompt");
|
|
});
|
|
|
|
test("canonical submitted prompt keeps wire identity while rewind resolves provider identity", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-submitted-prompt-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const allowProviderEcho = deferred<void>();
|
|
|
|
class SubmittedUserMessageSession extends TestAgentSession {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsRewindFiles: true,
|
|
};
|
|
readonly rewindMessageIds: string[] = [];
|
|
interruptCount = 0;
|
|
|
|
override async startTurn(
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<{ turnId: string }> {
|
|
const turnId = "turn-submitted-user-message";
|
|
const text = typeof prompt === "string" ? prompt : "";
|
|
setTimeout(async () => {
|
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: { type: "assistant_message", text: "output before provider echo" },
|
|
});
|
|
await allowProviderEcho.promise;
|
|
this.pushEvent({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
turnId,
|
|
item: {
|
|
type: "user_message",
|
|
text,
|
|
messageId: "provider-message-1",
|
|
clientMessageId: options?.clientMessageId,
|
|
},
|
|
});
|
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
|
}, 0);
|
|
return { turnId };
|
|
}
|
|
|
|
override async interrupt(): Promise<void> {
|
|
this.interruptCount += 1;
|
|
this.pushEvent({
|
|
type: "turn_canceled",
|
|
provider: this.provider,
|
|
turnId: "turn-submitted-user-message",
|
|
});
|
|
}
|
|
|
|
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
|
|
this.rewindMessageIds.push(messageId);
|
|
}
|
|
}
|
|
|
|
class SubmittedUserMessageClient extends TestAgentClient {
|
|
session: SubmittedUserMessageSession | null = null;
|
|
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
this.session = new SubmittedUserMessageSession(config);
|
|
return this.session;
|
|
}
|
|
}
|
|
|
|
const client = new SubmittedUserMessageClient();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000402",
|
|
});
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), { replayState: false });
|
|
const streamEvents = () =>
|
|
events.flatMap((event) => {
|
|
if (event.type !== "agent_stream") return [];
|
|
if (event.event.type !== "timeline") return [{ type: event.event.type }];
|
|
const item = event.event.item;
|
|
return [
|
|
{
|
|
type: item.type,
|
|
seq: event.seq,
|
|
...(item.type === "user_message"
|
|
? {
|
|
text: item.text,
|
|
clientMessageId: item.clientMessageId,
|
|
messageId: item.messageId,
|
|
}
|
|
: {}),
|
|
},
|
|
];
|
|
});
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
const run = manager.runAgent(snapshot.id, "hello from composer", {
|
|
clientMessageId: "msg-client-1",
|
|
});
|
|
await manager.waitForAgentRunStart(snapshot.id);
|
|
|
|
await expect(manager.rewind(snapshot.id, "msg-client-1", "files")).rejects.toThrow(
|
|
"Cannot rewind before the provider acknowledges the submitted prompt",
|
|
);
|
|
expect(client.session?.interruptCount).toBe(0);
|
|
|
|
allowProviderEcho.resolve();
|
|
expect(await run).toMatchObject({ canceled: false });
|
|
|
|
expect(streamEvents()).toEqual([
|
|
{ type: "turn_started" },
|
|
{
|
|
type: "user_message",
|
|
seq: 1,
|
|
text: "hello from composer",
|
|
clientMessageId: "msg-client-1",
|
|
messageId: "msg-client-1",
|
|
},
|
|
{ type: "assistant_message", seq: 2 },
|
|
{ type: "turn_completed" },
|
|
]);
|
|
|
|
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
|
|
expect(timeline).toMatchObject([
|
|
{
|
|
seq: 1,
|
|
timestamp: expect.any(String),
|
|
providerMessageId: "provider-message-1",
|
|
turnId: "turn-submitted-user-message",
|
|
item: {
|
|
type: "user_message",
|
|
text: "hello from composer",
|
|
messageId: "msg-client-1",
|
|
clientMessageId: "msg-client-1",
|
|
},
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: expect.any(String),
|
|
turnId: "turn-submitted-user-message",
|
|
item: { type: "assistant_message", text: "output before provider echo" },
|
|
},
|
|
]);
|
|
|
|
await manager.rewind(snapshot.id, "msg-client-1", "files");
|
|
await manager.rewind(snapshot.id, "provider-native-message", "files");
|
|
expect(client.session?.rewindMessageIds).toEqual([
|
|
"provider-message-1",
|
|
"provider-native-message",
|
|
]);
|
|
} finally {
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("authoritative timeline records a daemon-handled submitted prompt before its output", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-daemon-handled-prompt-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const commandCompleted = deferred<void>();
|
|
|
|
class DaemonHandledPromptSession extends TestAgentSession {
|
|
override readonly capabilities = {
|
|
...TEST_CAPABILITIES,
|
|
supportsRewindConversation: true,
|
|
};
|
|
|
|
override tryHandleOutOfBand(prompt: AgentPromptInput) {
|
|
if (prompt !== "/handled") return null;
|
|
return {
|
|
run: async ({ emit }: { emit: (event: AgentStreamEvent) => void }) => {
|
|
emit({
|
|
type: "timeline",
|
|
provider: this.provider,
|
|
item: { type: "assistant_message", text: "Handled by the daemon" },
|
|
});
|
|
commandCompleted.resolve();
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
class DaemonHandledPromptClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new DaemonHandledPromptSession(config);
|
|
}
|
|
}
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex: new DaemonHandledPromptClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000403",
|
|
});
|
|
|
|
try {
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), {
|
|
agentId: snapshot.id,
|
|
replayState: false,
|
|
});
|
|
|
|
await startAgentRun(manager, snapshot.id, "/handled", logger, {
|
|
runOptions: { clientMessageId: "msg-client-daemon-handled" },
|
|
});
|
|
await commandCompleted.promise;
|
|
|
|
expect(
|
|
events.flatMap((event) =>
|
|
event.type === "agent_stream" && event.event.type === "timeline" ? [event.event.item] : [],
|
|
),
|
|
).toEqual([
|
|
{
|
|
type: "user_message",
|
|
text: "/handled",
|
|
clientMessageId: "msg-client-daemon-handled",
|
|
},
|
|
{ type: "assistant_message", text: "Handled by the daemon" },
|
|
]);
|
|
|
|
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
|
|
expect(timeline.map((row) => row.item)).toEqual([
|
|
{
|
|
type: "user_message",
|
|
text: "/handled",
|
|
clientMessageId: "msg-client-daemon-handled",
|
|
},
|
|
{ type: "assistant_message", text: "Handled by the daemon" },
|
|
]);
|
|
} finally {
|
|
await manager.flush().catch(() => undefined);
|
|
await storage.flush().catch(() => undefined);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tryRunOutOfBand gives providers the command another agent sent, not its envelope", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-agent-out-of-band-"));
|
|
const seenPrompts: AgentPromptInput[] = [];
|
|
|
|
class SlashCommandSession extends TestAgentSession {
|
|
override tryHandleOutOfBand(prompt: AgentPromptInput) {
|
|
seenPrompts.push(prompt);
|
|
return prompt === "/handled" ? { run: async () => {} } : null;
|
|
}
|
|
}
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig) {
|
|
return new SlashCommandSession(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
try {
|
|
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
|
|
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
|
expect(seenPrompts).toEqual(["/handled"]);
|
|
|
|
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
|
|
const pasted = prepareAgentMessage(String(fromAgent.prompt));
|
|
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
|
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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 }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
// 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 = await 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);
|
|
|
|
class RecordingPersistedAgentsClient implements AgentClient {
|
|
readonly capabilities = TEST_CAPABILITIES;
|
|
calls = 0;
|
|
|
|
constructor(public readonly provider: AgentProvider) {}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async createSession(): Promise<AgentSession> {
|
|
throw new Error(`unexpected createSession for ${this.provider}`);
|
|
}
|
|
|
|
async resumeSession(): Promise<AgentSession> {
|
|
throw new Error(`unexpected resumeSession for ${this.provider}`);
|
|
}
|
|
|
|
async fetchCatalog() {
|
|
return { models: [], modes: [] };
|
|
}
|
|
|
|
async listImportableSessions() {
|
|
this.calls += 1;
|
|
return [
|
|
{
|
|
providerHandleId: `${this.provider}-session`,
|
|
cwd: "/tmp/recent",
|
|
title: null,
|
|
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
test.each([
|
|
[
|
|
"disabled",
|
|
"claude",
|
|
"codex",
|
|
{
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
codex: { enabled: false, derivedFromProviderId: null },
|
|
},
|
|
],
|
|
])(
|
|
"listImportableSessions skips %s providers in fan-out",
|
|
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
|
|
const includedClient = new RecordingPersistedAgentsClient(includedProvider);
|
|
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
|
|
const manager = new AgentManager({
|
|
clients: { [includedProvider]: includedClient, [skippedProvider]: skippedClient },
|
|
providerDefinitions,
|
|
logger,
|
|
});
|
|
|
|
const result = await manager.listImportableSessions();
|
|
|
|
expect(includedClient.calls).toBe(1);
|
|
expect(skippedClient.calls).toBe(0);
|
|
expect(result.sessions.map((d) => d.provider)).toEqual([includedProvider]);
|
|
},
|
|
);
|
|
|
|
test("listImportableSessions includes derived providers that list persisted agents", async () => {
|
|
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
|
const ompClient = new RecordingPersistedAgentsClient("omp");
|
|
const manager = new AgentManager({
|
|
clients: { claude: claudeClient, omp: ompClient },
|
|
providerDefinitions: {
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
omp: { enabled: true, derivedFromProviderId: "pi" },
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const result = await manager.listImportableSessions();
|
|
|
|
expect(claudeClient.calls).toBe(1);
|
|
expect(ompClient.calls).toBe(1);
|
|
expect(result.sessions.map((d) => d.provider).sort()).toEqual(["claude", "omp"]);
|
|
});
|
|
|
|
test("listImportableSessions narrows to the providerFilter when supplied", async () => {
|
|
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
|
const codexClient = new RecordingPersistedAgentsClient("codex");
|
|
const manager = new AgentManager({
|
|
clients: { claude: claudeClient, codex: codexClient },
|
|
providerDefinitions: {
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
codex: { enabled: true, derivedFromProviderId: null },
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const result = await manager.listImportableSessions({
|
|
providerFilter: new Set(["claude"]),
|
|
});
|
|
|
|
expect(claudeClient.calls).toBe(1);
|
|
expect(codexClient.calls).toBe(0);
|
|
expect(result.sessions.map((d) => d.provider)).toEqual(["claude"]);
|
|
});
|
|
|
|
test("listImportableSessions skips providers that lack supportsSessionListing even when row listing is defined", async () => {
|
|
const listableClient = new RecordingPersistedAgentsClient("claude");
|
|
const nonListableClient = new RecordingPersistedAgentsClient("acp");
|
|
// Override capabilities to remove session listing support
|
|
Object.defineProperty(nonListableClient, "capabilities", {
|
|
value: {
|
|
...TEST_CAPABILITIES,
|
|
supportsSessionListing: false,
|
|
},
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { claude: listableClient, acp: nonListableClient },
|
|
providerDefinitions: {
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
acp: { enabled: true, derivedFromProviderId: null },
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const result = await manager.listImportableSessions();
|
|
|
|
expect(listableClient.calls).toBe(1);
|
|
expect(nonListableClient.calls).toBe(0);
|
|
expect(result.sessions.map((d) => d.provider)).toEqual(["claude"]);
|
|
});
|
|
|
|
test("listImportableSessions returns healthy rows alongside thrown and timed-out provider errors", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const healthyClient = new RecordingPersistedAgentsClient("claude");
|
|
const failingClient = new RecordingPersistedAgentsClient("codex");
|
|
failingClient.listImportableSessions = async () => {
|
|
throw new Error("codex listing failed");
|
|
};
|
|
const hangingClient = new RecordingPersistedAgentsClient("pi");
|
|
hangingClient.listImportableSessions = async () => await new Promise(() => undefined);
|
|
const manager = new AgentManager({
|
|
clients: { claude: healthyClient, codex: failingClient, pi: hangingClient },
|
|
providerDefinitions: {
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
codex: { enabled: true, derivedFromProviderId: null },
|
|
pi: { enabled: true, derivedFromProviderId: null },
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const resultPromise = manager.listImportableSessions();
|
|
await vi.advanceTimersByTimeAsync(90_000);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
sessions: [
|
|
{
|
|
provider: "claude",
|
|
providerHandleId: "claude-session",
|
|
cwd: "/tmp/recent",
|
|
title: null,
|
|
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
},
|
|
],
|
|
providerErrors: [
|
|
{ provider: "codex", message: "codex listing failed" },
|
|
{
|
|
provider: "pi",
|
|
message: "Timed out listing importable sessions for provider 'pi' after 90000ms",
|
|
},
|
|
],
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("listImportableSessions searches every provider result before global ranking", async () => {
|
|
const client = new RecordingPersistedAgentsClient("claude");
|
|
client.listImportableSessions = async () => [
|
|
...Array.from({ length: 25 }, (_, index) => ({
|
|
providerHandleId: `non-match-${index}`,
|
|
cwd: "/tmp/other",
|
|
title: "Other work",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
lastActivityAt: new Date(`2026-04-${String(index + 2).padStart(2, "0")}T00:00:00.000Z`),
|
|
})),
|
|
{
|
|
providerHandleId: "title-match",
|
|
cwd: "/tmp/archive",
|
|
title: "Invoice cleanup",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
lastActivityAt: new Date("2026-04-01T04:00:00.000Z"),
|
|
},
|
|
{
|
|
providerHandleId: "first-prompt-match",
|
|
cwd: "/tmp/archive",
|
|
title: "Unrelated",
|
|
firstPromptPreview: "Investigate invoice totals",
|
|
lastPromptPreview: null,
|
|
lastActivityAt: new Date("2026-04-01T03:00:00.000Z"),
|
|
},
|
|
{
|
|
providerHandleId: "last-prompt-match",
|
|
cwd: "/tmp/archive",
|
|
title: "Unrelated",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: "Finish invoice export",
|
|
lastActivityAt: new Date("2026-04-01T02:00:00.000Z"),
|
|
},
|
|
{
|
|
providerHandleId: "cwd-match",
|
|
cwd: "/tmp/invoice-service",
|
|
title: "Unrelated",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
lastActivityAt: new Date("2026-04-01T01:00:00.000Z"),
|
|
},
|
|
];
|
|
const manager = new AgentManager({
|
|
clients: { claude: client },
|
|
providerDefinitions: {
|
|
claude: { enabled: true, derivedFromProviderId: null },
|
|
},
|
|
logger,
|
|
});
|
|
|
|
const result = await manager.listImportableSessions({
|
|
query: "INVOICE",
|
|
limit: 10,
|
|
scanLimit: 500,
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.providerHandleId)).toEqual([
|
|
"title-match",
|
|
"first-prompt-match",
|
|
"last-prompt-match",
|
|
"cwd-match",
|
|
]);
|
|
});
|
|
|
|
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: [
|
|
{
|
|
type: "user_message",
|
|
text: formatSystemNotificationPrompt("child finished"),
|
|
},
|
|
{ type: "user_message", text: "plain user message" },
|
|
],
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-0000000005a1",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "do something" });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
expect(timeline).toContainEqual(
|
|
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
|
);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
|
|
expect(userMessages).toHaveLength(1);
|
|
expect(userMessages[0].text).toBe("plain user message");
|
|
});
|
|
|
|
test("system-injected prompts appear as trigger tools during history replay", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
|
|
const codex = fakeCodexEmitting({
|
|
historyItems: [
|
|
{
|
|
type: "user_message",
|
|
text: formatSystemNotificationPrompt("schedule fired"),
|
|
messageId: "msg_history_envelope",
|
|
},
|
|
{
|
|
type: "user_message",
|
|
text: "real user message",
|
|
messageId: "msg_history_real",
|
|
},
|
|
{ type: "assistant_message", text: "reply" },
|
|
],
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-0000000005a2",
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
|
|
|
const timeline = manager.getTimeline(snapshot.id);
|
|
expect(timeline).toContainEqual(
|
|
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
|
);
|
|
const userMessages = timeline.filter((item) => item.type === "user_message");
|
|
|
|
expect(userMessages).toHaveLength(1);
|
|
expect(userMessages[0].text).toBe("real user message");
|
|
});
|
|
|
|
test("commandMayHaveChangedExternalState matches remote-state commands", () => {
|
|
// GitHub PR operations (remote, no local file changes)
|
|
expect(commandMayHaveChangedExternalState("gh pr merge 123")).toBe(true);
|
|
expect(commandMayHaveChangedExternalState("gh pr close 123")).toBe(true);
|
|
expect(commandMayHaveChangedExternalState("gh pr create")).toBe(true);
|
|
expect(commandMayHaveChangedExternalState("gh pr edit 123")).toBe(true);
|
|
expect(commandMayHaveChangedExternalState('gh pr comment 123 -b "lgtm"')).toBe(true);
|
|
expect(commandMayHaveChangedExternalState("gh pr review 123 -a")).toBe(true);
|
|
// Git remote operations (local refs unchanged)
|
|
expect(commandMayHaveChangedExternalState("git push origin main")).toBe(true);
|
|
expect(commandMayHaveChangedExternalState("git fetch origin")).toBe(true);
|
|
});
|
|
|
|
test("commandMayHaveChangedExternalState ignores local or read-only commands", () => {
|
|
// Local git mutations — already caught by file watchers on .git/HEAD
|
|
expect(commandMayHaveChangedExternalState("git commit -m 'hello'")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("git checkout main")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("git merge feature")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("git rebase main")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("git reset --hard HEAD~1")).toBe(false);
|
|
// git pull includes a merge/rebase that changes local refs → watchers catch it
|
|
expect(commandMayHaveChangedExternalState("git pull origin main")).toBe(false);
|
|
// Read-only gh commands
|
|
expect(commandMayHaveChangedExternalState("gh pr view 123")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("gh pr list")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("gh auth status")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("gh repo view")).toBe(false);
|
|
// Miscellaneous local commands
|
|
expect(commandMayHaveChangedExternalState("git status")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("ls -la")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("cat file.txt")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("npm install")).toBe(false);
|
|
expect(commandMayHaveChangedExternalState("npm publish")).toBe(false);
|
|
});
|
|
|
|
test("onWorkspaceStateMayHaveChanged is called when a completed shell tool call may have changed external state", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const onWorkspaceStateMayHaveChanged = vi.fn();
|
|
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: [
|
|
{
|
|
type: "tool_call",
|
|
callId: "call-1",
|
|
name: "bash",
|
|
status: "completed",
|
|
detail: { type: "shell", command: "gh pr merge 123 --squash" },
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex },
|
|
registry: storage,
|
|
logger,
|
|
onWorkspaceStateMayHaveChanged,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "merge it" });
|
|
|
|
expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledTimes(1);
|
|
expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledWith({ cwd: workdir });
|
|
});
|
|
|
|
test("onWorkspaceStateMayHaveChanged is not called for non-shell tool calls", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const onWorkspaceStateMayHaveChanged = vi.fn();
|
|
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: [
|
|
{
|
|
type: "tool_call",
|
|
callId: "call-1",
|
|
name: "read",
|
|
status: "completed",
|
|
detail: { type: "read", filePath: "/tmp/foo.txt" },
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex },
|
|
registry: storage,
|
|
logger,
|
|
onWorkspaceStateMayHaveChanged,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "read it" });
|
|
|
|
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
|
const storagePath = join(workdir, "agents");
|
|
const storage = new AgentStorage(storagePath, logger);
|
|
const onWorkspaceStateMayHaveChanged = vi.fn();
|
|
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: [
|
|
{
|
|
type: "tool_call",
|
|
callId: "call-1",
|
|
name: "bash",
|
|
status: "running",
|
|
detail: { type: "shell", command: "gh pr merge 123 --squash" },
|
|
error: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const manager = new AgentManager({
|
|
clients: { codex },
|
|
registry: storage,
|
|
logger,
|
|
onWorkspaceStateMayHaveChanged,
|
|
});
|
|
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
|
|
await manager.runAgent(snapshot.id, { text: "merge it" });
|
|
|
|
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("concurrent native restores run once before resuming the same agent", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const restoreStarted = deferred<void>();
|
|
const restoreAllowed = deferred<void>();
|
|
const operations: string[] = [];
|
|
const client = new (class extends TestAgentClient {
|
|
async unarchiveNativeSession(): Promise<void> {
|
|
operations.push("restore");
|
|
restoreStarted.resolve();
|
|
await restoreAllowed.promise;
|
|
}
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
_launchContext?: AgentLaunchContext,
|
|
options?: AgentResumeSessionOptions,
|
|
): Promise<AgentSession> {
|
|
operations.push(`resume:${options?.purpose}`);
|
|
return super.resumeSession(handle, config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
let agentId: string | undefined;
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
agentId = created.id;
|
|
await manager.archiveAgent(agentId);
|
|
const first = manager.unarchiveSnapshot(agentId);
|
|
await restoreStarted.promise;
|
|
const second = manager.unarchiveSnapshot(agentId);
|
|
const load = ensureAgentLoaded(agentId, {
|
|
agentManager: manager,
|
|
agentStorage: storage,
|
|
logger,
|
|
});
|
|
restoreAllowed.resolve();
|
|
await Promise.all([first, second, load]);
|
|
expect(operations).toEqual(["restore", "resume:interactive"]);
|
|
expect((await storage.get(agentId))?.archivedAt).toBeNull();
|
|
expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId);
|
|
} finally {
|
|
restoreAllowed.resolve();
|
|
if (agentId) await manager.closeAgent(agentId);
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("commits startup notices once on create and after restored history", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-"));
|
|
const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" };
|
|
const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" };
|
|
const toolOutput: AgentTimelineItem = {
|
|
type: "tool_call",
|
|
callId: "startup-output",
|
|
name: "shell",
|
|
status: "completed",
|
|
error: null,
|
|
detail: {
|
|
type: "shell",
|
|
command: "print output",
|
|
output: "x".repeat(1024 * 1024),
|
|
exitCode: 0,
|
|
},
|
|
};
|
|
const limitedToolOutput = limitAgentTimelineItemContent(toolOutput);
|
|
let activeNotice = notice;
|
|
let lastSession: NotifyingSession | null = null;
|
|
class NotifyingSession extends TestAgentSession {
|
|
readonly initialTimeline = [{ item: activeNotice, timestamp: "2026-09-22T00:00:00.000Z" }];
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: history,
|
|
timestamp: "2026-09-21T00:00:00.000Z",
|
|
};
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: toolOutput,
|
|
timestamp: "2026-09-21T01:00:00.000Z",
|
|
};
|
|
yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] };
|
|
}
|
|
}
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig) {
|
|
const session = new NotifyingSession(config);
|
|
lastSession = session;
|
|
return session;
|
|
}
|
|
override async resumeSession() {
|
|
const session = new NotifyingSession({ provider: "codex", cwd: workdir });
|
|
lastSession = session;
|
|
return session;
|
|
}
|
|
})();
|
|
const store = new RecordingTimelineStore();
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
durableTimelineStore: store,
|
|
logger,
|
|
});
|
|
const ids: string[] = [];
|
|
try {
|
|
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {});
|
|
ids.push(created.id);
|
|
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
|
|
await manager.reloadAgentSession(created.id);
|
|
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
|
|
await manager.closeAgent(created.id);
|
|
await manager.resumeAgentFromPersistence(
|
|
{
|
|
provider: "codex",
|
|
sessionId: "existing-created",
|
|
metadata: { cwd: workdir },
|
|
},
|
|
undefined,
|
|
created.id,
|
|
);
|
|
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
|
|
const followUp: AgentTimelineItem = {
|
|
type: "user_message",
|
|
text: "after cold restore",
|
|
messageId: "msg-after-cold-restore",
|
|
};
|
|
lastSession!.pushEvent({ type: "timeline", provider: "codex", item: followUp });
|
|
await manager.reloadAgentSession(created.id);
|
|
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([
|
|
notice,
|
|
followUp,
|
|
]);
|
|
const v1Notice: AgentTimelineItem = {
|
|
type: "notification",
|
|
level: "info",
|
|
message: "This chat uses OpenCode v1.",
|
|
};
|
|
const v2Notice: AgentTimelineItem = {
|
|
type: "notification",
|
|
level: "info",
|
|
message: "This chat uses OpenCode v2.",
|
|
};
|
|
activeNotice = v1Notice;
|
|
await manager.reloadAgentSession(created.id);
|
|
activeNotice = v2Notice;
|
|
await manager.reloadAgentSession(created.id);
|
|
activeNotice = v1Notice;
|
|
await manager.reloadAgentSession(created.id);
|
|
await manager.reloadAgentSession(created.id);
|
|
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([
|
|
notice,
|
|
followUp,
|
|
v1Notice,
|
|
v2Notice,
|
|
v1Notice,
|
|
]);
|
|
activeNotice = notice;
|
|
const resumed = await manager.resumeAgentFromPersistence({
|
|
provider: "codex",
|
|
sessionId: "existing",
|
|
metadata: { cwd: workdir },
|
|
});
|
|
ids.push(resumed.id);
|
|
await manager.hydrateTimelineFromProvider(resumed.id);
|
|
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
|
|
history,
|
|
limitedToolOutput,
|
|
notice,
|
|
]);
|
|
await manager.hydrateTimelineFromProvider(resumed.id, { force: true });
|
|
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
|
|
history,
|
|
limitedToolOutput,
|
|
notice,
|
|
]);
|
|
await manager.flush();
|
|
expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice }));
|
|
} finally {
|
|
for (const id of ids) await manager.closeAgent(id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("failed startup history closes the session without registering an agent", async () => {
|
|
let closed = false;
|
|
class FailingHistorySession extends TestAgentSession {
|
|
readonly initialTimeline = [
|
|
{
|
|
item: { type: "notification", level: "info", message: "Runtime v2" } as const,
|
|
timestamp: "2026-09-22T00:00:00.000Z",
|
|
},
|
|
];
|
|
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: { type: "assistant_message", text: "Partial history" },
|
|
};
|
|
throw new Error("History unavailable");
|
|
}
|
|
override async close() {
|
|
closed = true;
|
|
await super.close();
|
|
}
|
|
}
|
|
const client = new (class extends TestAgentClient {
|
|
override async resumeSession() {
|
|
return new FailingHistorySession({ provider: "codex", cwd: tmpdir() });
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
try {
|
|
await expect(
|
|
manager.resumeAgentFromPersistence({
|
|
provider: "codex",
|
|
sessionId: "failed-history",
|
|
metadata: { cwd: tmpdir() },
|
|
}),
|
|
).rejects.toThrow("History unavailable");
|
|
expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true });
|
|
} finally {
|
|
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
|
}
|
|
});
|
|
|
|
test("usage session is a pure read of the live adapter and disappears on close", async () => {
|
|
const descriptor = {
|
|
provider: "claude",
|
|
model: "opus",
|
|
env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" },
|
|
sessionKey: "launch-1",
|
|
};
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
return new (class extends TestAgentSession {
|
|
usageSession() {
|
|
return descriptor;
|
|
}
|
|
})(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
expect(manager.usageSession("missing")).toBeNull();
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
try {
|
|
expect(manager.usageSession(agent.id)).toBe(descriptor);
|
|
expect(manager.usageSession(agent.id)).toBe(descriptor);
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
}
|
|
expect(manager.usageSession(agent.id)).toBeNull();
|
|
});
|
|
|
|
test.each(["live", "history"])(
|
|
"agent messages become notification tools during %s ingestion",
|
|
async (path) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-message-provenance-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const item: AgentTimelineItem = {
|
|
type: "user_message",
|
|
text: '<paseo-system version="1" kind="agent-message" source-agent-id="remote::sender" message-id="delivery-1">\nReview <changes> & report back.\n</paseo-system>',
|
|
messageId: "provider-message",
|
|
};
|
|
const codex = fakeCodexEmitting(
|
|
path === "live" ? { turnItems: [item] } : { historyItems: [item] },
|
|
);
|
|
const manager = new AgentManager({ clients: { codex }, registry: storage, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
if (path === "live") await manager.runAgent(agent.id, { text: "start" });
|
|
else await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
|
expect(manager.getTimeline(agent.id)).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "tool_call",
|
|
callId: "paseo-agent-message:delivery-1",
|
|
agentMessage: {
|
|
event: "message",
|
|
sender: { id: "remote::sender" },
|
|
text: "Review <changes> & report back.",
|
|
},
|
|
name: "agent_message",
|
|
status: "completed",
|
|
detail: {
|
|
type: "plain_text",
|
|
icon: "bot",
|
|
text: "Review <changes> & report back.",
|
|
},
|
|
}),
|
|
);
|
|
expect(manager.getTimeline(agent.id).filter((entry) => entry.type === "user_message")).toEqual(
|
|
[],
|
|
);
|
|
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
|
},
|
|
);
|
|
|
|
test.each([false, true])(
|
|
"accepted agent prompt appears once with provider echo=%s",
|
|
async (echo) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-message-acceptance-"));
|
|
const delivery = prepareAgentMessage(
|
|
"Please review",
|
|
{ kind: "agent-message", agentId: "remote::sender" },
|
|
"accepted-delivery",
|
|
);
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: echo
|
|
? [
|
|
{
|
|
type: "user_message",
|
|
text: String(delivery.prompt),
|
|
clientMessageId: delivery.messageId,
|
|
messageId: "native-echo",
|
|
},
|
|
]
|
|
: [],
|
|
});
|
|
const manager = new AgentManager({ clients: { codex }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
try {
|
|
for await (const _event of manager.streamAgent(agent.id, delivery.prompt, {
|
|
clientMessageId: delivery.messageId,
|
|
})) {
|
|
}
|
|
expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([
|
|
expect.objectContaining({
|
|
callId: "paseo-agent-message:accepted-delivery",
|
|
status: "completed",
|
|
}),
|
|
]);
|
|
expect(manager.getTimeline(agent.id).filter((item) => item.type === "user_message")).toEqual(
|
|
[],
|
|
);
|
|
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("a rejected agent prompt never becomes a delivered notification", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-message-rejected-"));
|
|
const delivery = prepareAgentMessage(
|
|
"Please review",
|
|
{ kind: "agent-message", agentId: "sender" },
|
|
"rejected",
|
|
);
|
|
class RejectingSession extends TestAgentSession {
|
|
override async startTurn(): Promise<{ turnId: string }> {
|
|
throw new Error("provider rejected delivery");
|
|
}
|
|
}
|
|
const client = new (class extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig) {
|
|
return new RejectingSession(config);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
try {
|
|
const run = manager.streamAgent(agent.id, delivery.prompt, {
|
|
clientMessageId: delivery.messageId,
|
|
});
|
|
await expect(run.next()).rejects.toThrow("provider rejected delivery");
|
|
expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([]);
|
|
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.each([false, true])(
|
|
"human envelope examples remain human after echo with correlation=%s",
|
|
async (correlated) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "human-envelope-example-"));
|
|
const example = formatAgentMessage({
|
|
id: "example",
|
|
source: { kind: "agent-message", agentId: "sender" },
|
|
text: "hello",
|
|
});
|
|
const delivery = prepareAgentMessage(example, undefined, "human-submission");
|
|
const codex = fakeCodexEmitting({
|
|
turnItems: [
|
|
{
|
|
type: "user_message",
|
|
text: String(delivery.prompt),
|
|
messageId: "provider-echo",
|
|
...(correlated ? { clientMessageId: "human-submission" } : {}),
|
|
},
|
|
],
|
|
});
|
|
const manager = new AgentManager({ clients: { codex }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
try {
|
|
for await (const _event of manager.streamAgent(agent.id, delivery.prompt, {
|
|
clientMessageId: delivery.messageId,
|
|
})) {
|
|
}
|
|
expect(manager.getTimeline(agent.id)).toEqual([
|
|
{
|
|
type: "user_message",
|
|
text: example,
|
|
clientMessageId: "human-submission",
|
|
messageId: "human-submission",
|
|
},
|
|
]);
|
|
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeInstanceOf(Date);
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
{
|
|
event: {
|
|
type: "turn_completed",
|
|
provider: "codex",
|
|
turnId: "hook-turn",
|
|
} satisfies AgentStreamEvent,
|
|
outcome: { kind: "completed" },
|
|
},
|
|
{
|
|
event: {
|
|
type: "turn_failed",
|
|
provider: "codex",
|
|
turnId: "hook-turn",
|
|
error: "Failed",
|
|
code: "test",
|
|
} satisfies AgentStreamEvent,
|
|
outcome: { kind: "failed", error: { message: "Failed", code: "test" } },
|
|
},
|
|
{
|
|
event: {
|
|
type: "turn_canceled",
|
|
provider: "codex",
|
|
turnId: "hook-turn",
|
|
reason: "Stopped",
|
|
} satisfies AgentStreamEvent,
|
|
outcome: { kind: "canceled", reason: "Stopped" },
|
|
},
|
|
])(
|
|
"lifecycle hooks receive no conversation for $event.type and stream dispatch never reads timeline items",
|
|
async ({ event: terminalEvent, outcome }) => {
|
|
class CountingTimelineStore extends InMemoryAgentTimelineStore {
|
|
itemReads = 0;
|
|
override getItems(agentId: string): AgentTimelineItem[] {
|
|
this.itemReads += 1;
|
|
return super.getItems(agentId);
|
|
}
|
|
}
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-hook-payload-"));
|
|
const client = new SessionRecordingAgentClient();
|
|
const timelineStore = new CountingTimelineStore();
|
|
const events: Array<{ name: string; event: unknown }> = [];
|
|
const manager = new AgentManager({
|
|
clients: { codex: client },
|
|
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
logger,
|
|
timelineStore,
|
|
pluginLifecycle: {
|
|
before: async (_name, request) => request,
|
|
emit: (name, event) => {
|
|
events.push({ name, event });
|
|
},
|
|
},
|
|
});
|
|
onTestFinished(async () => {
|
|
await manager.closeAgent(manager.listAgents()[0]!.id);
|
|
await manager.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
await manager.appendTimelineItem(agent.id, {
|
|
type: "assistant_message",
|
|
text: "A long conversation".repeat(10_000),
|
|
});
|
|
expect(manager.getTimeline(agent.id)).toHaveLength(1);
|
|
const readsBeforeDispatch = timelineStore.itemReads;
|
|
const session = client.sessions[0]!;
|
|
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
|
|
await expect
|
|
.poll(() => events.filter(({ name }) => name === "agent.turn.before").length)
|
|
.toBe(1);
|
|
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
|
session.pushEvent(terminalEvent);
|
|
await expect
|
|
.poll(() => events.filter(({ name }) => name === "agent.turn.after"))
|
|
.toEqual([
|
|
{
|
|
name: "agent.turn.after",
|
|
event: {
|
|
agent: {
|
|
id: agent.id,
|
|
workspaceId: null,
|
|
parentAgentId: null,
|
|
provider: "codex",
|
|
cwd: workdir,
|
|
title: null,
|
|
},
|
|
turnId: "hook-turn",
|
|
outcome,
|
|
timeline: [],
|
|
},
|
|
},
|
|
]);
|
|
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
|
},
|
|
);
|
|
|
|
test.each(["live", "history", "accepted"] as const)(
|
|
"batched agent entries retain their identities through %s ingestion",
|
|
async (ingestion) => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-batch-provenance-"));
|
|
const entries = [
|
|
{
|
|
id: "report",
|
|
source: { kind: "agent-message" as const, agentId: "worker", serverId: "remote-host" },
|
|
text: "Result <ready>",
|
|
},
|
|
{
|
|
id: "finish",
|
|
source: {
|
|
kind: "agent-notification" as const,
|
|
agentId: "worker",
|
|
serverId: "remote-host",
|
|
event: "finished" as const,
|
|
},
|
|
text: "Worker finished",
|
|
},
|
|
];
|
|
const prompt = entries.map(formatAgentMessage).join("\n\n");
|
|
const item: AgentTimelineItem = {
|
|
type: "user_message",
|
|
text: prompt,
|
|
clientMessageId: "report",
|
|
messageId: "provider-batch",
|
|
};
|
|
const codex = fakeCodexEmitting(
|
|
ingestion === "history" ? { historyItems: [item] } : { turnItems: [item] },
|
|
);
|
|
const manager = new AgentManager({ clients: { codex }, logger });
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
try {
|
|
if (ingestion === "history")
|
|
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
|
else if (ingestion === "accepted") {
|
|
for await (const _event of manager.streamAgent(agent.id, prompt, {
|
|
clientMessageId: "report",
|
|
})) {
|
|
}
|
|
} else await manager.runAgent(agent.id, { text: "start" });
|
|
expect(
|
|
manager
|
|
.getTimeline(agent.id)
|
|
.filter((row) => row.type === "tool_call")
|
|
.map((row) => ({ callId: row.callId, agentMessage: row.agentMessage })),
|
|
).toEqual([
|
|
{
|
|
callId: "paseo-agent-message:report",
|
|
agentMessage: {
|
|
event: "message",
|
|
sender: { id: "worker", serverId: "remote-host" },
|
|
text: "Result <ready>",
|
|
},
|
|
},
|
|
{
|
|
callId: "paseo-agent-message:finish",
|
|
agentMessage: {
|
|
event: "finished",
|
|
sender: { id: "worker", serverId: "remote-host" },
|
|
text: "Worker finished",
|
|
},
|
|
},
|
|
]);
|
|
expect(manager.getTimeline(agent.id).filter((row) => row.type === "user_message")).toEqual(
|
|
[],
|
|
);
|
|
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
|
} finally {
|
|
await manager.closeAgent(agent.id);
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("reads the last durable assistant response without loading a stored-only child", async () => {
|
|
const store = new RecordingTimelineStore();
|
|
await store.appendCommitted("stored-child", {
|
|
type: "assistant_message",
|
|
text: "Partial work saved before restart",
|
|
});
|
|
const manager = new AgentManager({ clients: {}, durableTimelineStore: store, logger });
|
|
expect(manager.getAgent("stored-child")).toBeNull();
|
|
expect(await manager.getLastAssistantMessage("stored-child")).toBe(
|
|
"Partial work saved before restart",
|
|
);
|
|
expect(manager.getAgent("stored-child")).toBeNull();
|
|
});
|
|
|
|
function runtimeCleanup(manager: AgentManager) {
|
|
let now = Date.now();
|
|
let protectedCaller = (_agentId: string) => false;
|
|
const cleanup = createIdleRuntimeCleanup({
|
|
enabled: true,
|
|
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
|
logger,
|
|
now: () => now,
|
|
isProtected: (agentId) => protectedCaller(agentId),
|
|
schedule: () => () => {},
|
|
});
|
|
onTestFinished(() => cleanup.close());
|
|
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
|
|
now = options.now.getTime();
|
|
protectedCaller = options.isProtected ?? (() => false);
|
|
return cleanup.sweep();
|
|
};
|
|
}
|
|
|
|
function idleCleanupHarness() {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const commands: AgentPromptInput[] = [];
|
|
const prompts: AgentPromptInput[] = [];
|
|
const recordPrompts = (session: AgentSession) => {
|
|
session.tryHandleOutOfBand = (prompt) =>
|
|
prompt === "/maintenance"
|
|
? {
|
|
run: async () => {
|
|
commands.push(prompt);
|
|
},
|
|
}
|
|
: null;
|
|
const start = session.startTurn.bind(session);
|
|
session.startTurn = (prompt, options) => {
|
|
prompts.push(prompt);
|
|
return start(prompt, options);
|
|
};
|
|
return session;
|
|
};
|
|
const sessions: AgentSession[] = [];
|
|
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
|
const closeEntered = deferred<void>();
|
|
let closeGate: Promise<void> | undefined;
|
|
let closes = 0;
|
|
const client = new (class extends TestAgentClient {
|
|
override async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
config?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
const session = await super.resumeSession(handle, config);
|
|
session.describePersistence = () => handle;
|
|
return recordPrompts(session);
|
|
}
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const index = sessions.length;
|
|
const session: AgentSession = await super.createSession(config);
|
|
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
|
session.close = async () => {
|
|
closes++;
|
|
closeEntered.resolve();
|
|
await closeGate;
|
|
};
|
|
sessions.push(session);
|
|
return recordPrompts(session);
|
|
}
|
|
})();
|
|
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
onTestFinished(async () => {
|
|
closeGate = undefined;
|
|
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
|
await storage.flush();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
return {
|
|
commands,
|
|
sweep: runtimeCleanup(manager),
|
|
manager,
|
|
storage,
|
|
sessions,
|
|
probes,
|
|
prompts,
|
|
closeEntered,
|
|
get closes() {
|
|
return closes;
|
|
},
|
|
holdClose: (gate: Promise<void>) => {
|
|
closeGate = gate;
|
|
},
|
|
create: (labels?: Record<string, string>) =>
|
|
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
labels,
|
|
}),
|
|
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
|
|
};
|
|
}
|
|
|
|
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
|
|
const h = idleCleanupHarness();
|
|
const agent = await h.create();
|
|
const entered = deferred<void>();
|
|
const probe = deferred<boolean>();
|
|
h.probes[0] = () => {
|
|
entered.resolve();
|
|
return probe.promise;
|
|
};
|
|
const sweep = h.sweep({ now: h.later(61) });
|
|
await entered.promise;
|
|
await h.manager.runAgent(agent.id, "arrived during safety query");
|
|
probe.resolve(true);
|
|
expect((await sweep).collected).toEqual([]);
|
|
expect(h.closes).toBe(0);
|
|
});
|
|
|
|
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
|
|
const h = idleCleanupHarness();
|
|
const parent = await h.create();
|
|
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
|
|
h.probes[0] = async () => true;
|
|
h.probes[1] = async () => false;
|
|
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
|
|
expect(h.closes).toBe(0);
|
|
});
|
|
|
|
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
|
|
const h = idleCleanupHarness();
|
|
await h.create();
|
|
let queued = false;
|
|
h.probes[0] = async () => {
|
|
queued = true;
|
|
return true;
|
|
};
|
|
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
|
|
expect(h.closes).toBe(0);
|
|
});
|
|
|
|
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
|
|
const h = idleCleanupHarness();
|
|
const agent = await h.create();
|
|
h.probes[0] = async () => true;
|
|
const gate = deferred<void>();
|
|
h.holdClose(gate.promise);
|
|
const sweep = h.sweep({ now: h.later(61) });
|
|
await h.closeEntered.promise;
|
|
const run = h.manager.runAgent(agent.id, "arrived while closing");
|
|
gate.resolve();
|
|
expect((await sweep).collected).toHaveLength(1);
|
|
const result = await run;
|
|
expect(result.sessionId).toBe(agent.persistence?.sessionId);
|
|
expect(h.closes).toBe(1);
|
|
expect(h.prompts).toEqual(["arrived while closing"]);
|
|
});
|
|
|
|
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
|
|
const h = idleCleanupHarness();
|
|
const agent = await h.create();
|
|
h.probes[0] = async () => true;
|
|
const gate = deferred<void>();
|
|
h.holdClose(gate.promise);
|
|
const sweep = h.sweep({ now: h.later(61) });
|
|
await h.closeEntered.promise;
|
|
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
const deliveredWhileClosing = [...h.commands];
|
|
gate.resolve();
|
|
await sweep;
|
|
expect(deliveredWhileClosing).toEqual([]);
|
|
expect(await command).toEqual({ disposition: "out_of_band" });
|
|
expect(h.commands).toEqual(["/maintenance"]);
|
|
expect(h.prompts).toEqual([]);
|
|
});
|
|
|
|
test("folds provider background work and clears it when the agent closes", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let activeSession: TestAgentSession | null = null;
|
|
class BackgroundWorkClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
activeSession = new TestAgentSession(config);
|
|
return activeSession;
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new BackgroundWorkClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000201",
|
|
});
|
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
|
|
|
|
activeSession?.pushEvent({
|
|
type: "background_work",
|
|
provider: "codex",
|
|
items: [{ id: "bash-1", kind: "shell", description: "sleep 20" }],
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(manager.listAgentBackgroundWork(snapshot.id)).toEqual([
|
|
{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: expect.any(String) },
|
|
]),
|
|
);
|
|
expect(events).toContainEqual({
|
|
type: "background_work",
|
|
agentId: snapshot.id,
|
|
items: [expect.objectContaining({ id: "bash-1" })],
|
|
});
|
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
|
expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toEqual({ count: 1 });
|
|
expect(
|
|
events.some((event) => event.type === "agent_stream" && event.event.type === "background_work"),
|
|
).toBe(false);
|
|
|
|
await manager.closeAgent(snapshot.id);
|
|
|
|
expect(events).toContainEqual({ type: "background_work", agentId: snapshot.id, items: [] });
|
|
expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toBeUndefined();
|
|
});
|
|
|
|
test("does not deliver an internal agent's background work to global subscribers", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-internal-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
let activeSession: TestAgentSession | null = null;
|
|
class BackgroundWorkClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
activeSession = new TestAgentSession(config);
|
|
return activeSession;
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new BackgroundWorkClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000202",
|
|
});
|
|
const events: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => events.push(event), { replayState: false });
|
|
const agent = await manager.createAgent(
|
|
{ provider: "codex", cwd: workdir, internal: true },
|
|
undefined,
|
|
{ workspaceId: undefined },
|
|
);
|
|
const agentEvents: AgentManagerEvent[] = [];
|
|
manager.subscribe((event) => agentEvents.push(event), {
|
|
agentId: agent.id,
|
|
replayState: false,
|
|
});
|
|
|
|
activeSession?.pushEvent({
|
|
type: "background_work",
|
|
provider: "codex",
|
|
items: [{ id: "bash-1", kind: "shell", description: null }],
|
|
});
|
|
// The per-agent subscriber proves the event was emitted before the global one is checked.
|
|
await vi.waitFor(() =>
|
|
expect(agentEvents).toContainEqual(
|
|
expect.objectContaining({ type: "background_work", agentId: agent.id }),
|
|
),
|
|
);
|
|
|
|
expect(events.some((event) => event.type === "background_work")).toBe(false);
|
|
await expect(
|
|
manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "bash-1", stopToken: "token" }),
|
|
).rejects.toThrow("Unknown agent");
|
|
});
|
|
|
|
test("stops only a currently owned public background item with its current token", async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "agent-background-stop-"));
|
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
const stopped: string[] = [];
|
|
let activeSession: TestAgentSession | null = null;
|
|
class StoppableSession extends TestAgentSession {
|
|
async stopBackgroundWork(id: string) {
|
|
stopped.push(id);
|
|
}
|
|
}
|
|
class BackgroundWorkClient extends TestAgentClient {
|
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
activeSession = new StoppableSession(config);
|
|
return activeSession;
|
|
}
|
|
}
|
|
const manager = new AgentManager({
|
|
clients: { codex: new BackgroundWorkClient() },
|
|
registry: storage,
|
|
logger,
|
|
idFactory: () => "00000000-0000-4000-8000-000000000204",
|
|
});
|
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
workspaceId: undefined,
|
|
});
|
|
activeSession?.pushEvent({
|
|
type: "background_work",
|
|
provider: "codex",
|
|
items: [
|
|
{ id: "one", kind: "shell", description: null, canStop: true },
|
|
{ id: "two", kind: "other", description: null },
|
|
],
|
|
});
|
|
await vi.waitFor(() => expect(manager.listAgentBackgroundWork(agent.id)).toHaveLength(2));
|
|
const [one] = manager.listAgentBackgroundWork(agent.id);
|
|
await expect(
|
|
manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "one", stopToken: "stale-token" }),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
manager.stopAgentBackgroundWork({
|
|
agentId: agent.id,
|
|
itemId: "two",
|
|
stopToken: one.stopToken!,
|
|
}),
|
|
).rejects.toThrow();
|
|
await manager.stopAgentBackgroundWork({
|
|
agentId: agent.id,
|
|
itemId: "one",
|
|
stopToken: one.stopToken!,
|
|
});
|
|
expect(stopped).toEqual(["one"]);
|
|
activeSession?.pushEvent({
|
|
type: "background_work",
|
|
provider: "codex",
|
|
items: [{ id: "one", kind: "shell", description: null }],
|
|
});
|
|
activeSession?.pushEvent({
|
|
type: "background_work",
|
|
provider: "codex",
|
|
items: [{ id: "one", kind: "shell", description: null, canStop: true }],
|
|
});
|
|
await expect(
|
|
manager.stopAgentBackgroundWork({
|
|
agentId: agent.id,
|
|
itemId: "one",
|
|
stopToken: one.stopToken!,
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(stopped).toEqual(["one"]);
|
|
await manager.closeAgent(agent.id);
|
|
});
|