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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user