Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/components/adaptive-modal-sheet.tsx
#	packages/app/src/hooks/use-providers-snapshot.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/agent/agent-management-mcp.ts
#	packages/server/src/server/agent/mcp-server.ts
#	packages/server/src/server/persistence-hooks.ts
#	packages/server/src/server/schedule/service.ts
#	packages/server/src/server/session.ts
This commit is contained in:
Mohamed Boudra
2026-04-16 14:14:32 +07:00
67 changed files with 2795 additions and 1683 deletions
@@ -2838,6 +2838,7 @@ export class DaemonClient {
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
requestId?: string;
}): Promise<RefreshProvidersSnapshotPayload> {
return this.sendCorrelatedSessionRequest({
@@ -2845,6 +2846,7 @@ export class DaemonClient {
message: {
type: "refresh_providers_snapshot_request",
cwd: options?.cwd,
providers: options?.providers,
},
responseType: "refresh_providers_snapshot_response",
timeout: 5000,
@@ -1,144 +0,0 @@
import type pino from "pino";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "hydrateTimelineFromProvider"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentStorage, "get">;
logger: pino.Logger;
};
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
export class AgentLoadingService {
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
private readonly logger: pino.Logger;
constructor(options: AgentLoadingServiceOptions) {
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.logger = options.logger.child({ component: "agent-loading" });
}
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
if (inflight) {
return inflight;
}
const initPromise = this.loadStoredAgent(options);
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
try {
return await initPromise;
} finally {
pendingAgentBootstrapLoads.delete(options.agentId);
}
}
async resumeAgent(options: {
handle: AgentPersistenceHandle;
overrides?: Partial<AgentSessionConfig>;
}): Promise<ManagedAgent> {
const snapshot = await this.agentManager.resumeAgentFromPersistence(
options.handle,
options.overrides,
);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
return snapshot;
}
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
if (!existing.persistence) {
return existing;
}
const snapshot = await this.agentManager.reloadAgentSession(options.agentId);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
return snapshot;
}
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
if (!handle) {
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
}
const snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
return snapshot;
}
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
const sessionConfig = buildSessionConfig(record);
if (!sessionConfig) {
throw new Error(
`Agent ${options.agentId} has an invalid provider '${record.provider}' and cannot be loaded`,
);
}
snapshot = await this.agentManager.createAgent(sessionConfig, options.agentId, {
labels: record.labels,
});
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent created from stored config",
);
}
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
return snapshot;
}
}
@@ -0,0 +1,80 @@
import type { Logger } from "pino";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
export interface EnsureAgentLoadedDeps {
agentManager: AgentManager;
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
logger: Logger;
}
export async function ensureAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
const existing = deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await deps.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
const config = buildSessionConfig(record, {
validProviders,
logger: deps.logger,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels });
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
await deps.agentManager.hydrateTimelineFromProvider(agentId);
return deps.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,9 @@ import type {
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
import type { Logger } from "pino";
import { buildProviderRegistry } from "./provider-registry.js";
import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js";
export type { ManagedAgent };
@@ -129,6 +132,95 @@ export function toAgentPayload(
return payload;
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
logger: Logger,
): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
const runtimeInfo = record.runtimeInfo
? {
provider: coerceAgentProvider(
logger,
providerRegistry,
record.runtimeInfo.provider,
record.id,
),
sessionId: record.runtimeInfo.sessionId,
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
? { model: record.runtimeInfo.model ?? null }
: {}),
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
: {}),
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
? { modeId: record.runtimeInfo.modeId ?? null }
: {}),
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
}
: undefined;
return {
id: record.id,
provider,
cwd: record.cwd,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
runtimeInfo,
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
}),
...(runtimeInfo ? { runtimeInfo } : {}),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
status: record.lastStatus,
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
lastUsage: undefined,
lastError: undefined,
title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: record.labels,
};
}
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => ({
raw: value,
parsed: Date.parse(value),
}))
.filter((value) => !Number.isNaN(value.parsed));
if (timestamps.length === 0) {
return record.updatedAt;
}
timestamps.sort((a, b) => b.parsed - a.parsed);
return timestamps[0].raw;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { ProviderDefinition } from "./provider-registry.js";
type TestDeps = {
@@ -29,10 +29,17 @@ function createTestDeps(): TestDeps {
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
notifyAgentState: vi.fn(),
getAgent: vi.fn(),
listAgents: vi.fn().mockReturnValue([]),
getTimeline: vi.fn().mockReturnValue([]),
resumeAgentFromPersistence: vi.fn(),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
respondToPermission: vi.fn(),
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
};
const agentStorageSpies = {
@@ -40,7 +47,7 @@ function createTestDeps(): TestDeps {
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn(),
list: vi.fn().mockResolvedValue([]),
remove: vi.fn(),
};
@@ -68,6 +75,43 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): Provi
};
}
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
const now = "2026-04-11T00:00:00.000Z";
return {
id: "stored-agent",
provider: "claude",
cwd: "/tmp/stored-project",
createdAt: now,
updatedAt: now,
lastActivityAt: now,
lastUserMessageAt: null,
title: "Stored agent",
labels: {},
lastStatus: "closed",
lastModeId: "default",
config: {
modeId: "default",
model: "claude-sonnet-4-20250514",
},
runtimeInfo: {
provider: "claude",
sessionId: "session-123",
model: "claude-sonnet-4-20250514",
},
features: [],
persistence: {
provider: "claude",
sessionId: "session-123",
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: "2026-04-12T00:00:00.000Z",
...overrides,
};
}
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -483,4 +527,223 @@ describe("agent snapshot MCP serialization", () => {
});
expect(Array.isArray(structured.agents[0].features)).toBe(true);
});
it("returns archived agent snapshots from storage for get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
});
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(record);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_status"];
const response = await tool.callback({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({
status: "closed",
snapshot: expect.objectContaining({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
title: "Stored agent",
status: "closed",
}),
});
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
});
it("does not expose internal stored agents from get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(
createStoredRecord({
id: "internal-agent",
internal: true,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_status"];
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found",
);
});
it("includes stored non-archived agents in list_agents by default", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "closed-agent", archivedAt: null }),
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({ id: "live-agent", archivedAt: null }),
createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({});
expect(response.structuredContent.agents).toEqual([
expect.objectContaining({ id: "live-agent" }),
expect.objectContaining({ id: "closed-agent", archivedAt: null }),
]);
});
it("includes archived stored agents in list_agents when requested", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({
id: "internal-archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
internal: true,
}),
createStoredRecord({ id: "not-archived-agent", archivedAt: null }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({ includeArchived: true });
expect(response.structuredContent.agents).toEqual([
expect.objectContaining({ id: "live-agent" }),
expect.objectContaining({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
}),
expect.objectContaining({
id: "not-archived-agent",
archivedAt: null,
}),
]);
});
it("loads archived agents before reading get_agent_activity", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({ id: "archived-activity-agent" });
const snapshot = {
id: "archived-activity-agent",
currentModeId: "default",
} as ManagedAgent;
spies.agentManager.getAgent
.mockReturnValueOnce(null)
.mockReturnValue(snapshot)
.mockReturnValue(snapshot);
spies.agentStorage.get.mockResolvedValue(record);
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
spies.agentManager.getTimeline.mockReturnValue([
{
kind: "status",
timestamp: "2026-04-11T00:00:00.000Z",
text: "Agent resumed",
},
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_activity"];
const response = await tool.callback({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
agentId: "archived-activity-agent",
updateCount: 1,
currentModeId: "default",
}),
);
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
"archived-activity-agent",
);
});
});
+52 -12
View File
@@ -12,9 +12,10 @@ import {
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -199,6 +200,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
version: "2.0.0",
});
const requireProviderRegistry = (): Record<AgentProvider, ProviderDefinition> => {
if (!providerRegistry) {
throw new Error("Provider registry is required to load stored agent records");
}
return providerRegistry;
};
const resolveCallerAgent = () => {
if (!callerAgentId) {
return null;
@@ -598,6 +606,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: snapshot.id,
callerAgentId,
logger: childLogger,
@@ -790,6 +799,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
logger: childLogger,
@@ -853,19 +863,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
async ({ agentId }) => {
const snapshot = agentManager.getAgent(agentId);
if (!snapshot) {
if (snapshot) {
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
childLogger,
);
return {
content: [],
structuredContent: ensureValidJson({
status: snapshot.lifecycle,
snapshot: structuredSnapshot,
}),
};
}
const record = await agentStorage.get(agentId);
if (!record || record.internal) {
throw new Error(`Agent ${agentId} not found`);
}
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
const structuredSnapshot = buildStoredAgentPayload(
record,
requireProviderRegistry(),
childLogger,
);
return {
content: [],
structuredContent: ensureValidJson({
status: snapshot.lifecycle,
status: structuredSnapshot.status,
snapshot: structuredSnapshot,
}),
};
@@ -877,21 +903,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "List agents",
description: "List all live agents managed by the server.",
inputSchema: {},
inputSchema: {
includeArchived: z.boolean().optional().default(false),
},
outputSchema: {
agents: z.array(AgentSnapshotPayloadSchema),
},
},
async () => {
const snapshots = agentManager.listAgents();
const agents = await Promise.all(
snapshots.map((snapshot) =>
async ({ includeArchived }) => {
const liveSnapshots = agentManager.listAgents();
const liveAgents = await Promise.all(
liveSnapshots.map((snapshot) =>
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
),
);
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
const storedRecords = await agentStorage.list();
const storedAgents = storedRecords
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
return {
content: [],
structuredContent: ensureValidJson({ agents }),
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
};
},
);
@@ -1566,6 +1601,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId, limit }) => {
await ensureAgentLoaded(agentId, {
agentManager,
agentStorage,
logger: childLogger,
});
const timeline = agentManager.getTimeline(agentId);
const snapshot = agentManager.getAgent(agentId);
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { setupFinishNotification } from "./mcp-shared.js";
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
describe("setupFinishNotification", () => {
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent = {
id: "child-agent",
lifecycle: "idle",
config: { title: "Child Agent" },
} as ManagedAgent;
const agentManager = {
getAgent: vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return {
id: "caller-agent",
lifecycle: "idle",
config: { title: "Caller Agent" },
} as ManagedAgent;
}
return null;
}),
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
hasInFlightRun: vi.fn().mockReturnValue(false),
streamAgent: vi.fn(() => (async function* noop() {})()),
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
} as unknown as AgentManager;
const agentStorage = {
get: vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
),
} as unknown as AgentStorage;
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
await vi.waitFor(() => {
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
});
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
});
});
@@ -187,18 +187,19 @@ export function startAgentRun(
interface SetupFinishNotificationParams {
agentManager: AgentManager;
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
logger: Logger;
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, childAgentId, callerAgentId, logger } = params;
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
function notify(reason: "finished" | "errored" | "needs permission"): void {
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> {
if (fired) {
return;
}
@@ -209,6 +210,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
}
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;
@@ -36,6 +36,7 @@ export const ProviderRuntimeSettingsSchema = z
.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
})
.strict();
@@ -66,6 +67,7 @@ export const ProviderOverrideSchema = z
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
})
@@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => {
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
});
test("disallowedTools flows through to runtime settings", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
disallowedTools: ["WebSearch", "WebFetch"],
},
},
});
expect(mockState.constructorArgs.claude[0]).toEqual({
runtimeSettings: {
command: undefined,
env: undefined,
disallowedTools: ["WebSearch", "WebFetch"],
},
});
});
test("derived provider inherits and merges disallowedTools from base", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
disallowedTools: ["WebSearch"],
},
zai: {
extends: "claude",
label: "ZAI",
disallowedTools: ["ComputerUse"],
},
},
});
const zaiArgs = mockState.constructorArgs.claude.find(
(entry) =>
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
"ComputerUse",
),
);
expect(zaiArgs).toBeDefined();
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
"WebSearch",
"ComputerUse",
]);
});
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
buildProviderRegistry(logger, {
providerOverrides: {
@@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
}
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
if (!override?.command && !override?.env) {
if (!override?.command && !override?.env && !override?.disallowedTools) {
return undefined;
}
@@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings
}
: undefined,
env: override.env,
disallowedTools: override.disallowedTools,
};
}
@@ -122,6 +123,10 @@ function mergeRuntimeSettings(
...(override?.env ?? {}),
}
: undefined,
disallowedTools:
base?.disallowedTools || override?.disallowedTools
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
: undefined,
};
}
@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
} as const;
describe("ProviderSnapshotManager", () => {
const ttlMs = 5 * 60 * 1_000;
const projectCwd = resolve("/tmp/project");
const projectACwd = resolve("/tmp/project-a");
const projectBCwd = resolve("/tmp/project-b");
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
);
});
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("refresh with providers only re-fetches matching providers", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: claudeFetchModels,
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet-4",
);
});
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
provider: "claude",
status: "ready",
models: [createModel("claude", "sonnet-4")],
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
expect(codexFetchModels).toHaveBeenCalledTimes(2);
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("refresh treats an empty providers list as a full refresh", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: claudeFetchModels,
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
});
manager.refresh({ cwd: projectCwd, providers: [] });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "loading",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
{
provider: "claude",
status: "loading",
label: "claude",
description: "claude test provider",
defaultModeId: null,
},
]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet-4.5",
);
});
expect(codexFetchModels).toHaveBeenCalledTimes(2);
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
manager.destroy();
});
test("refresh ignores provider filters that are not in the registry", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.1")],
});
expect(codexFetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("refresh during an in-flight refresh is a no-op", async () => {
const fetchModels = deferred<AgentModelDefinition[]>();
const fetchModes = deferred<AgentMode[]>();
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries));
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
},
]);
manager.refresh(projectCwd);
manager.refresh(projectCwd);
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
manager.refresh({ cwd: projectCwd });
manager.refresh({ cwd: projectCwd });
expect(changes).toHaveLength(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
let now = 1_000;
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += ttlMs + 1;
const staleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.1")],
modes: [createMode("auto")],
});
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
manager.destroy();
});
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
let now = 2_000;
const staleRefreshModels = deferred<AgentModelDefinition[]>();
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockImplementationOnce(async () => staleRefreshModels.promise);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += ttlMs + 1;
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
manager.destroy();
});
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
let now = 3_000;
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => [createModel("codex", "gpt-5.1")],
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
now += ttlMs - 1;
const freshSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
let now = 4_000;
const unavailableFetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
const unavailableIsAvailable = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const errorFetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockRejectedValueOnce(new Error("model lookup failed"))
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
isAvailable: unavailableIsAvailable,
fetchModels: async (cwd) => unavailableFetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: async (cwd) => errorFetchModels(cwd),
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
"unavailable",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
});
now += ttlMs + 1;
const staleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
});
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet",
);
manager.destroy();
});
test("getSnapshot respects an injected TTL", async () => {
let now = 5_000;
const customTtlMs = 100;
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs: customTtlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += customTtlMs - 1;
manager.getSnapshot(projectCwd);
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
now += 2;
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
manager.destroy();
});
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
const codexModels = deferred<AgentModelDefinition[]>();
const { registry, handles } = createRegistry([
@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
import type { ProviderDefinition } from "./provider-registry.js";
const DEFAULT_CWD_KEY = "__default__";
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
type ProviderSnapshotManagerOptions = {
ttlMs?: number;
now?: () => number;
};
type ProviderSnapshotRefreshOptions = {
cwd?: string;
providers?: AgentProvider[];
};
export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly lastCheckedAts = new Map<string, number>();
private readonly warmUps = new Map<string, Promise<void>>();
private readonly events = new EventEmitter();
private destroyed = false;
private readonly ttlMs: number;
private readonly now: () => number;
constructor(
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
private readonly logger: Logger,
) {}
options: ProviderSnapshotManagerOptions = {},
) {
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
this.now = options.now ?? Date.now;
}
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
const cwdKey = normalizeCwdKey(cwd);
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
void this.warmUp(cwd);
return entriesToArray(loadingEntries);
}
if (this.shouldRevalidate(cwdKey)) {
void this.warmUp(cwd);
}
return entriesToArray(entries);
}
refresh(cwd?: string): void {
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
const { cwd } = options;
const cwdKey = normalizeCwdKey(cwd);
if (this.warmUps.has(cwdKey)) {
const inFlight = this.warmUps.get(cwdKey);
if (inFlight) {
await inFlight;
return;
}
this.resetSnapshotToLoading(cwdKey);
const providers = this.resolveRefreshProviders(options.providers);
this.resetSnapshotToLoading(cwdKey, providers);
this.emitChange(cwdKey);
void this.warmUp(cwd);
await this.warmUp(cwd, providers);
}
on(event: "change", listener: ProviderSnapshotChangeListener): this {
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
this.destroyed = true;
this.events.removeAllListeners();
this.snapshots.clear();
this.lastCheckedAts.clear();
this.warmUps.clear();
}
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
return entries;
}
private async warmUp(cwd?: string): Promise<void> {
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
const cwdKey = normalizeCwdKey(cwd);
const inFlight = this.warmUps.get(cwdKey);
if (inFlight) {
return inFlight;
}
const providersToRefresh = providers ?? this.getProviderIds();
const warmUpPromise = Promise.allSettled(
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
).then(() => undefined);
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
).then(() => {
if (!providers) {
this.lastCheckedAts.set(cwdKey, this.now());
}
});
this.warmUps.set(cwdKey, warmUpPromise);
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
}
const snapshot = this.getOrCreateSnapshot(cwdKey);
snapshot.set(provider, {
provider,
status: "loading",
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
try {
const client = definition.createClient(this.logger);
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
}
private shouldRevalidate(cwdKey: string): boolean {
if (this.warmUps.has(cwdKey)) {
return false;
}
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
if (lastCheckedAt === undefined) {
return false;
}
return this.now() - lastCheckedAt > this.ttlMs;
}
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
const existing = this.snapshots.get(cwdKey);
if (existing) {
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
return created;
}
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
private resetSnapshotToLoading(
cwdKey: string,
providers?: AgentProvider[],
): Map<AgentProvider, ProviderSnapshotEntry> {
const snapshot = this.getOrCreateSnapshot(cwdKey);
snapshot.clear();
for (const [provider, entry] of this.createLoadingEntries()) {
snapshot.set(provider, entry);
const loadingEntries = this.createLoadingEntries();
if (!providers) {
snapshot.clear();
for (const [provider, entry] of loadingEntries) {
snapshot.set(provider, entry);
}
return snapshot;
}
for (const provider of providers) {
const loadingEntry = loadingEntries.get(provider);
if (!loadingEntry) continue;
const existing = snapshot.get(provider);
snapshot.set(provider, {
...loadingEntry,
models: existing?.models,
modes: existing?.modes,
fetchedAt: existing?.fetchedAt,
});
}
return snapshot;
}
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
private getProviderIds(): AgentProvider[] {
return Object.keys(this.providerRegistry) as AgentProvider[];
}
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
if (!providers || providers.length === 0) {
return undefined;
}
const providerIds = new Set(this.getProviderIds());
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
}
}
function normalizeCwdKey(cwd?: string): string {
@@ -2146,6 +2146,12 @@ class ClaudeAgentSession implements AgentSession {
if (this.claudeSessionId) {
base.resume = this.claudeSessionId;
}
if (this.runtimeSettings?.disallowedTools?.length) {
base.disallowedTools = [
...(base.disallowedTools ?? []),
...this.runtimeSettings.disallowedTools,
];
}
return this.applyRuntimeSettings(base);
}
@@ -0,0 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const mockState = vi.hoisted(() => ({
superConstructorOptions: [] as unknown[],
}));
vi.mock("./acp-agent.js", () => ({
ACPAgentClient: class ACPAgentClient {
readonly provider: string;
constructor(options: unknown) {
this.provider = "acp";
mockState.superConstructorOptions.push(options);
}
},
}));
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient", () => {
test("passes the custom command only as defaultCommand", () => {
new GenericACPAgentClient({
logger: createTestLogger(),
command: ["hermes", "acp"],
env: {
HERMES_LOG: "info",
},
});
expect(mockState.superConstructorOptions).toEqual([
{
provider: "acp",
logger: expect.any(Object),
runtimeSettings: {
env: {
HERMES_LOG: "info",
},
},
defaultCommand: ["hermes", "acp"],
},
]);
});
});
@@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
provider: "acp",
logger: options.logger,
runtimeSettings: {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
},
defaultCommand: options.command as [string, ...string[]],
@@ -36,6 +36,7 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
@@ -1164,6 +1165,150 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function mapOpenCodeTodosToTimelineItems(
todos: Array<{ content?: string | null; status?: string | null }>,
): Extract<AgentTimelineItem, { type: "todo" }> {
return {
type: "todo",
items: todos.flatMap((todo) => {
const text = readNonEmptyString(todo.content);
if (!text) {
return [];
}
return [
{
text,
completed: todo.status === "completed",
},
];
}),
};
}
function createCompactionTimelineItem(
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
): Extract<AgentTimelineItem, { type: "compaction" }> {
return {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
};
}
const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const;
const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const;
const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const;
const PERMISSION_TITLE_BY_NAME: Record<string, string> = {
external_directory: "Access external directory",
bash: "Run shell command",
read: "Read files",
read_file: "Read files",
write: "Write files",
write_file: "Write files",
create_file: "Write files",
edit: "Edit files",
apply_patch: "Edit files",
apply_diff: "Edit files",
};
function toHumanReadablePermissionTitle(permission: string): string {
const mapped = PERMISSION_TITLE_BY_NAME[permission];
if (mapped) {
return mapped;
}
const normalized = permission
.split(/[\s_-]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ");
return normalized.length > 0 ? normalized : "Permission request";
}
function readFirstStringFromRecord(
record: Record<string, unknown> | null,
keys: readonly string[],
): string | null {
if (!record) {
return null;
}
for (const key of keys) {
const value = readNonEmptyString(record[key]);
if (value) {
return value;
}
}
return null;
}
function readPermissionField(
metadata: Record<string, unknown> | null,
keys: readonly string[],
): string | null {
const direct = readFirstStringFromRecord(metadata, keys);
if (direct) {
return direct;
}
const nestedInput = readOpenCodeRecord(metadata?.input);
return readFirstStringFromRecord(nestedInput, keys);
}
function buildOpenCodePermissionInput(params: {
patterns: string[];
metadata: Record<string, unknown> | null;
tool: Record<string, unknown> | null;
command: string | null;
}): Record<string, unknown> {
return {
...(params.patterns.length > 0 ? { patterns: params.patterns } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
...(params.tool ? { tool: params.tool } : {}),
...(params.command ? { command: params.command } : {}),
};
}
function buildOpenCodePermissionDetail(params: {
permission: string;
input: Record<string, unknown>;
command: string | null;
cwd: string | null;
}): ToolCallDetail {
if (params.command) {
return {
type: "shell",
command: params.command,
...(params.cwd ? { cwd: params.cwd } : {}),
};
}
return {
type: "unknown",
input: {
permission: params.permission,
...params.input,
},
output: null,
};
}
function buildOpenCodePermissionDescription(params: {
reason: string | null;
patterns: string[];
}): string | undefined {
const parts: string[] = [];
if (params.reason) {
parts.push(params.reason);
}
if (params.patterns.length > 0) {
parts.push(`Scope: ${params.patterns.join(", ")}`);
}
return parts.length > 0 ? parts.join(" - ") : undefined;
}
export function translateOpenCodeEvent(
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
@@ -1263,6 +1408,12 @@ export function translateOpenCodeEvent(
item: parsedToolPart.data,
});
}
} else if (part.type === "compaction") {
events.push({
type: "timeline",
provider: "opencode",
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
});
} else if (part.type === "step-finish") {
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
@@ -1320,6 +1471,31 @@ export function translateOpenCodeEvent(
break;
}
const metadata = readOpenCodeRecord(event.properties.metadata);
const tool = readOpenCodeRecord(event.properties.tool);
const patterns = Array.isArray(event.properties.patterns)
? event.properties.patterns.filter((value): value is string => typeof value === "string")
: [];
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
const input = buildOpenCodePermissionInput({
patterns,
metadata,
tool,
command,
});
const detail = buildOpenCodePermissionDetail({
permission: event.properties.permission,
input,
command,
cwd,
});
const description = buildOpenCodePermissionDescription({
reason,
patterns,
});
events.push({
type: "permission_requested",
provider: "opencode",
@@ -1328,9 +1504,10 @@ export function translateOpenCodeEvent(
provider: "opencode",
name: event.properties.permission,
kind: "tool",
title: event.properties.permission,
description: event.properties.patterns?.join(", "),
input: event.properties.metadata,
title: toHumanReadablePermissionTitle(event.properties.permission),
...(description ? { description } : {}),
input,
detail,
},
});
break;
@@ -1383,6 +1560,32 @@ export function translateOpenCodeEvent(
break;
}
case "todo.updated": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
events.push({
type: "timeline",
provider: "opencode",
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
});
break;
}
case "session.compacted": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
events.push({
type: "timeline",
provider: "opencode",
item: createCompactionTimelineItem("completed"),
});
break;
}
case "session.idle": {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
@@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
const state = createState();
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "external_directory",
patterns: ["/home/user/secrets/*"],
metadata: {
command: "ls /home/user/secrets",
reason: "Need to inspect generated files",
},
tool: {
messageID: "message-1",
callID: "call-1",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description: "Need to inspect generated files - Scope: /home/user/secrets/*",
input: {
patterns: ["/home/user/secrets/*"],
metadata: {
command: "ls /home/user/secrets",
reason: "Need to inspect generated files",
},
tool: {
messageID: "message-1",
callID: "call-1",
},
command: "ls /home/user/secrets",
},
detail: {
type: "shell",
command: "ls /home/user/secrets",
},
},
},
]);
});
it("falls back to unknown permission detail when command metadata is absent", () => {
const state = createState();
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-2",
sessionID: "session-1",
permission: "external_directory",
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-2",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description: "Need to access temporary checkout - Scope: /tmp/outside/*",
input: {
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
detail: {
type: "unknown",
input: {
permission: "external_directory",
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
output: null,
},
},
},
]);
});
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
@@ -310,6 +418,96 @@ describe("translateOpenCodeEvent", () => {
});
});
it("emits normalized todo timeline items from todo.updated", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "todo.updated",
properties: {
sessionID: "session-1",
todos: [
{ content: "Outline", status: "pending", priority: "high" },
{ content: "Ship", status: "completed", priority: "medium" },
{ content: " ", status: "completed", priority: "low" },
],
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "todo",
items: [
{ text: "Outline", completed: false },
{ text: "Ship", completed: true },
],
},
},
]);
});
it("emits compaction loading timeline items from compaction parts", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "compaction-part-1",
sessionID: "session-1",
messageID: "message-compaction-1",
type: "compaction",
auto: true,
},
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
},
]);
});
it("emits compaction completed timeline items from session.compacted", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "session.compacted",
properties: {
sessionID: "session-1",
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
it("emits reasoning from message.part.delta events", () => {
const state = createState();
@@ -510,6 +708,99 @@ describe("translateOpenCodeEvent", () => {
expect(result).toEqual([]);
});
it("emits turn_completed from session.status idle", () => {
const state = createState();
state.streamedPartKeys.add("text:part-1");
state.partTypes.set("part-1", "text");
const result = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: { type: "idle" },
},
},
state,
);
expect(result).toEqual([
{
type: "turn_completed",
provider: "opencode",
usage: undefined,
},
]);
expect(state.streamedPartKeys.size).toBe(0);
expect(state.partTypes.size).toBe(0);
});
it("emits turn_failed from fatal session.status retry", () => {
const state = createState();
state.streamedPartKeys.add("text:part-1");
state.partTypes.set("part-1", "text");
const result = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "retry",
attempt: 2,
message: "Invalid API key",
next: Date.now() + 1000,
},
},
},
state,
);
expect(result).toEqual([
{
type: "turn_failed",
provider: "opencode",
error: "Invalid API key",
},
]);
expect(state.streamedPartKeys.size).toBe(0);
expect(state.partTypes.size).toBe(0);
});
it("ignores transient session.status updates", () => {
const state = createState();
const busy = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: { type: "busy" },
},
},
state,
);
const retry = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "retry",
attempt: 1,
message: "rate limited",
next: Date.now() + 1000,
},
},
},
state,
);
expect(busy).toEqual([]);
expect(retry).toEqual([]);
});
it("emits structured assistant output when schema mode completes without text parts", () => {
const state = createState();
+75 -28
View File
@@ -1,9 +1,11 @@
import type pino from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type {
AgentPersistenceHandle,
AgentProvider,
AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
@@ -11,6 +13,8 @@ type LoggerLike = {
warn(...args: any[]): void;
};
const DEFAULT_AGENT_PROVIDER = "claude";
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
@@ -23,6 +27,18 @@ type BuildSessionConfigOptions = {
logger?: LoggerLike;
};
type RegisteredProviders = ReturnType<typeof buildProviderRegistry> | Iterable<AgentProvider>;
function isProviderRegistry(
registeredProviders: RegisteredProviders,
): registeredProviders is ReturnType<typeof buildProviderRegistry> {
return (
typeof registeredProviders === "object" &&
registeredProviders !== null &&
!(Symbol.iterator in registeredProviders)
);
}
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
@@ -90,30 +106,6 @@ export function buildSessionConfig(
};
}
export function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
) {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
};
}
export function extractTimestamps(record: StoredAgentRecord): {
createdAt: Date;
updatedAt: Date;
@@ -127,3 +119,58 @@ export function extractTimestamps(record: StoredAgentRecord): {
labels: record.labels,
};
}
function hasRegisteredProvider(registeredProviders: RegisteredProviders, value: string): boolean {
if (isProviderRegistry(registeredProviders)) {
return Object.prototype.hasOwnProperty.call(registeredProviders, value);
}
return new Set(registeredProviders).has(value as AgentProvider);
}
export function isRegisteredProvider(
providerRegistry: ReturnType<typeof buildProviderRegistry>,
value: string,
): boolean {
return hasRegisteredProvider(providerRegistry, value);
}
export function coerceAgentProvider(
logger: LoggerLike,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
value: string,
agentId?: string,
): AgentProvider {
if (isRegisteredProvider(providerRegistry, value)) {
return value;
}
logger.warn(
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
`Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`,
);
return DEFAULT_AGENT_PROVIDER;
}
export function toAgentPersistenceHandle(
logger: LoggerLike,
registeredProviders: RegisteredProviders,
handle: StoredAgentRecord["persistence"],
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!hasRegisteredProvider(registeredProviders, provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
@@ -265,4 +265,62 @@ describe("ScheduleService", () => {
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]?.status).toBe("succeeded");
});
test("rejects archived target agents before loading them", async () => {
const manager = new AgentManager({ logger: createTestLogger() });
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
now: () => now,
});
await agentStorage.upsert({
id: "archived-agent",
provider: "claude",
cwd: tempDir,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
lastActivityAt: now.toISOString(),
lastUserMessageAt: null,
title: "Archived Agent",
labels: {},
lastStatus: "closed",
lastModeId: "default",
config: {
modeId: "default",
},
runtimeInfo: null,
features: [],
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: "2026-01-02T00:00:00.000Z",
});
await expect(
(service as any).executeSchedule({
id: "schedule-1",
name: null,
prompt: "Check archived agent",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "agent",
agentId: "archived-agent",
},
status: "active",
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
nextRunAt: now.toISOString(),
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
}),
).rejects.toThrow("Agent archived-agent is archived");
});
});
+11 -68
View File
@@ -2,15 +2,10 @@ import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentStorage } from "../agent/agent-storage.js";
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
} from "../persistence-hooks.js";
import { ensureAgentLoaded } from "../agent/agent-loading.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
@@ -21,7 +16,6 @@ import type {
} from "./types.js";
const SCHEDULE_TICK_INTERVAL_MS = 1000;
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
function trimOptionalName(value: string | null | undefined): string | null {
if (typeof value !== "string") {
@@ -386,7 +380,16 @@ export class ScheduleService {
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
if (schedule.target.type === "agent") {
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
const record = await this.agentStorage.get(schedule.target.agentId);
if (record?.archivedAt) {
throw new Error(`Agent ${schedule.target.agentId} is archived`);
}
const agent = await ensureAgentLoaded(schedule.target.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.logger,
});
if (this.agentManager.hasInFlightRun(agent.id)) {
throw new Error(`Agent ${agent.id} already has an active run`);
}
@@ -435,64 +438,4 @@ export class ScheduleService {
}),
};
}
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
if (record.archivedAt) {
throw new Error(`Agent ${agentId} is archived`);
}
let snapshot: ManagedAgent;
if (record.persistence?.provider && record.persistence?.sessionId) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
{
provider: record.persistence.provider as AgentSessionConfig["provider"],
sessionId: record.persistence.sessionId,
nativeHandle: record.persistence.nativeHandle,
metadata: record.persistence.metadata,
},
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
} else {
const config = buildSessionConfig(record, {
validProviders: this.agentManager.getRegisteredProviderIds(),
logger: this.logger,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
snapshot = await this.agentManager.createAgent(config, agentId, {
labels: record.labels,
});
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
return this.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
if (pendingAgentInitializations.get(agentId) === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
}
}
+66 -162
View File
@@ -62,6 +62,12 @@ import {
createVoiceTurnController,
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildConfigOverrides,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { ensureAgentLoaded } from "./agent/agent-loading.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -86,7 +92,12 @@ import type {
ManagedAgent,
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "./agent/agent-projections.js";
import {
buildStoredAgentPayload,
resolveEffectiveThinkingOptionId,
resolveStoredAgentPayloadUpdatedAt,
toAgentPayload,
} from "./agent/agent-projections.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
import {
appendTimelineItemIfAgentKnown,
@@ -104,19 +115,18 @@ import {
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import type {
AgentPersistenceHandle,
AgentPermissionResponse,
AgentProvider,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentSessionConfig,
AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
@@ -135,7 +145,6 @@ import {
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
import { AgentLoadingService } from "./agent-loading-service.js";
import {
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
@@ -195,7 +204,6 @@ import {
const execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
@@ -464,7 +472,6 @@ export type SessionOptions = {
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
agentLoadingService?: AgentLoadingService;
createAgentMcpTransport?: AgentMcpTransportFactory;
workspaceGitService: WorkspaceGitService;
daemonConfigStore: DaemonConfigStore;
@@ -570,54 +577,6 @@ function convertPCMToWavBuffer(
return wavBuffer;
}
function isRegisteredProvider(
providerRegistry: ReturnType<typeof buildProviderRegistry>,
value: string,
): boolean {
return Object.prototype.hasOwnProperty.call(providerRegistry, value);
}
function coerceAgentProvider(
logger: pino.Logger,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
value: string,
agentId?: string,
): AgentProvider {
if (isRegisteredProvider(providerRegistry, value)) {
return value;
}
logger.warn(
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
`Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`,
);
return DEFAULT_AGENT_PROVIDER;
}
function toAgentPersistenceHandle(
logger: pino.Logger,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
handle: StoredAgentRecord["persistence"],
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isRegisteredProvider(providerRegistry, provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -673,7 +632,6 @@ export class Session {
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly agentLoadingService: AgentLoadingService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null;
@@ -752,7 +710,6 @@ export class Session {
scheduleService,
loopService,
checkoutDiffManager,
agentLoadingService,
workspaceGitService,
daemonConfigStore,
mcpBaseUrl,
@@ -794,13 +751,6 @@ export class Session {
this.scheduleService = scheduleService;
this.loopService = loopService;
this.checkoutDiffManager = checkoutDiffManager;
this.agentLoadingService =
agentLoadingService ??
new AgentLoadingService({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.workspaceGitService = workspaceGitService;
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl ?? null;
@@ -1184,9 +1134,7 @@ export class Session {
const storedRecord = await this.agentStorage.get(agent.id);
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;
const payload = toAgentPayload(agent, { title });
const storedUpdatedAt = storedRecord
? this.resolveStoredAgentPayloadUpdatedAt(storedRecord)
: null;
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
if (storedUpdatedAt) {
const liveUpdatedAt = Date.parse(payload.updatedAt);
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
@@ -1199,90 +1147,7 @@ export class Session {
}
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record));
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const provider = coerceAgentProvider(
this.sessionLogger,
this.providerRegistry,
record.provider,
record.id,
);
const runtimeInfo = record.runtimeInfo
? {
provider: coerceAgentProvider(
this.sessionLogger,
this.providerRegistry,
record.runtimeInfo.provider,
record.id,
),
sessionId: record.runtimeInfo.sessionId,
model: record.runtimeInfo.model ?? null,
thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null,
modeId: record.runtimeInfo.modeId ?? null,
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
}
: undefined;
return {
id: record.id,
provider,
cwd: record.cwd,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
runtimeInfo,
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
}),
...(runtimeInfo ? { runtimeInfo } : {}),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
status: record.lastStatus,
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(
this.sessionLogger,
this.providerRegistry,
record.persistence,
),
lastUsage: undefined,
lastError: record.lastError ?? undefined,
title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: record.labels,
};
}
private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => ({
raw: value,
parsed: Date.parse(value),
}))
.filter((value) => !Number.isNaN(value.parsed));
timestamps.sort((a, b) => b.parsed - a.parsed);
return timestamps[0].raw;
}
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
return this.agentLoadingService.ensureAgentLoaded({ agentId });
return buildStoredAgentPayload(record, this.providerRegistry, this.sessionLogger);
}
private isProviderVisibleToClient(provider: string): boolean {
@@ -2633,7 +2498,11 @@ export class Session {
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
const startedAt = Date.now();
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
const existing = await this.ensureAgentLoaded(agentId);
const existing = await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.sessionLogger.info(
{ agentId, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.ensureAgentLoaded.done",
@@ -2857,7 +2726,11 @@ export class Session {
await this.unarchiveAgentState(agentId);
try {
await this.ensureAgentLoaded(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
return {
@@ -3057,11 +2930,9 @@ export class Session {
);
try {
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentLoadingService.resumeAgent({
handle,
overrides,
});
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
await this.unarchiveAgentState(snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
if (requestId) {
@@ -3099,10 +2970,32 @@ export class Session {
try {
await this.unarchiveAgentState(agentId);
if (this.agentManager.getAgent(agentId)) {
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(
this.sessionLogger,
this.providerRegistry,
record.persistence,
);
if (!handle) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
}
const snapshot = await this.agentLoadingService.refreshAgent({ agentId });
await this.agentManager.hydrateTimelineFromProvider(agentId);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) {
@@ -3352,7 +3245,10 @@ export class Session {
private async handleRefreshProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined);
await this.providerSnapshotManager?.refresh({
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
providers: msg.providers,
});
this.emit({
type: "refresh_providers_snapshot_response",
payload: {
@@ -6699,7 +6595,11 @@ export class Session {
: undefined;
try {
const snapshot = await this.ensureAgentLoaded(msg.agentId);
const snapshot = await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
@@ -6853,7 +6753,11 @@ export class Session {
const agentId = resolved.agentId;
await this.unarchiveAgentState(agentId);
await this.ensureAgentLoaded(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.sessionLogger.trace(
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
@@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => {
}
});
it("parses permission request detail compatibly", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-shell-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
input: {
command: "ls /tmp/outside",
},
detail: {
type: "shell",
command: "ls /tmp/outside",
cwd: "/home/dev/project",
},
},
},
},
});
expect(parsed.payload.event.type).toBe("permission_requested");
if (parsed.payload.event.type === "permission_requested") {
expect(parsed.payload.event.request.detail).toEqual({
type: "shell",
command: "ls /tmp/outside",
cwd: "/home/dev/project",
});
}
});
it("rejects removed initialize_agent_request inbound payload", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
+2
View File
@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
title: z.string().optional(),
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
detail: z.lazy(() => ToolCallDetailPayloadSchema).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
actions: z.array(AgentPermissionActionSchema).optional(),
metadata: z.record(z.unknown()).optional(),
@@ -883,6 +884,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
type: z.literal("refresh_providers_snapshot_request"),
cwd: z.string().optional(),
providers: z.array(AgentProviderSchema).optional(),
requestId: z.string(),
});