Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
@@ -32,7 +32,7 @@ export function createRelayE2eeTransportFactory(args: {
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger
logger: TransportLogger,
): DaemonTransport {
let channel: EncryptedChannel | null = null;
let opened = false;
@@ -124,9 +124,7 @@ export function createEncryptedTransport(
emitClose(event);
});
base.onError((event) => {
relayTransport.onerror?.(
event instanceof Error ? event : new Error(String(event))
);
relayTransport.onerror?.(event instanceof Error ? event : new Error(String(event)));
emitError(event);
});
@@ -181,10 +179,7 @@ function emitHandlers<TArgs extends unknown[]>(
}
}
function invokeHandler<TArgs extends unknown[]>(
handler: (...args: TArgs) => void,
...args: TArgs
) {
function invokeHandler<TArgs extends unknown[]>(handler: (...args: TArgs) => void, ...args: TArgs) {
try {
handler(...args);
} catch {
@@ -35,10 +35,8 @@ export class TerminalStreamManager {
private readonly maxBufferedBytes: number;
constructor(private readonly config: TerminalStreamManagerConfig) {
this.maxBufferedChunks =
config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
this.maxBufferedBytes =
config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
}
clearAll(): void {
@@ -53,10 +51,7 @@ export class TerminalStreamManager {
this.ackOffsets.delete(input.streamId);
}
subscribe(input: {
streamId: number;
handler: TerminalChunkHandler;
}): () => void {
subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void {
const { streamId, handler } = input;
if (!this.handlers.has(streamId)) {
this.handlers.set(streamId, new Set());
@@ -111,10 +106,7 @@ export class TerminalStreamManager {
}
}
private flushBufferedChunks(input: {
streamId: number;
handler: TerminalChunkHandler;
}): void {
private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void {
const buffered = this.bufferedChunks.get(input.streamId);
if (!buffered || buffered.chunks.length === 0) {
return;
@@ -143,10 +135,7 @@ export class TerminalStreamManager {
queue.chunks.push(input.chunk);
queue.bytes += input.chunk.data.byteLength;
while (
queue.chunks.length > this.maxBufferedChunks ||
queue.bytes > this.maxBufferedBytes
) {
while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) {
const removed = queue.chunks.shift();
if (!removed) {
break;
@@ -14,7 +14,7 @@ export type DaemonTransportFactory = (options: {
export type WebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> }
options?: { headers?: Record<string, string> },
) => WebSocketLike;
export type WebSocketLike = {
@@ -6,7 +6,7 @@ export function copyArrayBufferViewToBuffer(data: ArrayBufferView): ArrayBuffer
}
export function normalizeTransportPayload(
data: string | Uint8Array | ArrayBuffer
data: string | Uint8Array | ArrayBuffer,
): string | ArrayBuffer {
if (typeof data === "string" || data instanceof ArrayBuffer) {
return data;
@@ -40,7 +40,7 @@ describe("daemon-client transport helpers", () => {
onMessage: () => () => {},
},
"daemon-public-key",
{ warn: vi.fn() }
{ warn: vi.fn() },
);
expect(openHandler).not.toBeNull();
@@ -6,7 +6,7 @@ import type {
export function defaultWebSocketFactory(
url: string,
_options?: { headers?: Record<string, string> }
_options?: { headers?: Record<string, string> },
): WebSocketLike {
const globalWs = (globalThis as { WebSocket?: any }).WebSocket;
if (!globalWs) {
@@ -15,9 +15,7 @@ export function defaultWebSocketFactory(
return new globalWs(url);
}
export function createWebSocketTransportFactory(
factory: WebSocketFactory
): DaemonTransportFactory {
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
return ({ url, headers }) => {
const ws = factory(url, { headers });
if ("binaryType" in ws) {
@@ -46,7 +44,7 @@ export function createWebSocketTransportFactory(
export function bindWsHandler(
ws: WebSocketLike,
event: "open" | "close" | "error" | "message",
handler: (...args: any[]) => void
handler: (...args: any[]) => void,
): () => void {
if (typeof ws.addEventListener === "function") {
ws.addEventListener(event, handler);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,7 @@
* to understand how command output is delivered.
*/
import {
query,
type SDKUserMessage,
type SDKMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { query, type SDKUserMessage, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
@@ -103,7 +99,9 @@ async function investigateCommand(commandName: string): Promise<void> {
break;
case "user":
process.stdout.write(`User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`);
process.stdout.write(
`User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`,
);
break;
case "assistant":
@@ -142,7 +140,7 @@ async function investigateCommand(commandName: string): Promise<void> {
process.stdout.write(`Errors: ${JSON.stringify(message.errors)}\n`);
}
// Check for any other properties
const resultKeys = Object.keys(message).filter(k => !["type", "subtype"].includes(k));
const resultKeys = Object.keys(message).filter((k) => !["type", "subtype"].includes(k));
if (resultKeys.length > 0) {
process.stdout.write(`Other result properties: ${resultKeys.join(", ")}\n`);
for (const key of resultKeys) {
@@ -161,7 +159,6 @@ async function investigateCommand(commandName: string): Promise<void> {
}
process.stdout.write(`\nTotal messages received: ${messageCount}\n`);
} finally {
input.end();
await claudeQuery.return?.();
+6 -3
View File
@@ -136,7 +136,9 @@ async function executeCommand(commandName: string): Promise<void> {
// Iterate the query to process the command
let gotSystemInit = false;
for await (const message of claudeQuery) {
process.stdout.write(` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`);
process.stdout.write(
` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`,
);
if (message.type === "system" && message.subtype === "init") {
gotSystemInit = true;
@@ -149,7 +151,9 @@ async function executeCommand(commandName: string): Promise<void> {
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text") {
process.stdout.write(` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`);
process.stdout.write(
` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`,
);
}
}
}
@@ -199,7 +203,6 @@ async function main() {
// Actually execute a command to demonstrate it works:
// Using "context" as it's fast and doesn't require arguments
await executeCommand("context");
} catch (error) {
process.stderr.write(`ERROR: ${error}\n`);
process.exit(1);
@@ -35,7 +35,7 @@ describe("computeShouldNotifyClient", () => {
clientState: staleMobile,
allClientStates: [activeOnAgent, staleMobile],
agentId: "agent-1",
})
}),
).toBe(false);
});
@@ -52,7 +52,7 @@ describe("computeShouldNotifyClient", () => {
clientState: unknownClient,
allClientStates: [unknownClient],
agentId: "agent-2",
})
}),
).toBe(true);
});
@@ -69,7 +69,7 @@ describe("computeShouldNotifyClient", () => {
clientState: focusedWeb,
allClientStates: [focusedWeb],
agentId: "agent-3",
})
}),
).toBe(true);
});
@@ -86,7 +86,7 @@ describe("computeShouldNotifyClient", () => {
clientState: activeButNotFocused,
allClientStates: [activeButNotFocused],
agentId: "agent-4",
})
}),
).toBe(false);
});
@@ -109,7 +109,7 @@ describe("computeShouldNotifyClient", () => {
clientState: staleMobile,
allClientStates: [staleMobile, activeWeb],
agentId: "agent-5",
})
}),
).toBe(false);
});
@@ -132,7 +132,7 @@ describe("computeShouldNotifyClient", () => {
clientState: staleMobile,
allClientStates: [staleMobile, staleWeb],
agentId: "agent-6",
})
}),
).toBe(true);
});
@@ -155,7 +155,7 @@ describe("computeShouldNotifyClient", () => {
clientState: staleWeb,
allClientStates: [staleWeb, staleMobile],
agentId: "agent-7",
})
}),
).toBe(false);
});
@@ -172,7 +172,7 @@ describe("computeShouldNotifyClient", () => {
clientState: staleWeb,
allClientStates: [staleWeb],
agentId: "agent-8",
})
}),
).toBe(true);
});
});
@@ -183,7 +183,7 @@ describe("computeShouldSendPush", () => {
computeShouldSendPush({
reason: "error",
allClientStates: [],
})
}),
).toBe(false);
});
@@ -198,7 +198,7 @@ describe("computeShouldSendPush", () => {
appVisible: true,
}),
],
})
}),
).toBe(false);
});
@@ -213,7 +213,7 @@ describe("computeShouldSendPush", () => {
appVisible: true,
}),
],
})
}),
).toBe(false);
});
@@ -233,7 +233,7 @@ describe("computeShouldSendPush", () => {
appVisible: false,
}),
],
})
}),
).toBe(true);
});
});
@@ -18,12 +18,9 @@ type ComputePushNotificationInput = {
allClientStates: ClientAttentionState[];
};
function hasActiveClientOnAgent(
allClientStates: ClientAttentionState[],
agentId: string
): boolean {
function hasActiveClientOnAgent(allClientStates: ClientAttentionState[], agentId: string): boolean {
return allClientStates.some(
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale,
);
}
@@ -33,17 +30,17 @@ function hasActiveWebClient(allClientStates: ClientAttentionState[]): boolean {
function hasOtherCompetingClient(
clientState: ClientAttentionState,
allClientStates: ClientAttentionState[]
allClientStates: ClientAttentionState[],
): boolean {
return allClientStates.some(
(state) =>
state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
state !== clientState && (state.deviceType === "mobile" || state.deviceType === null),
);
}
function hasActiveForegroundMobileClient(allClientStates: ClientAttentionState[]): boolean {
return allClientStates.some(
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale,
);
}
@@ -13,19 +13,18 @@ function toolCallItem(params: {
detail?: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"];
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
const status = params.status ?? "completed";
const detail =
params.detail ?? {
type: "unknown" as const,
input: params.input ?? null,
output: params.output ?? null,
};
const detail = params.detail ?? {
type: "unknown" as const,
input: params.input ?? null,
output: params.output ?? null,
};
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status,
detail,
error: status === "failed" ? params.error ?? { message: "failed" } : null,
error: status === "failed" ? (params.error ?? { message: "failed" }) : null,
metadata: params.metadata,
};
}
@@ -117,9 +116,7 @@ describe("curateAgentActivity", () => {
const result = curateAgentActivity(timeline);
expect(result).toBe(
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
);
expect(result).toBe('[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}');
});
it("collapses repeated tool updates by callId", () => {
@@ -46,7 +46,12 @@ function formatToolInputJson(input: unknown): string | null {
}
function hasNonEmptyObject(value: unknown): boolean {
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length > 0
);
}
function mergeUnknownValue(existing: unknown | null, incoming: unknown | null): unknown | null {
@@ -171,7 +176,7 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
*/
export function curateAgentActivity(
timeline: AgentTimelineItem[],
options?: { maxItems?: number }
options?: { maxItems?: number },
): string {
if (timeline.length === 0) {
return "No activity to display.";
@@ -182,9 +187,7 @@ export function curateAgentActivity(
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
const recentItems =
maxItems > 0 && collapsed.length > maxItems
? collapsed.slice(-maxItems)
: collapsed;
maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed;
const lines: string[] = [];
const buffers = { message: "", thought: "" };
@@ -26,16 +26,8 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type {
AgentPromptInput,
AgentProvider,
AgentPermissionRequest,
} from "./agent-sdk-types.js";
import type {
AgentManager,
ManagedAgent,
WaitForAgentResult,
} from "./agent-manager.js";
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentPermissionResponseSchema,
@@ -55,10 +47,7 @@ import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
@@ -72,16 +61,10 @@ const AgentProviderEnum = z.enum(
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
AgentProvider,
...AgentProvider[],
]
],
);
const AgentStatusEnum = z.enum([
"initializing",
"idle",
"running",
"error",
"closed",
]);
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
const AGENT_WAIT_TIMEOUT_MS = 50000;
@@ -92,7 +75,7 @@ async function waitForAgentWithTimeout(
options?: {
signal?: AbortSignal;
waitForActive?: boolean;
}
},
): Promise<WaitForAgentResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
@@ -111,18 +94,16 @@ async function waitForAgentWithTimeout(
if (options.signal.aborted) {
forwardAbort(options.signal.reason);
} else {
options.signal.addEventListener(
"abort",
() => forwardAbort(options.signal!.reason),
{ once: true }
);
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
once: true,
});
}
}
timeoutController.signal.addEventListener(
"abort",
() => forwardAbort(timeoutController.signal.reason),
{ once: true }
{ once: true },
);
try {
@@ -154,7 +135,7 @@ function startAgentRun(
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: { replaceRunning?: boolean }
options?: { replaceRunning?: boolean },
): void {
const snapshot = agentManager.getAgent(agentId);
const shouldReplace =
@@ -175,7 +156,7 @@ function startAgentRun(
}
function sanitizePermissionRequest(
permission: AgentPermissionRequest | null | undefined
permission: AgentPermissionRequest | null | undefined,
): AgentPermissionRequest | null {
if (!permission) {
return null;
@@ -202,7 +183,7 @@ function sanitizePermissionRequest(
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentId: string,
logger: Logger
logger: Logger,
): Promise<string | null> {
try {
const record = await agentStorage.get(agentId);
@@ -216,14 +197,14 @@ async function resolveAgentTitle(
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
snapshot: ManagedAgent,
logger: Logger
logger: Logger,
) {
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title });
}
export async function createAgentManagementMcpServer(
options: AgentManagementMcpOptions
options: AgentManagementMcpOptions,
): Promise<McpServer> {
const { agentManager, agentStorage, logger } = options;
const childLogger = logger.child({
@@ -240,47 +221,35 @@ export async function createAgentManagementMcpServer(
const inputSchema = {
cwd: z
.string()
.describe(
"Required working directory for the agent (absolute, relative, or ~)."
),
.describe("Required working directory for the agent (absolute, relative, or ~)."),
title: z
.string()
.trim()
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe(
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
),
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
agentType: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'."
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
initialPrompt: z
.string()
.optional()
.describe(
"Optional task to start immediately after creation (non-blocking)."
),
initialMode: z
.string()
.describe("Required session mode to configure before the first run."),
.describe("Optional task to start immediately after creation (non-blocking)."),
initialMode: z.string().describe("Required session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
.describe(
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
),
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
baseBranch: z
.string()
.optional()
.describe(
"Required when worktreeName is set: the base branch to diff/merge against."
),
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
};
@@ -302,7 +271,7 @@ export async function createAgentManagementMcpServer(
id: z.string(),
label: z.string(),
description: z.string().nullable().optional(),
})
}),
),
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
@@ -396,7 +365,7 @@ export async function createAgentManagementMcpServer(
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
"Failed to record initial prompt"
"Failed to record initial prompt",
);
}
@@ -404,11 +373,9 @@ export async function createAgentManagementMcpServer(
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
if (!background) {
const result = await waitForAgentWithTimeout(
agentManager,
snapshot.id,
{ waitForActive: true }
);
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
waitForActive: true,
});
const responseData = {
agentId: snapshot.id,
@@ -428,10 +395,7 @@ export async function createAgentManagementMcpServer(
};
}
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
"Failed to run initial prompt"
);
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
}
}
@@ -448,7 +412,7 @@ export async function createAgentManagementMcpServer(
permission: null,
}),
};
}
},
);
server.registerTool(
@@ -458,9 +422,7 @@ export async function createAgentManagementMcpServer(
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
agentId: z
.string()
.describe("Agent identifier returned by the create_agent tool"),
agentId: z.string().describe("Agent identifier returned by the create_agent tool"),
},
outputSchema: {
agentId: z.string(),
@@ -498,27 +460,21 @@ export async function createAgentManagementMcpServer(
signal.addEventListener("abort", forwardExternalAbort, {
once: true,
});
cleanupFns.push(() =>
signal.removeEventListener("abort", forwardExternalAbort)
);
cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort));
}
}
const unregister = waitTracker.register(agentId, (reason) => {
if (!abortController.signal.aborted) {
abortController.abort(
new Error(reason ?? "wait_for_agent cancelled")
);
abortController.abort(new Error(reason ?? "wait_for_agent cancelled"));
}
});
cleanupFns.push(unregister);
try {
const result: WaitForAgentResult = await waitForAgentWithTimeout(
agentManager,
agentId,
{ signal: abortController.signal }
);
const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, {
signal: abortController.signal,
});
const validJson = ensureValidJson({
agentId,
@@ -534,7 +490,7 @@ export async function createAgentManagementMcpServer(
} finally {
cleanup();
}
}
},
);
server.registerTool(
@@ -555,7 +511,7 @@ export async function createAgentManagementMcpServer(
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
},
outputSchema: {
@@ -584,10 +540,7 @@ export async function createAgentManagementMcpServer(
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId },
"Failed to record user message"
);
childLogger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, childLogger, {
@@ -627,7 +580,7 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: validJson,
};
}
},
);
server.registerTool(
@@ -653,7 +606,7 @@ export async function createAgentManagementMcpServer(
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
childLogger
childLogger,
);
return {
content: [],
@@ -662,7 +615,7 @@ export async function createAgentManagementMcpServer(
snapshot: structuredSnapshot,
}),
};
}
},
);
server.registerTool(
@@ -679,22 +632,21 @@ export async function createAgentManagementMcpServer(
const snapshots = agentManager.listAgents();
const agents = await Promise.all(
snapshots.map((snapshot) =>
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
)
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
),
);
return {
content: [],
structuredContent: ensureValidJson({ agents }),
};
}
},
);
server.registerTool(
"cancel_agent",
{
title: "Cancel Agent Run",
description:
"Abort the agent's current run but keep the agent alive for future tasks.",
description: "Abort the agent's current run but keep the agent alive for future tasks.",
inputSchema: {
agentId: z.string(),
},
@@ -711,7 +663,7 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: ensureValidJson({ success }),
};
}
},
);
server.registerTool(
@@ -733,23 +685,20 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true }),
};
}
},
);
server.registerTool(
"get_agent_activity",
{
title: "Get Agent Activity",
description:
"Return recent agent timeline entries as a curated summary.",
description: "Return recent agent timeline entries as a curated summary.",
inputSchema: {
agentId: z.string(),
limit: z
.number()
.optional()
.describe(
"Optional limit for number of activities to include (most recent first)."
),
.describe("Optional limit for number of activities to include (most recent first)."),
},
outputSchema: {
agentId: z.string(),
@@ -786,7 +735,7 @@ export async function createAgentManagementMcpServer(
content: contentWithCount,
}),
};
}
},
);
server.registerTool(
@@ -810,7 +759,7 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
};
}
},
);
server.registerTool(
@@ -826,7 +775,7 @@ export async function createAgentManagementMcpServer(
agentId: z.string(),
status: AgentStatusEnum,
request: AgentPermissionRequestPayloadSchema,
})
}),
),
},
},
@@ -844,7 +793,7 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: ensureValidJson({ permissions }),
};
}
},
);
server.registerTool(
@@ -868,7 +817,7 @@ export async function createAgentManagementMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true }),
};
}
},
);
return server;
@@ -226,7 +226,7 @@ describe("AgentManager", () => {
manager.createAgent({
provider: "codex",
cwd: join(workdir, "does-not-exist"),
})
}),
).rejects.toThrow("Working directory does not exist");
});
@@ -250,7 +250,7 @@ describe("AgentManager", () => {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.lastResumeOverrides = overrides;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
@@ -331,7 +331,7 @@ describe("AgentManager", () => {
class HistoryProbeSession extends TestAgentSession {
constructor(
config: AgentSessionConfig,
private readonly historyText: string | null
private readonly historyText: string | null,
) {
super(config);
}
@@ -362,7 +362,7 @@ describe("AgentManager", () => {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = {
@@ -487,7 +487,7 @@ describe("AgentManager", () => {
constructor(
config: AgentSessionConfig,
private readonly stableSessionId: string,
initiallyReady = false
initiallyReady = false,
) {
super(config);
this.persistenceReady = initiallyReady;
@@ -558,7 +558,7 @@ describe("AgentManager", () => {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeSessionCalls += 1;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
@@ -705,7 +705,7 @@ describe("AgentManager", () => {
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
});
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
await manager.emitLiveTimelineItem(snapshot.id, {
@@ -846,7 +846,7 @@ describe("AgentManager", () => {
manager.createAgent({
provider: "codex",
cwd: workdir,
})
}),
).rejects.toThrow("createAgent: agentId must be a UUID");
});
@@ -868,8 +868,8 @@ describe("AgentManager", () => {
provider: "codex",
cwd: workdir,
},
"not-a-uuid"
)
"not-a-uuid",
),
).rejects.toThrow("createAgent: agentId must be a UUID");
});
@@ -1000,7 +1000,7 @@ describe("AgentManager", () => {
resolve();
}
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
});
@@ -1087,7 +1087,7 @@ describe("AgentManager", () => {
}
lifecycleUpdates.push(event.agent.lifecycle);
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
const firstRun = manager.streamAgent(snapshot.id, "first run");
@@ -1124,9 +1124,7 @@ describe("AgentManager", () => {
expect(runningIndexes.length).toBeGreaterThanOrEqual(2);
const firstReplacementRunningIndex = runningIndexes[1]!;
expect(
lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")
).toBe(false);
expect(lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")).toBe(false);
allowSecondRunToEnd.resolve();
@@ -1194,7 +1192,7 @@ describe("AgentManager", () => {
}
}
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
liveEvents.push({ type: "turn_started", provider: "codex" });
@@ -1280,7 +1278,7 @@ describe("AgentManager", () => {
unsubscribe();
resolve();
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
liveEvents.push({ type: "turn_started", provider: "codex" });
});
@@ -1288,7 +1286,7 @@ describe("AgentManager", () => {
const beforeCancel = manager.getAgent(snapshot.id);
expect(beforeCancel?.lifecycle).toBe("running");
expect(Boolean(beforeCancel && "pendingRun" in beforeCancel && beforeCancel.pendingRun)).toBe(
false
false,
);
const cancelled = await manager.cancelAgentRun(snapshot.id);
@@ -1417,7 +1415,7 @@ describe("AgentManager", () => {
resolveAutonomousTurnStarted();
}
},
{ agentId: snapshot.id, replayState: true }
{ agentId: snapshot.id, replayState: true },
);
const foreground = manager.streamAgent(snapshot.id, "foreground run");
@@ -1444,16 +1442,14 @@ describe("AgentManager", () => {
const replaying = manager.getAgent(snapshot.id);
expect(replaying?.lifecycle).toBe("running");
expect(
foregroundEvents.some((event) => event.type === "turn_completed")
).toBe(true);
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(
foregroundEvents.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND")
)
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND"),
),
).toBe(false);
await autonomousTurnStarted;
@@ -1535,7 +1531,7 @@ describe("AgentManager", () => {
resolve();
}
},
{ agentId: snapshot.id, replayState: false }
{ agentId: snapshot.id, replayState: false },
);
});
@@ -1629,7 +1625,7 @@ describe("AgentManager", () => {
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const expectedFinalText =
"```json\n{\"message\":\"Reserve space for archive button in sidebar agent list\"}\n```";
'```json\n{"message":"Reserve space for archive button in sidebar agent list"}\n```';
class ChunkedAssistantSession implements AgentSession {
readonly provider = "codex" as const;
@@ -1651,7 +1647,7 @@ describe("AgentManager", () => {
provider: this.provider,
item: {
type: "assistant_message",
text: "```json\n{\"message\":\"Reserve space for archive button in side",
text: '```json\n{"message":"Reserve space for archive button in side',
},
};
yield {
@@ -1659,7 +1655,7 @@ describe("AgentManager", () => {
provider: this.provider,
item: {
type: "assistant_message",
text: "bar agent list\"}\n```",
text: 'bar agent list"}\n```',
},
};
yield { type: "turn_completed", provider: this.provider };
@@ -1870,7 +1866,7 @@ describe("AgentManager", () => {
receivedEvents.push(event.agent.id);
}
},
{ agentId: internalAgentId, replayState: false }
{ agentId: internalAgentId, replayState: false },
);
await manager.createAgent({
@@ -1895,7 +1891,7 @@ describe("AgentManager", () => {
expect(() =>
manager.subscribe(() => {}, {
agentId: "invalid-agent-id",
})
}),
).toThrow("subscribe: agentId must be a UUID");
});
@@ -2070,7 +2066,7 @@ describe("AgentManager", () => {
.getTimeline(agent.id)
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]")
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
expect(systemErrors).toHaveLength(1);
expect(systemErrors[0]?.text).toContain("invalid model id");
@@ -2129,15 +2125,13 @@ describe("AgentManager", () => {
title: "Detailed failure test",
});
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow(
"Provider execution failed"
);
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
const systemError = manager
.getTimeline(agent.id)
.find(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]")
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
expect(systemError?.text).toContain("Provider execution failed");
expect(systemError?.text).toContain("code: 126");
@@ -2273,10 +2267,7 @@ describe("AgentManager", () => {
return [];
}
async respondToPermission(
_requestId: string,
response: { behavior: string }
): Promise<void> {
async respondToPermission(_requestId: string, response: { behavior: string }): Promise<void> {
// Simulate what claude-agent.ts does: when plan permission is approved,
// it calls setMode("acceptEdits") internally
if (response.behavior === "allow") {
@@ -2548,9 +2539,7 @@ describe("AgentManager", () => {
});
const beforeHydrate = manager.getTimeline(snapshot.id);
const userMessagesBefore = beforeHydrate.filter(
(item) => item.type === "user_message"
);
const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message");
expect(userMessagesBefore).toHaveLength(2);
// hydrateTimeline replays provider history which includes user_message
@@ -2559,17 +2548,13 @@ describe("AgentManager", () => {
await manager.hydrateTimelineFromProvider(snapshot.id);
const afterHydrate = manager.getTimeline(snapshot.id);
const userMessagesAfter = afterHydrate.filter(
(item) => item.type === "user_message"
);
const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message");
// Should still have exactly 2 user messages, not 4
expect(userMessagesAfter).toHaveLength(2);
// Non-user_message items from history should still be replayed
const assistantMessages = afterHydrate.filter(
(item) => item.type === "assistant_message"
);
const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message");
expect(assistantMessages).toHaveLength(2);
});
@@ -2719,17 +2704,10 @@ describe("AgentManager", () => {
expect(userMessages).toHaveLength(2);
expect(
userMessages.map(
(item) =>
(item as Extract<AgentTimelineItem, { type: "user_message" }>).messageId
)
).toEqual([
"msg_client_hello",
"msg_provider_distinct",
]);
expect(userMessages.map((item) => item.text)).toEqual([
"hello from user",
"hello from user",
]);
(item) => (item as Extract<AgentTimelineItem, { type: "user_message" }>).messageId,
),
).toEqual(["msg_client_hello", "msg_provider_distinct"]);
expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]);
});
test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => {
@@ -2768,7 +2746,7 @@ describe("AgentManager", () => {
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message"
item.type === "user_message",
);
expect(userMessages).toHaveLength(3);
@@ -2814,20 +2792,18 @@ describe("AgentManager", () => {
const timeline = manager.getTimeline(snapshot.id);
const userMsg = timeline.find(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message"
item.type === "user_message",
);
expect(userMsg).toBeDefined();
expect(userMsg!.messageId).toBe(clientMsgId);
// Dispatched stream event should also carry the messageId
const streamEvent = events.find(
(e) => e.type === "timeline" && e.item.type === "user_message"
);
const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message");
expect(streamEvent).toBeDefined();
if (streamEvent?.type === "timeline") {
expect(
(streamEvent.item as { type: "user_message"; messageId?: string }).messageId
).toBe(clientMsgId);
expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe(
clientMsgId,
);
}
});
@@ -2866,11 +2842,15 @@ describe("AgentManager", () => {
class EchoClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> { return true; }
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new EchoUserMessageSession(config);
}
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
@@ -2891,21 +2871,17 @@ describe("AgentManager", () => {
await manager.runAgent(snapshot.id, { text: "hello from user" });
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item) => item.type === "user_message"
);
const userMessages = timeline.filter((item) => item.type === "user_message");
// Should be exactly 1 (canonical), not 2 (canonical + provider echo)
expect(userMessages).toHaveLength(1);
// The canonical one must carry the client messageId for optimistic matching
expect(
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).messageId
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).messageId,
).toBe("msg_client_echo_1");
// Assistant messages from the run should still appear
const assistantMessages = timeline.filter(
(item) => item.type === "assistant_message"
);
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
expect(assistantMessages).toHaveLength(1);
});
@@ -2937,11 +2913,15 @@ describe("AgentManager", () => {
class DifferentMessageIdClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> { return true; }
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new DifferentMessageIdSession(config);
}
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
@@ -2962,7 +2942,7 @@ describe("AgentManager", () => {
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message"
item.type === "user_message",
);
expect(userMessages).toHaveLength(2);
expect(userMessages.map((item) => item.messageId)).toEqual([
@@ -2995,11 +2975,15 @@ describe("AgentManager", () => {
class NoMessageIdClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> { return true; }
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new NoMessageIdSession(config);
}
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
@@ -3053,11 +3037,15 @@ describe("AgentManager", () => {
class UnexpectedUserMsgClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> { return true; }
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new UnexpectedUserMessageSession(config);
}
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
@@ -3073,14 +3061,12 @@ describe("AgentManager", () => {
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item) => item.type === "user_message"
);
const userMessages = timeline.filter((item) => item.type === "user_message");
// Provider's user_message should be recorded (no canonical to dedup against)
expect(userMessages).toHaveLength(1);
expect(
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).text
).toBe("continuation prompt");
expect((userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).text).toBe(
"continuation prompt",
);
});
});
+143 -230
View File
@@ -222,7 +222,7 @@ const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd(
handle: AgentPersistenceHandle | null,
cwd: string
cwd: string,
): AgentPersistenceHandle | null {
if (!handle) {
return null;
@@ -247,10 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
const BUSY_STATUSES: AgentLifecycleStatus[] = [
"initializing",
"running",
];
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
const AgentIdSchema = z.string().uuid();
function isAgentBusy(status: AgentLifecycleStatus): boolean {
@@ -265,10 +262,7 @@ function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
);
}
function createAbortError(
signal: AbortSignal | undefined,
fallbackMessage: string
): Error {
function createAbortError(signal: AbortSignal | undefined, fallbackMessage: string): Error {
const reason = signal?.reason;
const message =
typeof reason === "string"
@@ -287,13 +281,10 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function supportsLiveEventStream(
session: AgentSession
): session is LiveEventStreamingSession {
function supportsLiveEventStream(session: AgentSession): session is LiveEventStreamingSession {
return (
"streamLiveEvents" in session &&
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents ===
"function"
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents === "function"
);
}
@@ -316,10 +307,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly liveEventPumps = new Map<string, Promise<void>>();
private readonly liveEventBacklog = new Map<string, AgentStreamEvent[]>();
private readonly liveEventBacklogFlushTimers = new Map<
string,
ReturnType<typeof setTimeout>
>();
private readonly liveEventBacklogFlushTimers = new Map<string, ReturnType<typeof setTimeout>>();
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
@@ -363,9 +351,7 @@ export class AgentManager {
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
const targetAgentId =
options?.agentId == null
? null
: validateAgentId(options.agentId, "subscribe");
options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe");
const record: SubscriptionRecord = {
callback,
agentId: targetAgentId,
@@ -409,7 +395,7 @@ export class AgentManager {
}
async listPersistedAgents(
options?: PersistedAgentQueryOptions
options?: PersistedAgentQueryOptions,
): Promise<PersistedAgentDescriptor[]> {
if (options?.provider) {
const client = this.requireClient(options.provider);
@@ -430,19 +416,13 @@ export class AgentManager {
});
descriptors.push(...entries);
} catch (error) {
this.logger.warn(
{ err: error, provider },
"Failed to list persisted agents for provider"
);
this.logger.warn({ err: error, provider }, "Failed to list persisted agents for provider");
}
}
const limit = options?.limit ?? 20;
return descriptors
.sort(
(a, b) =>
b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
)
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit);
}
@@ -467,10 +447,7 @@ export class AgentManager {
} satisfies ProviderAvailability;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
{ err: error, provider },
"Failed to check provider availability"
);
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
return {
provider,
available: false,
@@ -488,7 +465,7 @@ export class AgentManager {
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
@@ -496,7 +473,7 @@ export class AgentManager {
try {
if (!session.listCommands) {
throw new Error(
`Provider '${normalizedConfig.provider}' does not support listing commands`
`Provider '${normalizedConfig.provider}' does not support listing commands`,
);
}
return await session.listCommands();
@@ -506,7 +483,7 @@ export class AgentManager {
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft command listing session"
"Failed to close draft command listing session",
);
}
}
@@ -528,18 +505,9 @@ export class AgentManager {
return rows.map((row) => ({ ...row }));
}
fetchTimeline(
id: string,
options?: AgentTimelineFetchOptions
): AgentTimelineFetchResult {
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const agent = this.requireAgent(id);
const {
rows,
epoch,
nextSeq,
minSeq,
maxSeq,
} = this.ensureTimelineState(agent);
const { rows, epoch, nextSeq, minSeq, maxSeq } = this.ensureTimelineState(agent);
const direction = options?.direction ?? "tail";
const requestedLimit = options?.limit;
const limit =
@@ -596,8 +564,7 @@ export class AgentManager {
}
if (direction === "tail") {
const selected =
selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
const selected = selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq;
return {
epoch,
@@ -629,8 +596,7 @@ export class AgentManager {
};
}
const selected =
selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
const selected = selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
const lastSelected = selected[selected.length - 1];
return {
epoch,
@@ -648,8 +614,7 @@ export class AgentManager {
// direction === "before"
const beforeSeq = cursor?.seq ?? nextSeq;
const endExclusive = rows.findIndex((row) => row.seq >= beforeSeq);
const boundedRows =
endExclusive < 0 ? rows : rows.slice(0, endExclusive);
const boundedRows = endExclusive < 0 ? rows : rows.slice(0, endExclusive);
const selected =
selectAll || limit >= boundedRows.length
? boundedRows
@@ -672,13 +637,10 @@ export class AgentManager {
async createAgent(
config: AgentSessionConfig,
agentId?: string,
options?: { labels?: Record<string, string> }
options?: { labels?: Record<string, string> },
): Promise<ManagedAgent> {
// Generate agent ID early so we can use it in MCP config
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"createAgent"
);
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const normalizedConfig = await this.normalizeConfig(config, {
labels: options?.labels,
agentId: resolvedAgentId,
@@ -686,15 +648,14 @@ export class AgentManager {
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`);
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.createSession(normalizedConfig);
return this.registerSession(
session,
normalizedConfig,
resolvedAgentId,
{ labels: options?.labels }
);
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
labels: options?.labels,
});
}
// Reconstruct an agent from provider persistence. Callers should explicitly
@@ -708,11 +669,11 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
}
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence"
"resumeAgentFromPersistence",
);
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const mergedConfig = {
@@ -727,19 +688,14 @@ export class AgentManager {
: overrides;
const client = this.requireClient(handle.provider);
const session = await client.resumeSession(handle, resumeOverrides);
return this.registerSession(
session,
normalizedConfig,
resolvedAgentId,
options
);
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
}
// Hot-reload an active agent session with config overrides while preserving
// in-memory timeline state.
async reloadAgentSession(
agentId: string,
overrides?: Partial<AgentSessionConfig>
overrides?: Partial<AgentSessionConfig>,
): Promise<ManagedAgent> {
let existing = this.requireAgent(agentId);
if (existing.lifecycle === "running" || existing.pendingRun) {
@@ -777,10 +733,7 @@ export class AgentManager {
try {
await existing.session.close();
} catch (error) {
this.logger.warn(
{ err: error, agentId },
"Failed to close previous session during refresh"
);
this.logger.warn({ err: error, agentId }, "Failed to close previous session during refresh");
}
// Preserve existing labels and timeline during reload.
@@ -809,7 +762,7 @@ export class AgentManager {
hasPendingRun: Boolean(agent.pendingRun),
pendingPermissions: agent.pendingPermissions.size,
},
"closeAgent: start"
"closeAgent: start",
);
this.agents.delete(agentId);
this.liveEventPumps.delete(agentId);
@@ -856,10 +809,7 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null
): Promise<void> {
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
@@ -885,10 +835,7 @@ export class AgentManager {
this.emitState(agent);
}
async setLabels(
agentId: string,
labels: Record<string, string>
): Promise<void> {
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
await this.persistSnapshot(agent);
@@ -916,7 +863,7 @@ export class AgentManager {
async runAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions
options?: AgentRunOptions,
): Promise<AgentRunResult> {
const events = this.streamAgent(agentId, prompt, options);
const timeline: AgentTimelineItem[] = [];
@@ -941,9 +888,7 @@ export class AgentManager {
const agent = this.requireAgent(agentId);
const sessionId = agent.persistence?.sessionId;
if (!sessionId) {
throw new Error(
`Agent ${agentId} has no persistence.sessionId after run completed`
);
throw new Error(`Agent ${agentId} has no persistence.sessionId after run completed`);
}
return {
sessionId,
@@ -957,7 +902,7 @@ export class AgentManager {
recordUserMessage(
agentId: string,
text: string,
options?: { messageId?: string; emitState?: boolean }
options?: { messageId?: string; emitState?: boolean },
): void {
const agent = this.requireAgent(agentId);
const normalizedMessageId = normalizeMessageId(options?.messageId);
@@ -969,14 +914,18 @@ export class AgentManager {
const updatedAt = this.touchUpdatedAt(agent);
agent.lastUserMessageAt = updatedAt;
const row = this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
}, {
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
});
this.dispatchStream(
agentId,
{
type: "timeline",
item,
provider: agent.provider,
},
{
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
},
);
if (options?.emitState !== false) {
this.emitState(agent);
}
@@ -986,21 +935,22 @@ export class AgentManager {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
}, {
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
});
this.dispatchStream(
agentId,
{
type: "timeline",
item,
provider: agent.provider,
},
{
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
},
);
await this.persistSnapshot(agent);
}
async emitLiveTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<void> {
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
this.dispatchStream(agentId, {
@@ -1013,7 +963,7 @@ export class AgentManager {
streamAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
const existingAgent = this.requireAgent(agentId);
this.logger.trace(
@@ -1024,7 +974,7 @@ export class AgentManager {
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"streamAgent: requested"
"streamAgent: requested",
);
if (existingAgent.pendingRun) {
this.logger.trace(
@@ -1032,7 +982,7 @@ export class AgentManager {
agentId,
lifecycle: existingAgent.lifecycle,
},
"streamAgent: rejected because pendingRun already exists"
"streamAgent: rejected because pendingRun already exists",
);
throw new Error(`Agent ${agentId} already has an active run`);
}
@@ -1052,7 +1002,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent.finalize: invoked"
"streamAgent.finalize: invoked",
);
if (finalized) {
return;
@@ -1067,7 +1017,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder"
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder",
);
if (error) {
agent.lastError = error;
@@ -1078,8 +1028,7 @@ export class AgentManager {
const mutableAgent = agent as ActiveManagedAgent;
mutableAgent.pendingRun = null;
const terminalError = error ?? mutableAgent.lastError;
const shouldHoldBusyForReplacement =
mutableAgent.pendingReplacement && !terminalError;
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
mutableAgent.lifecycle = shouldHoldBusyForReplacement
? "running"
: terminalError
@@ -1092,10 +1041,7 @@ export class AgentManager {
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null);
if (persistenceHandle) {
mutableAgent.persistence = attachPersistenceCwd(
persistenceHandle,
mutableAgent.cwd
);
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
}
this.logger.trace(
{
@@ -1105,7 +1051,7 @@ export class AgentManager {
terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
},
"streamAgent.finalize: applying terminal state"
"streamAgent.finalize: applying terminal state",
);
if (!shouldHoldBusyForReplacement) {
this.emitState(mutableAgent);
@@ -1133,14 +1079,13 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent: forwarded terminal/turn event"
"streamAgent: forwarded terminal/turn event",
);
}
yield event;
}
} catch (error) {
finalizeError =
error instanceof Error ? error.message : "Agent stream failed";
finalizeError = error instanceof Error ? error.message : "Agent stream failed";
self.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
@@ -1167,7 +1112,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent: started"
"streamAgent: started",
);
return streamForwarder;
@@ -1176,7 +1121,7 @@ export class AgentManager {
replaceAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
const snapshot = this.requireAgent(agentId);
if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) {
@@ -1200,7 +1145,7 @@ export class AgentManager {
const latestActive = latest as ActiveManagedAgent;
latestActive.pendingReplacement = false;
const hasForegroundRun = Boolean(
(latestActive as { pendingRun: AsyncGenerator<AgentStreamEvent> | null }).pendingRun
(latestActive as { pendingRun: AsyncGenerator<AgentStreamEvent> | null }).pendingRun,
);
const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle;
if (!hasForegroundRun && lifecycle === "running") {
@@ -1224,10 +1169,7 @@ export class AgentManager {
return;
}
if (
(!("pendingRun" in snapshot) || !snapshot.pendingRun) &&
!snapshot.pendingReplacement
) {
if ((!("pendingRun" in snapshot) || !snapshot.pendingRun) && !snapshot.pendingReplacement) {
throw new Error(`Agent ${agentId} has no pending run`);
}
@@ -1274,7 +1216,8 @@ export class AgentManager {
};
if (options?.signal) {
abortHandler = () => finishErr(createAbortError(options.signal!, "wait_for_agent_start aborted"));
abortHandler = () =>
finishErr(createAbortError(options.signal!, "wait_for_agent_start aborted"));
options.signal.addEventListener("abort", abortHandler, { once: true });
}
@@ -1299,7 +1242,7 @@ export class AgentManager {
return;
}
},
{ agentId, replayState: true }
{ agentId, replayState: true },
);
});
}
@@ -1307,7 +1250,7 @@ export class AgentManager {
async respondToPermission(
agentId: string,
requestId: string,
response: AgentPermissionResponse
response: AgentPermissionResponse,
): Promise<void> {
const agent = this.requireAgent(agentId);
await agent.session.respondToPermission(requestId, response);
@@ -1327,8 +1270,7 @@ export class AgentManager {
async cancelAgentRun(agentId: string): Promise<boolean> {
const agent = this.requireAgent(agentId);
const pendingRun = agent.pendingRun;
const hasForegroundPendingRun =
Boolean(pendingRun) && typeof pendingRun?.return === "function";
const hasForegroundPendingRun = Boolean(pendingRun) && typeof pendingRun?.return === "function";
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundPendingRun;
if (!hasForegroundPendingRun && !isAutonomousRunning) {
@@ -1338,10 +1280,7 @@ export class AgentManager {
try {
await agent.session.interrupt();
} catch (error) {
this.logger.error(
{ err: error, agentId },
"Failed to interrupt session"
);
this.logger.error({ err: error, agentId }, "Failed to interrupt session");
}
if (hasForegroundPendingRun && pendingRun) {
@@ -1350,10 +1289,7 @@ export class AgentManager {
// and pendingRun is properly cleared before we return.
await pendingRun.return(undefined as unknown as AgentStreamEvent);
} catch (error) {
this.logger.error(
{ err: error, agentId },
"Failed to cancel run"
);
this.logger.error({ err: error, agentId }, "Failed to cancel run");
throw error;
}
}
@@ -1402,7 +1338,7 @@ export class AgentManager {
}
private getLastAssistantMessageFromTimeline(
timeline: readonly AgentTimelineItem[]
timeline: readonly AgentTimelineItem[],
): string | null {
// Collect the last contiguous assistant messages (Claude streams chunks)
const chunks: string[] = [];
@@ -1426,22 +1362,21 @@ export class AgentManager {
async waitForAgentEvent(
agentId: string,
options?: WaitForAgentOptions
options?: WaitForAgentOptions,
): Promise<WaitForAgentResult> {
const snapshot = this.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
const hasPendingRun =
"pendingRun" in snapshot && Boolean(snapshot.pendingRun);
const hasPendingRun = "pendingRun" in snapshot && Boolean(snapshot.pendingRun);
const immediatePermission = this.peekPendingPermission(snapshot);
if (immediatePermission) {
return {
status: snapshot.lifecycle,
permission: immediatePermission,
lastMessage: this.getLastAssistantMessage(agentId)
lastMessage: this.getLastAssistantMessage(agentId),
};
}
@@ -1452,14 +1387,14 @@ export class AgentManager {
return {
status: initialStatus,
permission: null,
lastMessage: this.getLastAssistantMessage(agentId)
lastMessage: this.getLastAssistantMessage(agentId),
};
}
if (waitForActive && !initialBusy && !hasPendingRun) {
return {
status: initialStatus,
permission: null,
lastMessage: this.getLastAssistantMessage(agentId)
lastMessage: this.getLastAssistantMessage(agentId),
};
}
@@ -1467,7 +1402,6 @@ export class AgentManager {
throw createAbortError(options.signal, "wait_for_agent aborted");
}
return await new Promise<WaitForAgentResult>((resolve, reject) => {
// Bug #1 Fix: Check abort signal AGAIN inside Promise constructor
// to avoid race condition between pre-Promise check and abort listener registration
@@ -1511,7 +1445,7 @@ export class AgentManager {
resolve({
status: currentStatus,
permission,
lastMessage: this.getLastAssistantMessage(agentId)
lastMessage: this.getLastAssistantMessage(agentId),
});
};
@@ -1567,7 +1501,7 @@ export class AgentManager {
}
}
},
{ agentId, replayState: true }
{ agentId, replayState: true },
);
});
}
@@ -1589,27 +1523,23 @@ export class AgentManager {
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
}
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config
);
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
const now = new Date();
const initialTimeline = options?.timeline ? [...options.timeline] : [];
const initialTimelineRows =
options?.timelineRows?.length
? options.timelineRows.map((row) => ({ ...row }))
: this.buildTimelineRowsFromItems(
initialTimeline,
options?.timelineNextSeq ?? 1,
(options?.updatedAt ?? options?.createdAt ?? now).toISOString()
);
const initialTimelineRows = options?.timelineRows?.length
? options.timelineRows.map((row) => ({ ...row }))
: this.buildTimelineRowsFromItems(
initialTimeline,
options?.timelineNextSeq ?? 1,
(options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
);
const derivedNextSeq =
options?.timelineNextSeq ??
(initialTimelineRows.length
@@ -1674,7 +1604,7 @@ export class AgentManager {
private async resolveInitialPersistedTitle(
agentId: string,
config: AgentSessionConfig
config: AgentSessionConfig,
): Promise<string | null> {
const existing = await this.registry?.get(agentId);
if (existing) {
@@ -1689,7 +1619,7 @@ export class AgentManager {
private buildTimelineRowsFromItems(
items: readonly AgentTimelineItem[],
startSeq: number,
timestamp: string
timestamp: string,
): AgentTimelineRow[] {
let nextSeq = startSeq;
return items.map((item) => {
@@ -1726,7 +1656,7 @@ export class AgentManager {
private async persistSnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean }
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
if (!this.registry) {
return;
@@ -1754,9 +1684,7 @@ export class AgentManager {
try {
const pending = agent.session.getPendingPermissions();
agent.pendingPermissions = new Map(
pending.map((request) => [request.id, request])
);
agent.pendingPermissions = new Map(pending.map((request) => [request.id, request]));
} catch {
agent.pendingPermissions.clear();
}
@@ -1776,7 +1704,7 @@ export class AgentManager {
if (!agent.persistence && newInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: newInfo.sessionId },
agent.cwd
agent.cwd,
);
}
// Emit state if runtimeInfo changed so clients get the updated model
@@ -1803,7 +1731,7 @@ export class AgentManager {
return [];
}
return [[messageId, row.item.text]];
})
}),
);
try {
for await (const event of agent.session.streamHistory()) {
@@ -1824,7 +1752,7 @@ export class AgentManager {
options?: {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
},
): void {
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
@@ -1853,10 +1781,7 @@ export class AgentManager {
// These are already canonically recorded by recordUserMessage() and replaying them would
// create duplicates. Match by messageId (not text) to avoid dropping legitimate
// provider-origin messages that happen to reuse the same text.
if (
options?.fromHistory &&
event.item.type === "user_message"
) {
if (options?.fromHistory && event.item.type === "user_message") {
const eventMessageId = normalizeMessageId(event.item.messageId);
if (eventMessageId) {
const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId);
@@ -1869,10 +1794,7 @@ export class AgentManager {
break;
}
timelineRow = this.recordTimeline(agent, event.item);
if (
!options?.fromHistory &&
event.item.type === "user_message"
) {
if (!options?.fromHistory && event.item.type === "user_message") {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
@@ -1884,7 +1806,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_completed"
"handleStreamEvent: turn_completed",
);
agent.lastUsage = event.usage;
agent.lastError = undefined;
@@ -1901,7 +1823,7 @@ export class AgentManager {
agent,
event.provider,
this.formatTurnFailedMessage(event),
options
options,
);
for (const [requestId] of agent.pendingPermissions) {
agent.pendingPermissions.delete(requestId);
@@ -1923,7 +1845,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_canceled"
"handleStreamEvent: turn_canceled",
);
if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "idle";
@@ -1949,7 +1871,7 @@ export class AgentManager {
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_started"
"handleStreamEvent: turn_started",
);
if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "running";
@@ -1976,12 +1898,16 @@ export class AgentManager {
// Skip dispatching individual stream events during history replay.
if (!options?.fromHistory) {
this.dispatchStream(agent.id, event, timelineRow
? {
seq: timelineRow.seq,
epoch: this.ensureTimelineState(agent).epoch,
}
: undefined);
this.dispatchStream(
agent.id,
event,
timelineRow
? {
seq: timelineRow.seq,
epoch: this.ensureTimelineState(agent).epoch,
}
: undefined,
);
}
}
@@ -1992,7 +1918,7 @@ export class AgentManager {
options?: {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
},
): void {
if (options?.fromHistory) {
return;
@@ -2021,12 +1947,12 @@ export class AgentManager {
{
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
}
},
);
}
private formatTurnFailedMessage(
event: Extract<AgentStreamEvent, { type: "turn_failed" }>
event: Extract<AgentStreamEvent, { type: "turn_failed" }>,
): string {
const base = event.error.trim();
const parts = [base.length > 0 ? base : "Provider run failed"];
@@ -2047,7 +1973,7 @@ export class AgentManager {
options?: {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
},
): boolean {
if (options?.fromHistory || event.type !== "timeline") {
return false;
@@ -2070,10 +1996,7 @@ export class AgentManager {
});
}
private recordTimeline(
agent: ManagedAgent,
item: AgentTimelineItem
): AgentTimelineRow {
private recordTimeline(agent: ManagedAgent, item: AgentTimelineItem): AgentTimelineRow {
const timelineState = this.ensureTimelineState(agent);
const row: AgentTimelineRow = {
seq: timelineState.nextSeq,
@@ -2174,7 +2097,7 @@ export class AgentManager {
private broadcastAgentAttention(
agent: ManagedAgent,
reason: "finished" | "error" | "permission"
reason: "finished" | "error" | "permission",
): void {
this.onAgentAttention?.({
agentId: agent.id,
@@ -2186,7 +2109,7 @@ export class AgentManager {
private dispatchStream(
agentId: string,
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string }
metadata?: { seq?: number; epoch?: string },
): void {
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
}
@@ -2223,10 +2146,9 @@ export class AgentManager {
}
}
private async normalizeConfig(
config: AgentSessionConfig,
_options?: { labels?: Record<string, string>; agentId?: string }
_options?: { labels?: Record<string, string>; agentId?: string },
): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
@@ -2239,7 +2161,11 @@ export class AgentManager {
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
}
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
if (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error(`Working directory does not exist: ${normalized.cwd}`);
}
if (error instanceof Error) {
@@ -2252,8 +2178,7 @@ export class AgentManager {
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
const normalizedId = trimmed.toLowerCase();
normalized.model =
trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
normalized.model = trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
}
return normalized;
@@ -2308,7 +2233,7 @@ export class AgentManager {
eventType: event.type,
backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1,
},
"Live event pump: queued event because pendingRun is active"
"Live event pump: queued event because pendingRun is active",
);
this.enqueueLiveEvent(latest.id, event);
continue;
@@ -2316,15 +2241,9 @@ export class AgentManager {
this.flushLiveEventBacklog(latest);
this.handleStreamEvent(latest, event);
}
this.logger.warn(
{ agentId: agent.id },
"Live event pump stream ended; restarting"
);
this.logger.warn({ agentId: agent.id }, "Live event pump stream ended; restarting");
} catch (error) {
this.logger.warn(
{ err: error, agentId: agent.id },
"Live event pump failed"
);
this.logger.warn({ err: error, agentId: agent.id }, "Live event pump failed");
}
// Keep pump alive unless the agent is gone.
@@ -2377,10 +2296,7 @@ export class AgentManager {
return;
}
if (latest.pendingRun) {
this.scheduleLiveEventBacklogFlush(
agentId,
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
);
this.scheduleLiveEventBacklogFlush(agentId, LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS);
return;
}
this.flushLiveEventBacklog(latest);
@@ -2433,9 +2349,6 @@ export class AgentManager {
} else {
this.liveEventBacklog.set(agent.id, deferred);
}
this.scheduleLiveEventBacklogFlush(
agent.id,
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
);
this.scheduleLiveEventBacklogFlush(agent.id, LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS);
}
}
@@ -23,9 +23,11 @@ type McpClient = {
close: () => Promise<void>;
};
async function withTimeout<T>(
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
async function withTimeout<T>(options: {
promise: Promise<T>;
timeoutMs: number;
label: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
@@ -41,9 +43,10 @@ async function withTimeout<T>(
}
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number }
): Promise<void> {
async function waitForPathExists(options: {
targetPath: string;
timeoutMs: number;
}): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
@@ -51,9 +54,7 @@ async function waitForPathExists(
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
);
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`);
}
async function getAvailablePort(): Promise<number> {
@@ -108,191 +109,183 @@ async function waitForAgentCompletion(options: {
}
describe("agent MCP end-to-end (offline)", () => {
test(
"create_agent runs initial prompt and affects filesystem",
async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
test("create_agent runs initial prompt and affects filesystem", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`)
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const filePath = path.join(agentCwd, "mcp-smoke.txt");
await writeFile(filePath, "ok", "utf8");
const initialPrompt = [
"You must call the Bash command tool with the exact command `rm -f mcp-smoke.txt`.",
"Run it and reply with done and stop.",
"Do not respond before the command finishes.",
].join("\n");
const result = (await client.callTool({
name: "create_agent",
args: {
cwd: agentCwd,
title: "MCP e2e smoke",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt,
background: false,
},
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
throw new Error(
`Expected mcp-smoke.txt to be removed, but it still exists with contents: ${contents}`,
);
}
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(agentCwd, { recursive: true, force: true });
}
}, 30_000);
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const { execSync } = await import("node:child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
await writeFile(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
},
}),
"utf8",
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
cwd: repoRoot,
stdio: "pipe",
});
let agentId: string | null = null;
try {
const filePath = path.join(agentCwd, "mcp-smoke.txt");
await writeFile(filePath, "ok", "utf8");
const initialPrompt = [
"You must call the Bash command tool with the exact command `rm -f mcp-smoke.txt`.",
"Run it and reply with done and stop.",
"Do not respond before the command finishes.",
].join("\n");
const result = (await client.callTool({
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
cwd: agentCwd,
title: "MCP e2e smoke",
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt,
background: false,
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
})) as McpToolResult;
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
await waitForAgentCompletion({ client, agentId: agentId! });
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
throw new Error(
`Expected mcp-smoke.txt to be removed, but it still exists with contents: ${contents}`
);
}
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(agentCwd, { recursive: true, force: true });
await waitForPathExists({
targetPath: path.join(worktreePath, "setup-done.txt"),
timeoutMs: 15000,
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 30000,
});
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
},
30_000
);
test(
"create_agent with worktree is async and boots terminals only after setup success",
async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const { execSync } = await import("node:child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
await writeFile(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
},
}),
"utf8"
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
cwd: repoRoot,
stdio: "pipe",
});
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
await waitForPathExists({
targetPath: path.join(worktreePath, "setup-done.txt"),
timeoutMs: 15000,
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 30000,
});
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(repoRoot, { recursive: true, force: true });
}
},
60_000
);
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(repoRoot, { recursive: true, force: true });
}
}, 60_000);
});
@@ -72,79 +72,79 @@ function initGitRepo(repoDir: string): void {
rmSync(codexSessionDir, { recursive: true, force: true });
}, 60000);
test(
"generates a title using a real Codex agent",
async () => {
const agent = await manager.createAgent({
test("generates a title using a real Codex agent", async () => {
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: repoDir,
title: "Main Agent",
}, "4e0a4508-e522-4fe9-8384-cf3bf889f16d");
},
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: repoDir,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: repoDir,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
await manager.closeAgent(agent.id);
},
180000
);
await manager.closeAgent(agent.id);
}, 180000);
test(
"renames the worktree branch using a real Codex agent",
async () => {
const worktreeSlug = "metadata-worktree";
const worktree = await createWorktree({
branchName: worktreeSlug,
cwd: repoDir,
baseBranch: "main",
worktreeSlug,
paseoHome,
});
test("renames the worktree branch using a real Codex agent", async () => {
const worktreeSlug = "metadata-worktree";
const worktree = await createWorktree({
branchName: worktreeSlug,
cwd: repoDir,
baseBranch: "main",
worktreeSlug,
paseoHome,
});
const agent = await manager.createAgent({
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: worktree.worktreePath,
title: "Worktree Agent",
}, "32bb765d-f637-44a2-9820-f2efd5261418");
},
"32bb765d-f637-44a2-9820-f2efd5261418",
);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: worktree.worktreePath,
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
explicitTitle: "Explicit Title",
paseoHome,
logger,
});
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: worktree.worktreePath,
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
explicitTitle: "Explicit Title",
paseoHome,
logger,
});
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
}).toString().trim();
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const validation = validateBranchSlug(currentBranch);
expect(validation.valid).toBe(true);
expect(currentBranch).toBe("feat/metadata-worktree");
const validation = validateBranchSlug(currentBranch);
expect(validation.valid).toBe(true);
expect(currentBranch).toBe("feat/metadata-worktree");
await manager.closeAgent(agent.id);
},
180000
);
await manager.closeAgent(agent.id);
}, 180000);
});
@@ -55,7 +55,7 @@ function normalizeAutoTitle(title: string): string | null {
async function canRenameBranch(
cwd: string,
paseoHome: string | undefined,
getCheckoutStatusImpl: typeof getCheckoutStatus
getCheckoutStatusImpl: typeof getCheckoutStatus,
): Promise<boolean> {
let status: CheckoutStatusResult;
try {
@@ -77,7 +77,10 @@ async function canRenameBranch(
}
export async function determineAgentMetadataNeeds(
options: Pick<AgentMetadataGenerationOptions, "initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps">
options: Pick<
AgentMetadataGenerationOptions,
"initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps"
>,
): Promise<AgentMetadataNeeds> {
const prompt = options.initialPrompt?.trim();
if (!prompt) {
@@ -86,11 +89,7 @@ export async function determineAgentMetadataNeeds(
const needsTitle = !hasExplicitTitle(options.explicitTitle);
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
const needsBranch = await canRenameBranch(
options.cwd,
options.paseoHome,
getCheckoutStatusImpl
);
const needsBranch = await canRenameBranch(options.cwd, options.paseoHome, getCheckoutStatusImpl);
return {
prompt,
@@ -116,21 +115,17 @@ function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject<any> | null
function buildPrompt(needs: AgentMetadataNeeds): string {
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
Boolean
Boolean,
) as string[];
const instructions: string[] = [
"Generate metadata for a coding agent based on the user prompt.",
];
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
if (needs.needsTitle) {
instructions.push(
`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`
);
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
}
if (needs.needsBranch) {
instructions.push(
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens."
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.",
);
}
@@ -145,7 +140,7 @@ function buildPrompt(needs: AgentMetadataNeeds): string {
}
export async function generateAndApplyAgentMetadata(
options: AgentMetadataGenerationOptions
options: AgentMetadataGenerationOptions,
): Promise<void> {
const needs = await determineAgentMetadataNeeds(options);
if (!needs.prompt) {
@@ -186,13 +181,13 @@ export async function generateAndApplyAgentMetadata(
) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Structured metadata generation failed"
"Structured metadata generation failed",
);
return;
}
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation failed"
"Agent metadata generation failed",
);
return;
}
@@ -210,7 +205,7 @@ export async function generateAndApplyAgentMetadata(
if (!validation.valid) {
options.logger.warn(
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
"Generated branch name is invalid"
"Generated branch name is invalid",
);
return;
}
@@ -221,7 +216,7 @@ export async function generateAndApplyAgentMetadata(
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Failed to re-check branch eligibility"
"Failed to re-check branch eligibility",
);
return;
}
@@ -241,20 +236,18 @@ export async function generateAndApplyAgentMetadata(
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, branch: normalizedBranch },
"Failed to rename branch"
"Failed to rename branch",
);
}
}
}
export function scheduleAgentMetadataGeneration(
options: AgentMetadataGenerationOptions
): void {
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
queueMicrotask(() => {
void generateAndApplyAgentMetadata(options).catch((error) => {
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation crashed"
"Agent metadata generation crashed",
);
});
});
@@ -15,9 +15,7 @@ const NON_GIT_CHECKOUT_STATUS = {
isPaseoOwnedWorktree: false,
currentBranch: null,
repoRoot: "/tmp/repo",
} as Awaited<
ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>
>;
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
isGit: true,
@@ -32,20 +30,16 @@ const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: true,
} as Awaited<
ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>
>;
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
function createDeps(
generateStructuredAgentResponseWithFallback: NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>
>,
): AgentMetadataGeneratorDeps {
return {
generateStructuredAgentResponseWithFallback,
getCheckoutStatus: vi
.fn()
.mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable<
getCheckoutStatus: vi.fn().mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable<
AgentMetadataGeneratorDeps["getCheckoutStatus"]
>,
};
@@ -71,10 +65,7 @@ describe("agent metadata generator auto-title", () => {
});
expect(setTitle).toHaveBeenCalledTimes(1);
expect(setTitle).toHaveBeenCalledWith(
"agent-1",
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS)
);
expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS));
});
it("does not generate an auto title when an explicit title is provided", async () => {
@@ -105,17 +96,13 @@ describe("agent metadata generator auto-title", () => {
setTitle,
notifyAgentState,
} as unknown as AgentManager;
const renameCurrentBranch = vi
.fn()
.mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
const getCheckoutStatus = vi
.fn()
.mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable<
@@ -139,7 +126,7 @@ describe("agent metadata generator auto-title", () => {
expect(renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo/metadata-worktree",
"feature/metadata-worktree"
"feature/metadata-worktree",
);
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
expect(setTitle).not.toHaveBeenCalled();
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "./agent-manager.js";
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import type {
AgentPermissionRequest,
@@ -11,17 +8,12 @@ import type {
AgentSessionConfig,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions"
> & {
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
};
function createManagedAgent(
overrides: ManagedAgentOverrides = {}
): ManagedAgent {
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = new Date("2025-01-01T00:00:00.000Z");
const baseConfig: AgentSessionConfig = {
provider: "claude",
@@ -47,14 +39,11 @@ function createManagedAgent(
...restOverrides
} = overrides;
const sessionValue =
lifecycle === "closed" ? null : restOverrides.session ?? ({} as any);
const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any));
const pendingRunValue =
restOverrides.pendingRun ??
(lifecycle === "running" ? (async function* noop() {})() : null);
restOverrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
const lastErrorValue =
restOverrides.lastError ??
(lifecycle === "error" ? "encountered error" : undefined);
restOverrides.lastError ?? (lifecycle === "error" ? "encountered error" : undefined);
const agent: ManagedAgent = {
id: "agent-123",
@@ -79,8 +68,7 @@ function createManagedAgent(
{ id: "build", label: "Building", description: "Detailed" },
],
currentModeId: "plan",
pendingPermissions:
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
pendingPermissions: pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
timeline: [],
runtimeInfo: {
@@ -106,9 +94,7 @@ function createManagedAgent(
};
}
function createPermission(
overrides: Partial<AgentPermissionRequest> = {}
): AgentPermissionRequest {
function createPermission(overrides: Partial<AgentPermissionRequest> = {}): AgentPermissionRequest {
const base: AgentPermissionRequest = {
id: "perm-1",
provider: "claude",
@@ -127,12 +113,12 @@ describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
currentModeId: "focus",
persistence: {
provider: "claude",
sessionId: "persist-2",
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
},
});
persistence: {
provider: "claude",
sessionId: "persist-2",
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
},
});
const record = toStoredAgentRecord(agent, { title: "Refactor Agent" });
@@ -235,10 +221,7 @@ describe("toAgentPayload", () => {
expect(payload.title).toBe("UI Payload");
expect(payload.model).toBe(agent.config.model);
expect(payload.thinkingOptionId).toBeNull();
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
"perm-a",
"perm-b",
]);
expect(payload.pendingPermissions.map((item) => item.id)).toEqual(["perm-a", "perm-b"]);
expect(payload.pendingPermissions[0]).not.toBe(permissionA);
expect(payload.pendingPermissions[0].input).toEqual({ command: "ls" });
expect(payload.pendingPermissions[1].metadata).toEqual({
@@ -1,8 +1,5 @@
import type { AgentSnapshotPayload } from "../messages.js";
import type {
SerializableAgentConfig,
StoredAgentRecord,
} from "./agent-storage.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
AgentMetadata,
@@ -43,7 +40,7 @@ export function resolveEffectiveThinkingOptionId(options: {
export function toStoredAgentRecord(
agent: ManagedAgent,
options?: ProjectionOptions
options?: ProjectionOptions,
): StoredAgentRecord {
const createdAt = options?.createdAt ?? agent.createdAt.toISOString();
const config = buildSerializableConfig(agent.config);
@@ -57,9 +54,7 @@ export function toStoredAgentRecord(
createdAt,
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt
? agent.lastUserMessageAt.toISOString()
: null,
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
lastStatus: agent.lifecycle,
@@ -68,9 +63,7 @@ export function toStoredAgentRecord(
runtimeInfo,
persistence,
requiresAttention: agent.attention.requiresAttention,
attentionReason: agent.attention.requiresAttention
? agent.attention.attentionReason
: null,
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
attentionTimestamp: agent.attention.requiresAttention
? agent.attention.attentionTimestamp.toISOString()
: null,
@@ -80,7 +73,7 @@ export function toStoredAgentRecord(
export function toAgentPayload(
agent: ManagedAgent,
options?: ProjectionOptions
options?: ProjectionOptions,
): AgentSnapshotPayload {
const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo);
const thinkingOptionId = agent.config.thinkingOptionId ?? null;
@@ -99,9 +92,7 @@ export function toAgentPayload(
runtimeInfo,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt
? agent.lastUserMessageAt.toISOString()
: null,
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
status: agent.lifecycle,
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
@@ -134,9 +125,7 @@ export function toAgentPayload(
return payload;
}
function buildSerializableConfig(
config: AgentSessionConfig
): SerializableAgentConfig | null {
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {
serializable.title = config.title ?? null;
@@ -164,20 +153,18 @@ function buildSerializableConfig(
}
function sanitizePendingPermissions(
pending: Map<string, AgentPermissionRequest>
pending: Map<string, AgentPermissionRequest>,
): AgentPermissionRequest[] {
return Array.from(pending.values()).map((request) => (
{
...request,
input: sanitizeMetadata(request.input),
suggestions: sanitizeMetadataArray(request.suggestions),
metadata: sanitizeMetadata(request.metadata),
}
));
return Array.from(pending.values()).map((request) => ({
...request,
input: sanitizeMetadata(request.input),
suggestions: sanitizeMetadataArray(request.suggestions),
metadata: sanitizeMetadata(request.metadata),
}));
}
function sanitizePersistenceHandle(
handle: AgentPersistenceHandle | null
handle: AgentPersistenceHandle | null,
): AgentPersistenceHandle | null {
if (!handle) {
return null;
@@ -196,9 +183,7 @@ function sanitizePersistenceHandle(
return sanitized;
}
function cloneCapabilities(
capabilities: AgentCapabilityFlags
): AgentCapabilityFlags {
function cloneCapabilities(capabilities: AgentCapabilityFlags): AgentCapabilityFlags {
return { ...capabilities };
}
@@ -232,19 +217,13 @@ function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
}
return Object.keys(result).length ? result : undefined;
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
return undefined;
}
function isJsonObject(
value: JsonValue
): value is { [key: string]: JsonValue } {
function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -300,7 +279,7 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
}
function sanitizeRuntimeInfo(
runtimeInfo: AgentRuntimeInfo | undefined
runtimeInfo: AgentRuntimeInfo | undefined,
): AgentRuntimeInfo | undefined {
if (!runtimeInfo) {
return undefined;
@@ -193,7 +193,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
expect(result.title.length).toBeGreaterThan(0);
expect(typeof result.count).toBe("number");
},
180000
180000,
);
test.runIf(hasClaudeCredentials)(
@@ -234,6 +234,6 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
expect(result.message.trim().toLowerCase()).toBe("hello");
},
180000
180000,
);
});
@@ -22,10 +22,7 @@ function createScriptedCaller(responses: string[]) {
describe("getStructuredAgentResponse", () => {
it("retries on invalid JSON and succeeds", async () => {
const schema = z.object({ title: z.string() });
const { caller, prompts } = createScriptedCaller([
"not json",
'{"title":"ok"}',
]);
const { caller, prompts } = createScriptedCaller(["not json", '{"title":"ok"}']);
const result = await getStructuredAgentResponse({
caller,
@@ -42,10 +39,7 @@ describe("getStructuredAgentResponse", () => {
it("retries on schema mismatch with validation errors", async () => {
const schema = z.object({ count: z.number() });
const { caller, prompts } = createScriptedCaller([
'{"count":"nope"}',
'{"count":2}',
]);
const { caller, prompts } = createScriptedCaller(['{"count":"nope"}', '{"count":2}']);
const result = await getStructuredAgentResponse({
caller,
@@ -62,10 +56,7 @@ describe("getStructuredAgentResponse", () => {
it("fails after maxRetries with last response and validation errors", async () => {
const schema = z.object({ count: z.number() });
const { caller } = createScriptedCaller([
'{"count":"nope"}',
'{"count":"still"}',
]);
const { caller } = createScriptedCaller(['{"count":"nope"}', '{"count":"still"}']);
try {
await getStructuredAgentResponse({
@@ -82,7 +73,7 @@ describe("getStructuredAgentResponse", () => {
name: "StructuredAgentResponseError",
lastResponse: '{"count":"still"}',
validationErrors: expect.arrayContaining([expect.stringContaining("count")]),
})
}),
);
}
});
@@ -96,10 +87,7 @@ describe("getStructuredAgentResponse", () => {
required: ["name"],
additionalProperties: false,
};
const { caller, prompts } = createScriptedCaller([
'{"name": 123}',
'{"name": "ok"}',
]);
const { caller, prompts } = createScriptedCaller(['{"name": 123}', '{"name": "ok"}']);
const result = await getStructuredAgentResponse({
caller,
@@ -115,9 +103,7 @@ describe("getStructuredAgentResponse", () => {
it("extracts JSON from markdown code fences", async () => {
const schema = z.object({ message: z.string() });
const { caller } = createScriptedCaller([
'```json\n{"message": "hello"}\n```',
]);
const { caller } = createScriptedCaller(['```json\n{"message": "hello"}\n```']);
const result = await getStructuredAgentResponse({
caller,
@@ -131,9 +117,7 @@ describe("getStructuredAgentResponse", () => {
it("extracts JSON from plain code fences", async () => {
const schema = z.object({ value: z.number() });
const { caller } = createScriptedCaller([
'```\n{"value": 42}\n```',
]);
const { caller } = createScriptedCaller(['```\n{"value": 42}\n```']);
const result = await getStructuredAgentResponse({
caller,
@@ -149,7 +133,9 @@ describe("getStructuredAgentResponse", () => {
describe("generateStructuredAgentResponseWithFallback", () => {
const schema = z.object({ summary: z.string() });
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
function createManager(
availability: Array<{ provider: string; available: boolean; error: string | null }>,
) {
return {
listProviderAvailability: async () => availability,
} as unknown as AgentManager;
@@ -272,7 +258,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
runner: async () => {
throw new Error("failed");
},
})
}),
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
});
});
@@ -51,7 +51,7 @@ export class StructuredAgentFallbackError extends Error {
super(
summary.length > 0
? `Structured generation failed for all providers: ${summary}`
: "Structured generation failed for all providers"
: "Structured generation failed for all providers",
);
this.name = "StructuredAgentFallbackError";
this.attempts = attempts;
@@ -109,7 +109,7 @@ function isZodSchema(value: unknown): value is z.ZodTypeAny {
function buildZodValidator<T>(schema: z.ZodTypeAny, schemaName: string): SchemaValidator<T> {
const zodToJsonSchemaAny = zodToJsonSchema as unknown as (
input: z.ZodTypeAny,
name?: string
name?: string,
) => JsonSchema;
const jsonSchema = zodToJsonSchemaAny(schema, schemaName);
return {
@@ -130,7 +130,9 @@ function buildZodValidator<T>(schema: z.ZodTypeAny, schemaName: string): SchemaV
function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
const AjvConstructor = Ajv as unknown as {
new (options?: AjvOptions): {
new (
options?: AjvOptions,
): {
compile: (input: JsonSchema) => ((value: unknown) => boolean) & {
errors?: ErrorObject[] | null;
};
@@ -146,7 +148,8 @@ function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
return { ok: true, value: value as T };
}
const errors = (validate.errors ?? []).map((error: ErrorObject) => {
const path = error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)";
const path =
error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)";
const message = error.message ?? "is invalid";
return `${path}: ${message}`;
});
@@ -155,7 +158,10 @@ function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
};
}
function buildValidator<T>(schema: z.ZodType<T> | JsonSchema, schemaName: string): SchemaValidator<T> {
function buildValidator<T>(
schema: z.ZodType<T> | JsonSchema,
schemaName: string,
): SchemaValidator<T> {
if (isZodSchema(schema)) {
return buildZodValidator(schema, schemaName);
}
@@ -234,13 +240,13 @@ function extractFirstJsonSnippet(text: string): string | null {
escaped = true;
continue;
}
if (ch === "\"") {
if (ch === '"') {
inString = false;
}
continue;
}
if (ch === "\"") {
if (ch === '"') {
inString = true;
continue;
}
@@ -268,7 +274,7 @@ function extractFirstJsonSnippet(text: string): string | null {
}
export async function getStructuredAgentResponse<T>(
options: StructuredAgentResponseOptions<T>
options: StructuredAgentResponseOptions<T>,
): Promise<T> {
const { caller, prompt, schema, maxRetries = 2, schemaName = "Response" } = options;
const validator = buildValidator(schema, schemaName);
@@ -308,21 +314,20 @@ export async function getStructuredAgentResponse<T>(
attemptPrompt = buildRetryPrompt(basePrompt, lastErrors);
}
throw new StructuredAgentResponseError(
"Agent response did not match the required JSON schema",
{
lastResponse,
validationErrors: lastErrors,
}
);
throw new StructuredAgentResponseError("Agent response did not match the required JSON schema", {
lastResponse,
validationErrors: lastErrors,
});
}
export async function generateStructuredAgentResponse<T>(
options: StructuredAgentGenerationOptions<T>
options: StructuredAgentGenerationOptions<T>,
): Promise<T> {
const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options;
const modeId =
agentConfig.modeId ?? getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? undefined;
agentConfig.modeId ??
getAgentProviderDefinition(agentConfig.provider).defaultModeId ??
undefined;
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId);
try {
const caller: AgentCaller = async (nextPrompt) => {
@@ -360,7 +365,7 @@ function errorMessage(error: unknown): string {
}
export async function generateStructuredAgentResponseWithFallback<T>(
options: StructuredAgentGenerationWithFallbackOptions<T>
options: StructuredAgentGenerationWithFallbackOptions<T>,
): Promise<T> {
const {
manager,
@@ -380,12 +385,9 @@ export async function generateStructuredAgentResponseWithFallback<T>(
const runStructured =
runner ??
((input: StructuredAgentGenerationOptions<T>) =>
generateStructuredAgentResponse<T>(input));
((input: StructuredAgentGenerationOptions<T>) => generateStructuredAgentResponse<T>(input));
const availability = await manager.listProviderAvailability();
const availabilityByProvider = new Map(
availability.map((entry) => [entry.provider, entry])
);
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
const attempts: StructuredGenerationAttempt[] = [];
for (const candidate of providers) {
@@ -412,9 +414,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
provider: candidate.provider,
cwd,
...(candidate.model ? { model: candidate.model } : {}),
...(candidate.thinkingOptionId
? { thinkingOptionId: candidate.thinkingOptionId }
: {}),
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
},
});
return result;
@@ -37,10 +37,7 @@ export interface McpSseServerConfig {
* Discriminated union by `type` field.
* Each provider normalizes this to their expected format.
*/
export type McpServerConfig =
| McpStdioServerConfig
| McpHttpServerConfig
| McpSseServerConfig;
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig | McpSseServerConfig;
export type AgentMode = {
id: string;
@@ -425,7 +422,10 @@ export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/**
@@ -25,9 +25,7 @@ type ManagedAgentOverrides = Omit<
attention?: ManagedAgent["attention"];
};
function createManagedAgent(
overrides: ManagedAgentOverrides = {}
): ManagedAgent {
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
@@ -43,48 +41,39 @@ function createManagedAgent(
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
const session =
lifecycle === "closed"
? null
: overrides.session ?? ({} as AgentSession);
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const pendingRun =
overrides.pendingRun ??
(lifecycle === "running" ? (async function* noop() {})() : null);
overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
const agent: ManagedAgent = {
id: overrides.id ?? "agent-test",
provider,
cwd,
session,
capabilities:
overrides.capabilities ??
{
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions:
overrides.pendingPermissions ??
new Map<string, AgentPermissionRequest>(),
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
pendingRun,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo:
overrides.runtimeInfo ?? {
provider,
sessionId: overrides.sessionId ?? "session-123",
model: config.model ?? null,
modeId: config.modeId ?? null,
},
runtimeInfo: overrides.runtimeInfo ?? {
provider,
sessionId: overrides.sessionId ?? "session-123",
model: config.model ?? null,
modeId: config.modeId ?? null,
},
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
@@ -132,7 +121,7 @@ describe("AgentStorage", () => {
},
},
},
})
}),
);
const records = await storage.list();
@@ -162,9 +151,7 @@ describe("AgentStorage", () => {
test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
await storage.applySnapshot(
createManagedAgent({ id: agentId, createdAt: firstTimestamp })
);
await storage.applySnapshot(createManagedAgent({ id: agentId, createdAt: firstTimestamp }));
const initialRecord = await storage.get(agentId);
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
@@ -175,7 +162,7 @@ describe("AgentStorage", () => {
createdAt: new Date("2025-02-01T00:00:00.000Z"),
updatedAt: new Date("2025-02-01T00:00:00.000Z"),
lifecycle: "running",
})
}),
);
const updatedRecord = await storage.get(agentId);
@@ -189,7 +176,7 @@ describe("AgentStorage", () => {
createManagedAgent({
id: agentId,
lifecycle: "idle",
})
}),
);
const archivedAt = "2025-01-03T00:00:00.000Z";
@@ -202,7 +189,7 @@ describe("AgentStorage", () => {
id: agentId,
lifecycle: "running",
updatedAt: new Date("2025-01-04T00:00:00.000Z"),
})
}),
);
const recordAfterSnapshot = await storage.get(agentId);
@@ -215,7 +202,7 @@ describe("AgentStorage", () => {
id: "agent-2",
provider: "codex",
cwd: "/tmp/second",
})
}),
);
await storage.setTitle("agent-2", "Fix Login Bug");
@@ -228,16 +215,14 @@ describe("AgentStorage", () => {
});
test("setTitle throws when the agent record does not exist", async () => {
await expect(storage.setTitle("missing-agent", "Impossible"))
.rejects.toThrow("Agent missing-agent not found");
await expect(storage.setTitle("missing-agent", "Impossible")).rejects.toThrow(
"Agent missing-agent not found",
);
});
test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override";
await storage.applySnapshot(
createManagedAgent({ id: agentId }),
{ title: "Provided Title" }
);
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
const record = await storage.get(agentId);
expect(record?.title).toBe("Provided Title");
@@ -250,7 +235,7 @@ describe("AgentStorage", () => {
id: agentId,
lifecycle: "idle",
currentModeId: "plan",
})
}),
);
await storage.setTitle(agentId, "Important Bug Fix");
@@ -260,7 +245,7 @@ describe("AgentStorage", () => {
lifecycle: "running",
currentModeId: "build",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
})
}),
);
const record = await storage.get(agentId);
@@ -291,7 +276,7 @@ describe("AgentStorage", () => {
id: agentId,
lifecycle: "running",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
})
}),
);
storageInternals.cache.set(agentId, {
@@ -311,7 +296,7 @@ describe("AgentStorage", () => {
createManagedAgent({
id: "normal-agent",
cwd: "/tmp/project",
})
}),
);
// Create an internal agent
@@ -321,7 +306,7 @@ describe("AgentStorage", () => {
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true }
{ internal: true },
);
// Registry should return all agents - filtering is done at the manager level
@@ -336,7 +321,7 @@ describe("AgentStorage", () => {
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true }
{ internal: true },
);
const record = await storage.get("internal-agent");
@@ -351,7 +336,7 @@ describe("AgentStorage", () => {
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true }
{ internal: true },
);
// Reload the registry from disk
@@ -377,7 +362,7 @@ describe("AgentStorage", () => {
id: agentId,
cwd: "/tmp/project-a",
provider: "codex",
})
}),
);
const record = await storage.get(agentId);
expect(record).not.toBeNull();
@@ -390,7 +375,7 @@ describe("AgentStorage", () => {
await fs.writeFile(
duplicatePathB,
JSON.stringify({ ...recordA, cwd: "/tmp/project-b" }, null, 2),
"utf8"
"utf8",
);
// Force a reload so the registry has to discover from disk (and may choose either copy).
@@ -65,13 +65,7 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick<
AgentSessionConfig,
| "title"
| "modeId"
| "model"
| "thinkingOptionId"
| "extra"
| "systemPrompt"
| "mcpServers"
"title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -141,7 +135,7 @@ export class AgentStorage {
if (this.pendingWrites.get(agentId) === next) {
this.pendingWrites.delete(agentId);
}
})
}),
);
await next;
@@ -162,10 +156,7 @@ export class AgentStorage {
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code && code !== "ENOENT") {
this.logger.warn(
{ err: error, agentId, filePath },
"Failed to remove agent record file"
);
this.logger.warn({ err: error, agentId, filePath }, "Failed to remove agent record file");
}
}
}
@@ -177,7 +168,7 @@ export class AgentStorage {
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean }
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
await this.load();
await this.waitForPendingWrite(agent.id);
@@ -187,11 +178,9 @@ export class AgentStorage {
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride
? options?.internal
: (agent.internal ?? existing?.internal),
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
// Preserve soft-delete/archive status across snapshot flushes.
@@ -341,9 +330,7 @@ export class AgentStorage {
}
private async waitForPendingWrite(agentId: string): Promise<void> {
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(
() => undefined
);
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
}
}
@@ -357,10 +344,7 @@ function projectDirNameFromCwd(cwd: string): string {
async function writeFileAtomically(targetPath: string, payload: string) {
const directory = path.dirname(targetPath);
const tempPath = path.join(
directory,
`.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
);
const tempPath = path.join(directory, `.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
await fs.writeFile(tempPath, payload, "utf8");
await fs.rename(tempPath, targetPath);
}
@@ -19,7 +19,7 @@ export interface DictationDebugChunkWriter {
export function createDictationDebugChunkWriter(
metadata: Pick<DictationDebugAudioMetadata, "sessionId" | "dictationId">,
logger: pino.Logger
logger: pino.Logger,
): DictationDebugChunkWriter | null {
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
if (!debugDir) {
@@ -35,7 +35,7 @@ export function createDictationDebugChunkWriter(
const folder = join(
debugDir,
sanitizeForFilename(metadata.sessionId, "session"),
`${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}`
`${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}`,
);
let folderCreated = false;
@@ -58,7 +58,7 @@ export async function maybePersistDictationDebugAudio(
audio: Buffer,
metadata: DictationDebugAudioMetadata,
logger: pino.Logger,
chunkWriterFolder?: string | null
chunkWriterFolder?: string | null,
): Promise<string | null> {
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
if (!debugDir) {
@@ -71,7 +71,8 @@ export async function maybePersistDictationDebugAudio(
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const folder = chunkWriterFolder ?? join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
const folder =
chunkWriterFolder ?? join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
await mkdir(folder, { recursive: true });
const filename = chunkWriterFolder
@@ -95,13 +95,15 @@ describe("create_agent MCP tool", () => {
title: "Short title",
});
expect(parsed.success).toBe(false);
expect(parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt")).toBe(true);
expect(
parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt"),
).toBe(true);
});
it("surfaces createAgent validation failures", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockRejectedValue(
new Error("Working directory does not exist: /path/that/does/not/exist")
new Error("Working directory does not exist: /path/that/does/not/exist"),
);
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"];
@@ -111,7 +113,7 @@ describe("create_agent MCP tool", () => {
cwd: "/path/that/does/not/exist",
title: "Short title",
initialPrompt: "Do work",
})
}),
).rejects.toThrow("Working directory does not exist");
});
@@ -140,7 +142,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth bug",
}),
undefined,
undefined
undefined,
);
});
@@ -168,7 +170,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth",
}),
undefined,
undefined
undefined,
);
});
@@ -216,7 +218,7 @@ describe("create_agent MCP tool", () => {
cwd: subdir,
}),
undefined,
{ labels: { source: "voice" } }
{ labels: { source: "voice" } },
);
await rm(baseDir, { recursive: true, force: true });
});
@@ -244,7 +246,7 @@ describe("speak MCP tool", () => {
expect.objectContaining({
text: "Hello from voice agent.",
callerAgentId: "voice-agent-1",
})
}),
);
});
@@ -260,7 +262,7 @@ describe("speak MCP tool", () => {
});
const tool = (server as any)._registeredTools["speak"];
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for caller agent"
"No speak handler registered for caller agent",
);
});
+74 -162
View File
@@ -3,21 +3,10 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import type {
AgentPromptInput,
AgentProvider,
AgentPermissionRequest,
} from "./agent-sdk-types.js";
import type {
AgentManager,
ManagedAgent,
WaitForAgentResult,
} from "./agent-manager.js";
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentPermissionResponseSchema,
@@ -35,16 +24,10 @@ import {
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type {
VoiceCallerContext,
VoiceSpeakHandler,
} from "../voice-types.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -60,12 +43,8 @@ export interface AgentMcpServerOptions {
* Optional resolver for session-bound speak handlers.
* Used by hidden voice agents to narrate through daemon-managed TTS.
*/
resolveSpeakHandler?: (
callerAgentId: string
) => VoiceSpeakHandler | null;
resolveCallerContext?: (
callerAgentId: string
) => VoiceCallerContext | null;
resolveSpeakHandler?: (callerAgentId: string) => VoiceSpeakHandler | null;
resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null;
enableVoiceTools?: boolean;
voiceOnly?: boolean;
logger: Logger;
@@ -87,7 +66,7 @@ const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
function mapModeAcrossProviders(
sourceMode: string,
sourceProvider: AgentProvider,
targetProvider: AgentProvider
targetProvider: AgentProvider,
): string {
if (sourceProvider === targetProvider) {
return sourceMode;
@@ -116,16 +95,10 @@ const AgentProviderEnum = z.enum(
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
AgentProvider,
...AgentProvider[],
]
],
);
const AgentStatusEnum = z.enum([
"initializing",
"idle",
"running",
"error",
"closed",
]);
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
const AGENT_WAIT_TIMEOUT_MS = 50000;
@@ -162,7 +135,7 @@ async function waitForAgentWithTimeout(
options?: {
signal?: AbortSignal;
waitForActive?: boolean;
}
},
): Promise<WaitForAgentResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
@@ -182,11 +155,9 @@ async function waitForAgentWithTimeout(
if (options.signal.aborted) {
forwardAbort(options.signal.reason);
} else {
options.signal.addEventListener(
"abort",
() => forwardAbort(options.signal!.reason),
{ once: true }
);
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
once: true,
});
}
}
@@ -194,7 +165,7 @@ async function waitForAgentWithTimeout(
timeoutController.signal.addEventListener(
"abort",
() => forwardAbort(timeoutController.signal.reason),
{ once: true }
{ once: true },
);
try {
@@ -226,7 +197,7 @@ function startAgentRun(
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: { replaceRunning?: boolean }
options?: { replaceRunning?: boolean },
): void {
const snapshot = agentManager.getAgent(agentId);
const shouldReplace =
@@ -241,16 +212,13 @@ function startAgentRun(
// Events are broadcast via AgentManager subscribers.
}
} catch (error) {
logger.error(
{ err: error, agentId },
"Agent stream failed"
);
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();
}
function sanitizePermissionRequest(
permission: AgentPermissionRequest | null | undefined
permission: AgentPermissionRequest | null | undefined,
): AgentPermissionRequest | null {
if (!permission) {
return null;
@@ -277,16 +245,13 @@ function sanitizePermissionRequest(
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentId: string,
logger: Logger
logger: Logger,
): Promise<string | null> {
try {
const record = await agentStorage.get(agentId);
return record?.title ?? null;
} catch (error) {
logger.error(
{ err: error, agentId },
"Failed to load agent title"
);
logger.error({ err: error, agentId }, "Failed to load agent title");
return null;
}
}
@@ -294,15 +259,13 @@ async function resolveAgentTitle(
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
snapshot: ManagedAgent,
logger: Logger
logger: Logger,
) {
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title });
}
export async function createAgentMcpServer(
options: AgentMcpServerOptions
): Promise<McpServer> {
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
const {
agentManager,
agentStorage,
@@ -314,7 +277,7 @@ export async function createAgentMcpServer(
} = options;
const childLogger = logger.child({ module: "agent", component: "mcp-server" });
const waitTracker = new WaitForAgentTracker(logger);
const callerContext = callerAgentId ? resolveCallerContext?.(callerAgentId) ?? null : null;
const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null;
const server = new McpServer({
name: "agent-mcp",
@@ -325,87 +288,67 @@ export async function createAgentMcpServer(
cwd: z
.string()
.optional()
.describe(
"Optional working directory. Defaults to the caller agent working directory."
),
.describe("Optional working directory. Defaults to the caller agent working directory."),
title: z
.string()
.trim()
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe(
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
),
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
agentType: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'."
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe(
"Required first task to run immediately after creation."
),
.describe("Required first task to run immediately after creation."),
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
};
const topLevelInputSchema = {
cwd: z
.string()
.describe(
"Required working directory for the agent (absolute, relative, or ~)."
),
.describe("Required working directory for the agent (absolute, relative, or ~)."),
title: z
.string()
.trim()
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe(
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
),
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
agentType: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'."
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe(
"Required first task to run immediately after creation."
),
initialMode: z
.string()
.describe("Required session mode to configure before the first run."),
.describe("Required first task to run immediately after creation."),
initialMode: z.string().describe("Required session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
.describe(
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
),
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
baseBranch: z
.string()
.optional()
.describe(
"Required when worktreeName is set: the base branch to diff/merge against."
),
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
};
const createAgentInputSchema = callerAgentId
? agentToAgentInputSchema
: topLevelInputSchema;
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
const topLevelCreateAgentArgsSchema = z.object({
...topLevelInputSchema,
@@ -447,7 +390,7 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ ok: true }),
};
}
},
);
}
@@ -473,7 +416,7 @@ export async function createAgentMcpServer(
id: z.string(),
label: z.string(),
description: z.string().nullable().optional(),
})
}),
),
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
@@ -508,11 +451,7 @@ export async function createAgentMcpServer(
});
const parentMode = parentAgent.currentModeId;
if (parentMode) {
resolvedMode = mapModeAcrossProviders(
parentMode,
parentAgent.provider,
provider
);
resolvedMode = mapModeAcrossProviders(parentMode, parentAgent.provider, provider);
}
} else {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
@@ -520,12 +459,7 @@ export async function createAgentMcpServer(
initialPrompt = topLevelArgs.initialPrompt;
background = topLevelArgs.background ?? false;
normalizedTitle = topLevelArgs.title.trim();
const {
cwd,
initialMode,
worktreeName,
baseBranch,
} = topLevelArgs;
const { cwd, initialMode, worktreeName, baseBranch } = topLevelArgs;
resolvedCwd = expandUserPath(cwd);
@@ -559,7 +493,7 @@ export async function createAgentMcpServer(
title: normalizedTitle ?? undefined,
},
undefined,
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined,
);
if (worktreeConfig) {
@@ -599,10 +533,7 @@ export async function createAgentMcpServer(
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
"Failed to record initial prompt"
);
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to record initial prompt");
}
try {
@@ -610,11 +541,9 @@ export async function createAgentMcpServer(
// If not running in background, wait for completion
if (!background) {
const result = await waitForAgentWithTimeout(
agentManager,
snapshot.id,
{ waitForActive: true }
);
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
waitForActive: true,
});
const responseData = {
agentId: snapshot.id,
@@ -635,10 +564,7 @@ export async function createAgentMcpServer(
return response;
}
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
"Failed to run initial prompt"
);
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
}
// Return immediately if background=true
@@ -656,7 +582,7 @@ export async function createAgentMcpServer(
}),
};
return response;
}
},
);
server.registerTool(
@@ -666,9 +592,7 @@ export async function createAgentMcpServer(
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
agentId: z
.string()
.describe("Agent identifier returned by the create_agent tool"),
agentId: z.string().describe("Agent identifier returned by the create_agent tool"),
},
outputSchema: {
agentId: z.string(),
@@ -704,9 +628,7 @@ export async function createAgentMcpServer(
forwardExternalAbort();
} else {
signal.addEventListener("abort", forwardExternalAbort, { once: true });
cleanupFns.push(() =>
signal.removeEventListener("abort", forwardExternalAbort)
);
cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort));
}
}
@@ -718,11 +640,9 @@ export async function createAgentMcpServer(
cleanupFns.push(unregister);
try {
const result: WaitForAgentResult = await waitForAgentWithTimeout(
agentManager,
agentId,
{ signal: abortController.signal }
);
const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, {
signal: abortController.signal,
});
const validJson = ensureValidJson({
agentId,
@@ -739,7 +659,7 @@ export async function createAgentMcpServer(
} finally {
cleanup();
}
}
},
);
server.registerTool(
@@ -760,7 +680,7 @@ export async function createAgentMcpServer(
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
},
outputSchema: {
@@ -789,10 +709,7 @@ export async function createAgentMcpServer(
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId },
"Failed to record user message"
);
childLogger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, childLogger, {
@@ -820,7 +737,6 @@ export async function createAgentMcpServer(
return response;
}
// Return immediately if background=true
// Re-fetch snapshot since the state may have changed
const currentSnapshot = agentManager.getAgent(agentId);
@@ -838,7 +754,7 @@ export async function createAgentMcpServer(
structuredContent: validJson,
};
return response;
}
},
);
server.registerTool(
@@ -864,7 +780,7 @@ export async function createAgentMcpServer(
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
childLogger
childLogger,
);
return {
content: [],
@@ -873,7 +789,7 @@ export async function createAgentMcpServer(
snapshot: structuredSnapshot,
}),
};
}
},
);
server.registerTool(
@@ -890,22 +806,21 @@ export async function createAgentMcpServer(
const snapshots = agentManager.listAgents();
const agents = await Promise.all(
snapshots.map((snapshot) =>
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
)
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
),
);
return {
content: [],
structuredContent: ensureValidJson({ agents }),
};
}
},
);
server.registerTool(
"cancel_agent",
{
title: "Cancel Agent Run",
description:
"Abort the agent's current run but keep the agent alive for future tasks.",
description: "Abort the agent's current run but keep the agent alive for future tasks.",
inputSchema: {
agentId: z.string(),
},
@@ -922,7 +837,7 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ success }),
};
}
},
);
server.registerTool(
@@ -944,15 +859,14 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true }),
};
}
},
);
server.registerTool(
"get_agent_activity",
{
title: "Get Agent Activity",
description:
"Return recent agent timeline entries as a curated summary.",
description: "Return recent agent timeline entries as a curated summary.",
inputSchema: {
agentId: z.string(),
limit: z
@@ -971,9 +885,7 @@ export async function createAgentMcpServer(
const timeline = agentManager.getTimeline(agentId);
const snapshot = agentManager.getAgent(agentId);
const activitiesToCurate = limit
? timeline.slice(-limit)
: timeline;
const activitiesToCurate = limit ? timeline.slice(-limit) : timeline;
const curatedContent = curateAgentActivity(activitiesToCurate);
const totalCount = timeline.length;
@@ -981,9 +893,9 @@ export async function createAgentMcpServer(
let countHeader: string;
if (limit && shownCount < totalCount) {
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? 'activity' : 'activities'} (limited to ${limit})`;
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`;
} else {
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? 'activity' : 'activities'}`;
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`;
}
const contentWithCount = `${countHeader}\n\n${curatedContent}`;
@@ -997,7 +909,7 @@ export async function createAgentMcpServer(
content: contentWithCount,
}),
};
}
},
);
server.registerTool(
@@ -1021,7 +933,7 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
};
}
},
);
server.registerTool(
@@ -1037,7 +949,7 @@ export async function createAgentMcpServer(
agentId: z.string(),
status: AgentStatusEnum,
request: AgentPermissionRequestPayloadSchema,
})
}),
),
},
},
@@ -1055,7 +967,7 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ permissions }),
};
}
},
);
server.registerTool(
@@ -1079,7 +991,7 @@ export async function createAgentMcpServer(
content: [],
structuredContent: ensureValidJson({ success: true }),
};
}
},
);
return server;
@@ -1,10 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -29,22 +26,18 @@ describe("provider model catalogs (e2e)", () => {
await ctx.cleanup();
}, 60_000);
test(
"Claude catalog exposes Sonnet and Haiku variants",
async () => {
const result = await ctx.client.listProviderModels("claude");
test("Claude catalog exposes Sonnet and Haiku variants", async () => {
const result = await ctx.client.listProviderModels("claude");
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
const descriptions = result.models.map(
(model) => `${model.label} ${model.description ?? ""}`.toLowerCase()
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
},
180_000
);
const descriptions = result.models.map((model) =>
`${model.label} ${model.description ?? ""}`.toLowerCase(),
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
}, 180_000);
test.runIf(hasCodex)(
"Codex catalog exposes gpt-5.1-codex",
@@ -55,7 +48,7 @@ describe("provider model catalogs (e2e)", () => {
const ids = result.models.map((model) => model.id);
expect(ids.some((id) => id.startsWith("gpt-5.1-codex"))).toBe(true);
},
180_000
180_000,
);
test.runIf(hasOpenCode)(
@@ -78,6 +71,6 @@ describe("provider model catalogs (e2e)", () => {
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
expect(providerIds.size).toBeGreaterThan(0);
},
180_000
180_000,
);
});
@@ -40,7 +40,11 @@ describe("resolveAgentModel", () => {
opencode: { fetchModels: vi.fn() },
} as any);
const result = await resolveAgentModel({ provider: "claude", cwd: "~/repo", logger: testLogger });
const result = await resolveAgentModel({
provider: "claude",
cwd: "~/repo",
logger: testLogger,
});
expect(result).toBe("claude-3.5-sonnet");
expect(fetchModels).toHaveBeenCalledWith({
@@ -11,7 +11,7 @@ type ResolveAgentModelOptions = {
};
export async function resolveAgentModel(
options: ResolveAgentModelOptions
options: ResolveAgentModelOptions,
): Promise<string | undefined> {
const trimmed = options.requestedModel?.trim();
if (trimmed) {
@@ -28,7 +28,7 @@ export async function resolveAgentModel(
} catch (error) {
options.logger.warn(
{ err: error, provider: options.provider },
"Failed to resolve default model"
"Failed to resolve default model",
);
return undefined;
}
@@ -3,10 +3,7 @@ import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import type { AgentStreamEventPayload } from "../messages.js";
describe("OpenCode reasoning events (e2e)", () => {
@@ -23,111 +20,95 @@ describe("OpenCode reasoning events (e2e)", () => {
await rm(agentCwd, { recursive: true, force: true });
}, 60_000);
test(
"gpt-5 nano emits reasoning events through daemon",
async () => {
const allEvents: Array<{
event: AgentStreamEventPayload;
timestamp: string;
}> = [];
// Subscribe to all events
ctx.client.on((event) => {
if (event.type === "agent_stream") {
allEvents.push({
event: event.event,
timestamp: event.timestamp,
});
}
});
// Create agent with gpt-5 nano model
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd: agentCwd,
model: "opencode/gpt-5-nano",
title: "reasoning-test",
});
expect(agent.id).toBeTruthy();
// Send a message that should trigger reasoning
await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step.");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120_000);
// Log all events
for (let i = 0; i < allEvents.length; i++) {
const { event, timestamp } = allEvents[i];
test("gpt-5 nano emits reasoning events through daemon", async () => {
const allEvents: Array<{
event: AgentStreamEventPayload;
timestamp: string;
}> = [];
// Subscribe to all events
ctx.client.on((event) => {
if (event.type === "agent_stream") {
allEvents.push({
event: event.event,
timestamp: event.timestamp,
});
}
});
// Group by type
const byType = new Map<string, number>();
for (const { event } of allEvents) {
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
// Create agent with gpt-5 nano model
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd: agentCwd,
model: "opencode/gpt-5-nano",
title: "reasoning-test",
});
expect(agent.id).toBeTruthy();
// Send a message that should trigger reasoning
await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step.");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120_000);
// Log all events
for (let i = 0; i < allEvents.length; i++) {
const { event, timestamp } = allEvents[i];
}
// Group by type
const byType = new Map<string, number>();
for (const { event } of allEvents) {
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
}
for (const [type, count] of byType) {
}
// Check timeline events breakdown
const timelineEvents = allEvents.filter(({ event }) => event.type === "timeline");
const itemTypes = new Map<string, number>();
for (const { event } of timelineEvents) {
if (event.type === "timeline") {
itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1);
}
}
for (const [type, count] of byType) {
for (const [type, count] of itemTypes) {
}
// Find reasoning events
const reasoningEvents = timelineEvents.filter(
({ event }) => event.type === "timeline" && event.item.type === "reasoning",
);
for (const { event } of reasoningEvents.slice(0, 5)) {
if (event.type === "timeline") {
}
}
// Check timeline events breakdown
const timelineEvents = allEvents.filter(
({ event }) => event.type === "timeline"
);
const itemTypes = new Map<string, number>();
for (const { event } of timelineEvents) {
if (event.type === "timeline") {
itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1);
// Check for duplicate consecutive events
let duplicateCount = 0;
for (let i = 1; i < allEvents.length; i++) {
const prev = allEvents[i - 1];
const curr = allEvents[i];
if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) {
duplicateCount++;
if (duplicateCount <= 5) {
}
}
}
for (const [type, count] of itemTypes) {
// HARD ASSERT: Agent completed
expect(finalState.status).toBe("idle");
}
// HARD ASSERT: Got events
expect(allEvents.length).toBeGreaterThan(0);
// Find reasoning events
const reasoningEvents = timelineEvents.filter(
({ event }) => event.type === "timeline" && event.item.type === "reasoning"
);
for (const { event } of reasoningEvents.slice(0, 5)) {
if (event.type === "timeline") {
}
}
// Check for duplicate consecutive events
let duplicateCount = 0;
for (let i = 1; i < allEvents.length; i++) {
const prev = allEvents[i - 1];
const curr = allEvents[i];
if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) {
duplicateCount++;
if (duplicateCount <= 5) {
}
}
}
// HARD ASSERT: Agent completed
expect(finalState.status).toBe("idle");
// HARD ASSERT: Got events
expect(allEvents.length).toBeGreaterThan(0);
// Delete the agent
await ctx.client.deleteAgent(agent.id);
},
180_000
);
// Delete the agent
await ctx.client.deleteAgent(agent.id);
}, 180_000);
});
@@ -26,11 +26,7 @@ export class Pcm16MonoResampler {
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
}
const srcChunk = new Int16Array(
pcm16le.buffer,
pcm16le.byteOffset,
pcm16le.byteLength / 2
);
const srcChunk = new Int16Array(pcm16le.buffer, pcm16le.byteOffset, pcm16le.byteLength / 2);
const hasCarry = this.carrySample !== null;
const srcLen = srcChunk.length + (hasCarry ? 1 : 0);
@@ -80,4 +76,3 @@ export class Pcm16MonoResampler {
return Buffer.from(outArr.buffer, outArr.byteOffset, outArr.byteLength);
}
}
@@ -43,7 +43,7 @@ describe("resolveProviderCommandPrefix", () => {
mode: "append",
args: ["--chrome"],
},
resolveDefault
resolveDefault,
);
expect(resolveDefault).toHaveBeenCalledTimes(1);
@@ -61,7 +61,7 @@ describe("resolveProviderCommandPrefix", () => {
mode: "replace",
argv: ["docker", "run", "--rm", "my-wrapper"],
},
resolveDefault
resolveDefault,
);
expect(resolveDefault).not.toHaveBeenCalled();
@@ -117,12 +117,10 @@ describe("findExecutable", () => {
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(
"echo from profile\n/usr/local/bin/codex\n"
"echo from profile\n/usr/local/bin/codex\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex"
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
});
@@ -143,10 +141,8 @@ describe("findExecutable", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex"
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith(
"/usr/local/bin/codex"
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
});
});
@@ -42,7 +42,7 @@ export const ProviderRuntimeSettingsSchema = z
export const AgentProviderRuntimeSettingsMapSchema = z.record(
AgentProviderSchema,
ProviderRuntimeSettingsSchema
ProviderRuntimeSettingsSchema,
);
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
@@ -67,7 +67,7 @@ interface FindExecutableDependencies {
function resolveExecutableFromWhichOutput(
name: string,
output: string,
source: "login-shell" | "which"
source: "login-shell" | "which",
): string | null {
const lines = output
.split(/\r?\n/)
@@ -81,7 +81,7 @@ function resolveExecutableFromWhichOutput(
if (!candidate.startsWith("/")) {
console.warn(
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
);
return null;
}
@@ -91,7 +91,7 @@ function resolveExecutableFromWhichOutput(
export function resolveProviderCommandPrefix(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string
resolveDefaultCommand: () => string,
): ProviderCommandPrefix {
if (!commandConfig || commandConfig.mode === "default") {
return {
@@ -128,7 +128,7 @@ export function resolveShellEnv(): Record<string, string> {
export function applyProviderEnv(
baseEnv: Record<string, string | undefined>,
runtimeSettings?: ProviderRuntimeSettings,
shellEnv?: Record<string, string>
shellEnv?: Record<string, string>,
): Record<string, string | undefined> {
return {
...baseEnv,
@@ -149,7 +149,7 @@ export function applyProviderEnv(
*/
export function findExecutable(
name: string,
dependencies?: FindExecutableDependencies
dependencies?: FindExecutableDependencies,
): string | null {
const trimmed = name.trim();
if (!trimmed) {
@@ -183,10 +183,12 @@ export function findExecutable(
const shell = deps.shell;
if (shell) {
try {
const out = deps.execSync(`${shell} -lic "which ${trimmed}"`, {
encoding: "utf8",
timeout: 5000,
}).trim();
const out = deps
.execSync(`${shell} -lic "which ${trimmed}"`, {
encoding: "utf8",
timeout: 5000,
})
.trim();
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
if (resolved) {
return resolved;
@@ -200,7 +202,7 @@ export function findExecutable(
return resolveExecutableFromWhichOutput(
trimmed,
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
"which"
"which",
);
} catch {
return null;
@@ -213,7 +215,7 @@ export function isCommandAvailable(command: string): boolean {
export function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string
resolveDefaultCommand: () => string,
): boolean {
try {
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = 'default' | 'safe' | 'moderate' | 'dangerous' | 'readonly';
export type AgentModeIcon = 'ShieldCheck' | 'ShieldAlert' | 'ShieldOff';
export type AgentModeColorTier = "default" | "safe" | "moderate" | "dangerous" | "readonly";
export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff";
export interface AgentModeVisuals {
icon: AgentModeIcon;
@@ -25,29 +25,83 @@ export interface AgentProviderDefinition {
}
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
{ id: "default", label: "Always Ask", description: "Prompts for permission the first time a tool is used", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", description: "Automatically approves edit-focused tools without prompting", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", description: "Analyze the codebase without executing tools or edits", icon: "ShieldCheck", colorTier: "readonly" },
{ id: "bypassPermissions", label: "Bypass", description: "Skip all permission prompts (use with caution)", icon: "ShieldAlert", colorTier: "dangerous" },
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
icon: "ShieldCheck",
colorTier: "readonly",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
icon: "ShieldAlert",
colorTier: "dangerous",
},
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{ id: "read-only", label: "Read Only", description: "Read files and answer questions. Manual approval required for edits, commands, or network ops.", icon: "ShieldCheck", colorTier: "readonly" },
{ id: "auto", label: "Auto", description: "Edit files and run commands but still request approval before escalating scope.", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", description: "Edit files, run commands, and access the network without additional prompts.", icon: "ShieldAlert", colorTier: "dangerous" },
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "readonly",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "full-access",
label: "Full Access",
description: "Edit files, run commands, and access the network without additional prompts.",
icon: "ShieldAlert",
colorTier: "dangerous",
},
];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldCheck", colorTier: "default" },
{ id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", colorTier: "readonly" },
{
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
icon: "ShieldCheck",
colorTier: "default",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
icon: "ShieldCheck",
colorTier: "readonly",
},
];
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "claude",
label: "Claude",
description:
"Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
@@ -59,8 +113,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "codex",
label: "Codex",
description:
"OpenAI's Codex workspace agent with sandbox controls and optional network access",
description: "OpenAI's Codex workspace agent with sandbox controls and optional network access",
defaultModeId: "auto",
modes: CODEX_MODES,
voice: {
@@ -72,8 +125,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "opencode",
label: "OpenCode",
description:
"Open-source coding assistant with multi-provider model support",
description: "Open-source coding assistant with multi-provider model support",
defaultModeId: "build",
modes: OPENCODE_MODES,
voice: {
@@ -91,7 +143,10 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [string, ...string[]];
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
string,
...string[],
];
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
@@ -17,14 +17,9 @@ import {
type AgentProviderDefinition,
} from "./provider-manifest.js";
export type {
AgentProviderDefinition,
};
export type { AgentProviderDefinition };
export {
AGENT_PROVIDER_DEFINITIONS,
getAgentProviderDefinition,
};
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
createClient: (logger: Logger) => AgentClient;
@@ -37,21 +32,15 @@ type BuildProviderRegistryOptions = {
export function buildProviderRegistry(
logger: Logger,
options?: BuildProviderRegistryOptions
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, ProviderDefinition> {
const runtimeSettings = options?.runtimeSettings;
const claudeClient = new ClaudeAgentClient({
logger,
runtimeSettings: runtimeSettings?.claude,
});
const codexClient = new CodexAppServerAgentClient(
logger,
runtimeSettings?.codex
);
const opencodeClient = new OpenCodeAgentClient(
logger,
runtimeSettings?.opencode
);
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
return {
claude: {
@@ -68,8 +57,7 @@ export function buildProviderRegistry(
},
opencode: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
createClient: (logger: Logger) =>
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
fetchModels: (options) => opencodeClient.listModels(options),
},
};
@@ -80,7 +68,7 @@ export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null
export function createAllClients(
logger: Logger,
options?: BuildProviderRegistryOptions
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger, options);
return {
@@ -92,10 +80,7 @@ export function createAllClients(
export async function shutdownProviders(
logger: Logger,
options?: BuildProviderRegistryOptions
options?: BuildProviderRegistryOptions,
): Promise<void> {
await OpenCodeServerManager.getInstance(
logger,
options?.runtimeSettings?.opencode
).shutdown();
await OpenCodeServerManager.getInstance(logger, options?.runtimeSettings?.opencode).shutdown();
}
@@ -1,8 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
@@ -7,40 +7,36 @@ import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => {
test(
"lists slash commands with the expected contract",
async () => {
expect(isCommandAvailable("claude")).toBe(true);
test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "plan",
});
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "plan",
});
try {
expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
try {
expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBeGreaterThan(0);
expect(commands.map((command) => command.name)).toContain("rewind");
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBeGreaterThan(0);
expect(commands.map((command) => command.name)).toContain("rewind");
for (const command of commands) {
const typed = command as AgentSlashCommand;
expect(typeof typed.name).toBe("string");
expect(typed.name.length).toBeGreaterThan(0);
expect(typed.name.startsWith("/")).toBe(false);
expect(typeof typed.description).toBe("string");
expect(typeof typed.argumentHint).toBe("string");
}
} finally {
await session.close();
for (const command of commands) {
const typed = command as AgentSlashCommand;
expect(typeof typed.name).toBe("string");
expect(typed.name.length).toBeGreaterThan(0);
expect(typed.name.startsWith("/")).toBe(false);
expect(typeof typed.description).toBe("string");
expect(typeof typed.argumentHint).toBe("string");
}
},
60_000
);
} finally {
await session.close();
}
}, 60_000);
});
@@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type {
AgentSession,
AgentStreamEvent,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../provider-launch-config.js";
import { ClaudeAgentClient } from "./claude-agent.js";
@@ -34,7 +30,7 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
async function nextStreamEvent(
stream: AsyncGenerator<AgentStreamEvent>,
timeoutMs: number,
label: string
label: string,
): Promise<IteratorResult<AgentStreamEvent>> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@@ -58,15 +54,11 @@ async function collectUntilTerminal(
options?: {
timeoutMs?: number;
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
}
},
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
const next = await nextStreamEvent(
stream,
options?.timeoutMs ?? 45_000,
"stream event"
);
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
if (next.done || !next.value) {
return events;
}
@@ -82,7 +74,7 @@ async function collectUntilTerminal(
async function collectUntil(
stream: AsyncGenerator<AgentStreamEvent>,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
@@ -118,16 +110,10 @@ function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
});
}
function getLatestCompletedBashCall(
events: AgentStreamEvent[]
): ToolCallTimelineItem | undefined {
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
return [...getToolCalls(events)]
.reverse()
.find(
(item) =>
item.status === "completed" &&
item.name.toLowerCase() === "bash"
);
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function getInternalQuery(session: AgentSession): unknown {
@@ -150,10 +136,7 @@ async function createSession(params?: {
return { cwd, session };
}
async function cleanupSession(handle: {
cwd: string;
session: AgentSession;
}): Promise<void> {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
}
@@ -163,252 +146,225 @@ describe("ClaudeAgentSession integration", () => {
expect(isCommandAvailable("claude")).toBe(true);
});
test(
"streams a basic response turn end-to-end",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
test("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
handle.session.stream("Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
try {
const events = await collectUntilTerminal(
handle.session.stream("Respond with exactly: HELLO_WORLD")
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world")
)
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000
);
test(
"runs a real Bash tool call and completes it",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
handle.session.stream(
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" ")
)
);
const bashCalls = getToolCalls(events).filter(
(item) => item.name.toLowerCase() === "bash"
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT")
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000
);
test(
"interrupts a running Bash turn and continues on the same query",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = handle.session.stream(
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" ")
);
const initialEvents = await collectUntil(
firstStream,
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) =>
event.type === "turn_canceled" && event.provider === "claude"
)
).toBe(true);
const followUpEvents = await collectUntilTerminal(
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK")
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain(
"after_interrupt_ok"
);
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000
);
test(
"creates an autonomous live turn when a background task completes",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const liveEventsStream = handle.session.streamLiveEvents();
const foregroundEvents = await collectUntilTerminal(
handle.session.stream(
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" ")
),
{ timeoutMs: 45_000 }
);
test("runs a real Bash tool call and completes it", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
try {
const events = await collectUntilTerminal(
handle.session.stream(
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const liveEvents = await collectUntilTerminal(liveEventsStream, {
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("interrupts a running Bash turn and continues on the same query", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = handle.session.stream(
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
const followUpEvents = await collectUntilTerminal(
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("creates an autonomous live turn when a background task completes", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const liveEventsStream = handle.session.streamLiveEvents();
const foregroundEvents = await collectUntilTerminal(
handle.session.stream(
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectUntilTerminal(liveEventsStream, {
timeoutMs: 45_000,
});
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
handle.session.stream(
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
});
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
},
);
expect(
liveEvents.some(
(event) => event.type === "turn_started" && event.provider === "claude"
)
).toBe(true);
expect(
compactText(getAssistantText(liveEvents))
).toContain(autonomousWakeToken.toLowerCase());
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
test(
"surfaces permission requests and resumes after approval",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
handle.session.stream(
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" ")
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
}
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested"
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved"
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000
);
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
});
@@ -182,7 +182,7 @@ function createScriptedQuery(params: {
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
stream: AsyncGenerator<AgentStreamEvent>,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of stream) {
@@ -211,7 +211,7 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
async function waitFor(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number }
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 2_000;
const intervalMs = options?.intervalMs ?? 5;
@@ -259,9 +259,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
expect(queries[0]?.return).not.toHaveBeenCalled();
const firstTurnEvents = await collectUntilTerminal(firstTurn);
expect(
firstTurnEvents.find((event) => event.type === "turn_canceled")
).toMatchObject({
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
type: "turn_canceled",
provider: "claude",
reason: "Interrupted",
@@ -546,8 +544,8 @@ describe("ClaudeAgentSession autonomous turns", () => {
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
expect(
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
(event) => event?.type === "turn_canceled"
)
(event) => event?.type === "turn_canceled",
),
).toBe(false);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
@@ -5,7 +5,7 @@ import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
describe("normalizeClaudeRuntimeModelId", () => {
function latestModelId(family: "sonnet" | "opus" | "haiku"): string {
const latest = CLAUDE_MODEL_CATALOG.find(
(model) => model.family === family && model.isLatestInFamily
(model) => model.family === family && model.isLatestInFamily,
);
if (latest) {
return latest.modelId;
@@ -127,13 +127,11 @@ function createSpyLogger(): {
}
function extractStringLogArgs(calls: unknown[][]): string[] {
return calls.flatMap((args) =>
args.filter((arg): arg is string => typeof arg === "string")
);
return calls.flatMap((args) => args.filter((arg): arg is string => typeof arg === "string"));
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
stream: AsyncGenerator<AgentStreamEvent>,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of stream) {
@@ -149,10 +147,7 @@ async function collectUntilTerminal(
return events;
}
async function waitForCondition(
check: () => boolean,
timeoutMs: number
): Promise<void> {
async function waitForCondition(check: () => boolean, timeoutMs: number): Promise<void> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (check()) {
@@ -219,7 +214,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
};
}
return { done: true, value: undefined };
})
}),
);
});
@@ -242,13 +237,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
try {
await session.run("redaction check");
const queryLogCall = spy.debug.mock.calls.find(
(args) => args[1] === "claude query"
);
const queryLogCall = spy.debug.mock.calls.find((args) => args[1] === "claude query");
expect(queryLogCall).toBeDefined();
const payload = queryLogCall?.[0] as
| { options?: Record<string, unknown> }
| undefined;
const payload = queryLogCall?.[0] as { options?: Record<string, unknown> } | undefined;
expect(payload?.options).toBeDefined();
expect(payload?.options).not.toHaveProperty("env");
expect(payload?.options).not.toHaveProperty("systemPrompt");
@@ -256,9 +247,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(payload?.options).toHaveProperty("hasEnv");
expect(payload?.options).toHaveProperty("envKeyCount");
const serialized = JSON.stringify(
spy.calls,
(_key, value) => (typeof value === "function" ? "[function]" : value)
const serialized = JSON.stringify(spy.calls, (_key, value) =>
typeof value === "function" ? "[function]" : value,
);
expect(serialized).not.toContain(envSecret);
expect(serialized).not.toContain(runtimeSecret);
@@ -277,12 +267,10 @@ describe("ClaudeAgentSession redesign invariants", () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal = session as unknown as {
query:
| {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
}
| null;
query: {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
} | null;
input: { end: () => void } | null;
queryRestartNeeded: boolean;
interruptActiveTurn: () => Promise<void>;
@@ -300,12 +288,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
try {
await internal.interruptActiveTurn();
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter(
(message) => message.includes("interruptActiveTurn")
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) =>
message.includes("interruptActiveTurn"),
);
const interruptDebugMessages = extractStringLogArgs(spy.debug.mock.calls).filter((message) =>
message.includes("interruptActiveTurn"),
);
const interruptDebugMessages = extractStringLogArgs(
spy.debug.mock.calls
).filter((message) => message.includes("interruptActiveTurn"));
expect(interruptInfoMessages).toEqual([]);
expect(interruptDebugMessages).toEqual([
@@ -401,8 +389,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
for (const fixture of fixtures) {
expect(
readEventIdentifiers(
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0]
)
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0],
),
).toEqual(fixture.expected);
}
});
@@ -482,7 +470,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
index,
delta: {
type: "input_json_delta",
partial_json: "{\"command\":\"echo ",
partial_json: '{"command":"echo ',
},
},
expectedCommand: "echo ",
@@ -493,7 +481,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
index,
delta: {
type: "input_json_delta",
partial_json: "delta\"}",
partial_json: 'delta"}',
},
},
expectedCommand: "echo delta",
@@ -503,7 +491,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
type: "content_block_delta",
delta: {
type: "input_json_delta",
partial_json: "{\"command\":\"ignored\"}",
partial_json: '{"command":"ignored"}',
},
},
expectedCommand: "echo delta",
@@ -511,9 +499,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
] as const;
for (const fixture of deltaFixtures) {
internal.mapPartialEvent(
fixture.event as unknown as Record<string, unknown>
);
internal.mapPartialEvent(fixture.event as unknown as Record<string, unknown>);
expect(readCommand()).toBe(fixture.expectedCommand);
}
@@ -553,8 +539,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
index,
delta: {
type: "input_json_delta",
partial_json:
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before",
partial_json: '{"file_path":"src/message.tsx","old_string":"before',
},
});
@@ -572,7 +557,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const internal = session as unknown as {
buildToolOutput: (
block: Record<string, unknown>,
entry: Record<string, unknown> | undefined
entry: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined;
};
@@ -611,7 +596,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
},
a: 0,
},
expectedOutput: "{\"a\":0,\"nested\":{\"a\":1,\"b\":2},\"z\":3}",
expectedOutput: '{"a":0,"nested":{"a":1,"b":2},"z":3}',
},
] as const;
@@ -625,14 +610,14 @@ describe("ClaudeAgentSession redesign invariants", () => {
content: fixture.content,
is_error: false,
},
toolEntry
toolEntry,
);
expect(output).toEqual(
expect.objectContaining({
type: "command",
command: "echo hello",
output: fixture.expectedOutput,
})
}),
);
}
} finally {
@@ -647,12 +632,16 @@ describe("ClaudeAgentSession redesign invariants", () => {
runTracker: {
bindIdentifiers: (
run: { id: string },
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
) => void;
};
routeMessage: (input: {
message: AgentStreamEvent | Record<string, unknown>;
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
identifiers: {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
};
metadataOnly: boolean;
}) => { run: { id: string } | null; reason: string };
turnState: "autonomous";
@@ -727,14 +716,18 @@ describe("ClaudeAgentSession redesign invariants", () => {
runTracker: {
bindIdentifiers: (
run: { id: string },
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
) => void;
};
activeForegroundTurn: { runId: string; queue: unknown } | null;
turnState: "foreground";
routeMessage: (input: {
message: Record<string, unknown>;
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
identifiers: {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
};
metadataOnly: boolean;
}) => { run: { id: string } | null; reason: string };
};
@@ -846,8 +839,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
};
}
return { done: true, value: undefined };
})
)
}),
),
);
const session = await createSession();
@@ -857,7 +850,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Timed out waiting for foreground terminal event")),
1_000
1_000,
);
}),
]);
@@ -867,7 +860,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
event.type === "timeline" && event.item.type === "assistant_message",
)
.map((event) => event.item.text)
.join("");
@@ -938,7 +931,10 @@ describe("ClaudeAgentSession redesign invariants", () => {
const internal = session as unknown as {
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
activeForegroundTurn: { runId: string; queue: unknown } | null;
runTracker: { getRun: (runId: string) => unknown; complete: (run: unknown, state: "completed") => void };
runTracker: {
getRun: (runId: string) => unknown;
complete: (run: unknown, state: "completed") => void;
};
pushEvent: (event: AgentStreamEvent) => void;
};
@@ -1068,7 +1064,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
};
}
return { done: true, value: undefined };
})
}),
);
mock.interrupt.mockImplementation(async () => {
@@ -1079,41 +1075,28 @@ describe("ClaudeAgentSession redesign invariants", () => {
streamCase = "success";
const successEvents = await collectUntilTerminal(session.stream("success prompt"));
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(
true
);
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(
false
);
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(
false
);
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false);
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false);
streamCase = "error";
const errorEvents = await collectUntilTerminal(session.stream("error prompt"));
expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true);
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(
false
);
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false);
streamCase = "interrupt";
const interruptStream = session.stream("interrupt prompt");
const interruptEvents: AgentStreamEvent[] = [];
for await (const event of interruptStream) {
interruptEvents.push(event);
if (
event.type === "timeline" &&
event.item.type === "assistant_message"
) {
if (event.type === "timeline" && event.item.type === "assistant_message") {
await session.interrupt();
}
if (event.type === "turn_canceled") {
break;
}
}
expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(
true
);
expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(true);
await session.close();
});
@@ -1216,7 +1199,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
};
}
return { done: true, value: undefined };
})
}),
);
});
@@ -1225,7 +1208,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
event.type === "timeline" && event.item.type === "assistant_message",
)
.map((event) => event.item.text)
.join("");
@@ -1335,7 +1318,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
};
}
return { done: true, value: undefined };
})
}),
);
});
@@ -1344,7 +1327,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
event.type === "timeline" && event.item.type === "assistant_message",
)
.map((event) => event.item.text)
.join("");
@@ -1358,5 +1341,4 @@ describe("ClaudeAgentSession redesign invariants", () => {
await session.close();
});
});
@@ -47,7 +47,7 @@ function buildQueryMock(events: unknown[]): QueryMock {
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
stream: AsyncGenerator<AgentStreamEvent>,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of stream) {
@@ -235,7 +235,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
},
total_cost_usd: 0,
},
])
]),
);
});
@@ -255,16 +255,14 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call"
event.type === "timeline" && event.item.type === "tool_call",
)
.map((event) => event.item)
.filter((item) => item.callId === "task-call-1");
expect(timelineToolCalls.length).toBeGreaterThanOrEqual(2);
const subAgentUpdates = timelineToolCalls.filter(
(item) => item.detail.type === "sub_agent"
);
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
expect(subAgentUpdates.length).toBeGreaterThanOrEqual(1);
const latest = subAgentUpdates[subAgentUpdates.length - 1];
@@ -298,7 +296,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
}));
const projected = projectTimelineRows(rows, "claude", "projected");
const projectedTaskCalls = projected.filter(
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1"
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1",
);
expect(projectedTaskCalls).toHaveLength(1);
@@ -318,13 +316,11 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call"
event.type === "timeline" && event.item.type === "tool_call",
)
.map((event) => event.item)
.filter((item) => item.callId === "task-tail-1");
const subAgentUpdates = timelineToolCalls.filter(
(item) => item.detail.type === "sub_agent"
);
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
@@ -129,9 +129,9 @@ describe("convertClaudeHistoryEntry", () => {
},
};
const mapBlocks = vi.fn().mockReturnValue([
{ type: "assistant_message", text: "No response requested." },
]);
const mapBlocks = vi
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
@@ -241,34 +241,30 @@ describe("convertClaudeHistoryEntry", () => {
describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger();
test(
"returns models with required fields",
async () => {
const client = new ClaudeAgentClient({ logger });
const models = await client.listModels();
test("returns models with required fields", async () => {
const client = new ClaudeAgentClient({ logger });
const models = await client.listModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.provider).toBe("claude");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
for (const model of models) {
expect(model.provider).toBe("claude");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
const modelIds = models.map((model) => model.id);
expect(
modelIds.some(
(id) =>
id.includes("claude") ||
id.includes("sonnet") ||
id.includes("opus") ||
id.includes("haiku")
)
).toBe(true);
},
60_000
);
const modelIds = models.map((model) => model.id);
expect(
modelIds.some(
(id) =>
id.includes("claude") ||
id.includes("sonnet") ||
id.includes("opus") ||
id.includes("haiku"),
),
).toBe(true);
}, 60_000);
});
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import type {
AgentPersistenceHandle,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
@@ -103,7 +100,7 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
async function readNextEvent(
iterator: AsyncIterator<AgentStreamEvent>,
timeoutMs: number
timeoutMs: number,
): Promise<AgentStreamEvent> {
const outcome = await Promise.race([
iterator.next().then((result) => ({ kind: "result" as const, result })),
@@ -168,7 +165,7 @@ describe("ClaudeAgentSession history replay regression", () => {
},
}),
].join("\n"),
"utf8"
"utf8",
);
previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
@@ -272,12 +269,7 @@ describe("ClaudeAgentSession history replay regression", () => {
};
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const historyPath = path.join(
configDir,
"projects",
sanitized,
"history-session.jsonl"
);
const historyPath = path.join(configDir, "projects", sanitized, "history-session.jsonl");
const session = await client.resumeSession(handle, { cwd });
@@ -312,7 +304,7 @@ describe("ClaudeAgentSession history replay regression", () => {
content: APPENDED_ASSISTANT_MARKER,
},
})}`,
"utf8"
"utf8",
);
const appendedEvents: AgentStreamEvent[] = [];
@@ -322,13 +314,13 @@ describe("ClaudeAgentSession history replay regression", () => {
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification"
event.item.name === "task_notification",
);
const sawAssistant = appendedEvents.some(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes(APPENDED_ASSISTANT_MARKER)
event.item.text.includes(APPENDED_ASSISTANT_MARKER),
);
if (sawTaskNotification && sawAssistant) {
break;
@@ -339,11 +331,11 @@ describe("ClaudeAgentSession history replay regression", () => {
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification"
event.item.name === "task_notification",
);
const turnStartedEvent = appendedEvents.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
event.type === "turn_started"
event.type === "turn_started",
);
expect(taskNotificationEvent).toBeTruthy();
@@ -420,9 +412,8 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
expect(sdkMocks.lastQuery).toBeTruthy();
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith(
"history-user-uuid",
{ dryRun: false }
);
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
dryRun: false,
});
});
});
@@ -5,11 +5,7 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";
import {
query,
type SDKMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
@@ -89,7 +89,7 @@ export function listClaudeCatalogModels(): AgentModelDefinition[] {
label: model.name,
description: model.description,
isDefault: model.isDefault,
})
}),
);
}
@@ -4,9 +4,7 @@ import { parsePartialJsonObject } from "./partial-json.js";
describe("parsePartialJsonObject", () => {
it("parses complete objects", () => {
expect(
parsePartialJsonObject("{\"command\":\"pwd\",\"cwd\":\"/tmp/repo\"}")
).toEqual({
expect(parsePartialJsonObject('{"command":"pwd","cwd":"/tmp/repo"}')).toEqual({
value: {
command: "pwd",
cwd: "/tmp/repo",
@@ -16,7 +14,7 @@ describe("parsePartialJsonObject", () => {
});
it("keeps partial string values without field-specific logic", () => {
expect(parsePartialJsonObject("{\"command\":\"echo ")).toEqual({
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
value: {
command: "echo ",
},
@@ -25,11 +23,7 @@ describe("parsePartialJsonObject", () => {
});
it("returns parsed prefix fields from incomplete objects", () => {
expect(
parsePartialJsonObject(
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before"
)
).toEqual({
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
old_string: "before",
@@ -39,11 +33,7 @@ describe("parsePartialJsonObject", () => {
});
it("parses nested partial values generically", () => {
expect(
parsePartialJsonObject(
"{\"payload\":{\"path\":\"src/index.ts\",\"content\":\"hello"
)
).toEqual({
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
value: {
payload: {
path: "src/index.ts",
@@ -55,6 +45,6 @@ describe("parsePartialJsonObject", () => {
});
it("returns null for non-object payloads", () => {
expect(parsePartialJsonObject("\"text\"")).toBeNull();
expect(parsePartialJsonObject('"text"')).toBeNull();
});
});
@@ -12,11 +12,8 @@ function skipWhitespace(input: string, index: number): number {
return currentIndex;
}
function parsePartialString(
input: string,
index: number
): PartialParseResult<string> | null {
if (input[index] !== "\"") {
function parsePartialString(input: string, index: number): PartialParseResult<string> | null {
if (input[index] !== '"') {
return null;
}
@@ -25,7 +22,7 @@ function parsePartialString(
while (currentIndex < input.length) {
const character = input[currentIndex];
if (character === "\"") {
if (character === '"') {
return {
value,
nextIndex: currentIndex + 1,
@@ -44,7 +41,7 @@ function parsePartialString(
}
switch (escapeCharacter) {
case "\"":
case '"':
case "\\":
case "/":
value += escapeCharacter;
@@ -105,7 +102,7 @@ function parsePartialString(
function parsePartialLiteral(
input: string,
index: number
index: number,
): PartialParseResult<boolean | null> | null {
const remaining = input.slice(index);
if (remaining.startsWith("true")) {
@@ -120,13 +117,8 @@ function parsePartialLiteral(
return null;
}
function parsePartialNumber(
input: string,
index: number
): PartialParseResult<number> | null {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
input.slice(index)
);
function parsePartialNumber(input: string, index: number): PartialParseResult<number> | null {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(input.slice(index));
if (!match?.[0]) {
return null;
}
@@ -138,10 +130,7 @@ function parsePartialNumber(
};
}
function parsePartialArray(
input: string,
index: number
): PartialParseResult<unknown[]> | null {
function parsePartialArray(input: string, index: number): PartialParseResult<unknown[]> | null {
if (input[index] !== "[") {
return null;
}
@@ -225,7 +214,7 @@ function parsePartialArray(
function parsePartialObject(
input: string,
index: number
index: number,
): PartialParseResult<Record<string, unknown>> | null {
if (input[index] !== "{") {
return null;
@@ -327,10 +316,7 @@ function parsePartialObject(
};
}
function parsePartialJsonValue(
input: string,
index: number
): PartialParseResult<unknown> | null {
function parsePartialJsonValue(input: string, index: number): PartialParseResult<unknown> | null {
const currentIndex = skipWhitespace(input, index);
const character = input[currentIndex];
@@ -338,7 +324,7 @@ function parsePartialJsonValue(
return null;
}
if (character === "\"") {
if (character === '"') {
return parsePartialString(input, currentIndex);
}
if (character === "{") {
@@ -358,7 +344,7 @@ function parsePartialJsonValue(
}
export function parsePartialJsonObject(
input: string
input: string,
): { value: Record<string, unknown>; complete: boolean } | null {
const currentIndex = skipWhitespace(input, 0);
const parsed = parsePartialObject(input, currentIndex);
@@ -368,7 +354,6 @@ export function parsePartialJsonObject(
return {
value: parsed.value,
complete:
parsed.complete && skipWhitespace(input, parsed.nextIndex) === input.length,
complete: parsed.complete && skipWhitespace(input, parsed.nextIndex) === input.length,
};
}
@@ -3,11 +3,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { mapClaudeRunningToolCall } from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "../../../../shared/tool-call-display.js";
import type {
AgentMetadata,
AgentStreamEvent,
AgentTimelineItem,
} from "../../agent-sdk-types.js";
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
type ClaudeContentChunk = { type: string; [key: string]: unknown };
@@ -45,9 +41,7 @@ function readTrimmedString(value: unknown): string | undefined {
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as { type?: unknown }).type === "string"
value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string",
);
}
@@ -72,10 +66,7 @@ export class ClaudeSidechainTracker {
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(
state,
parentToolUseId
);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
const actionCandidates = this.extractSubAgentActionCandidates(message);
let actionUpdated = false;
for (const action of actionCandidates) {
@@ -104,7 +95,7 @@ export class ClaudeSidechainTracker {
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
actions: state.actions.map((action) => ({
@@ -136,7 +127,7 @@ export class ClaudeSidechainTracker {
private updateSubAgentContextFromTaskInput(
state: SubAgentActivityState,
parentToolUseId: string
parentToolUseId: string,
): boolean {
const taskInput = this.getToolInput(parentToolUseId);
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
@@ -237,17 +228,14 @@ export class ClaudeSidechainTracker {
private appendSubAgentAction(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate
candidate: SubAgentActionCandidate,
): boolean {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
return false;
}
const summary = this.deriveSubAgentActionSummary(
normalizedToolName,
candidate.input
);
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) {
@@ -256,10 +244,7 @@ export class ClaudeSidechainTracker {
return false;
}
const nextSummary = existing.summary ?? summary;
if (
existing.toolName === normalizedToolName &&
existing.summary === nextSummary
) {
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
return false;
}
state.actions[existingIndex] = {
@@ -299,10 +284,7 @@ export class ClaudeSidechainTracker {
}
}
private deriveSubAgentActionSummary(
toolName: string,
input: unknown
): string | undefined {
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
@@ -10,8 +10,8 @@ describe("task-notification-tool-call", () => {
it("detects task notification user content in string payloads", () => {
expect(
isTaskNotificationUserContent(
"<task-notification>\n<task-id>bg-1</task-id>\n</task-notification>"
)
"<task-notification>\n<task-id>bg-1</task-id>\n</task-notification>",
),
).toBe(true);
expect(isTaskNotificationUserContent("hello")).toBe(false);
});
@@ -8,7 +8,7 @@ const TAG_NAME_PATTERN = /[.*+?^${}()|[\]\\]/g;
const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
(value) => (typeof value === "string" ? value.trim() : value),
z.string().min(1).optional()
z.string().min(1).optional(),
);
const TaskNotificationEnvelopeSchema = z.object({
@@ -130,10 +130,7 @@ function extractUserContentText(content: unknown): string | null {
function readTaskNotificationTagValue(input: ReadTaskNotificationTagInput): string | null {
const escapedTagName = input.tagName.replace(TAG_NAME_PATTERN, "\\$&");
const pattern = new RegExp(
`<${escapedTagName}>\\s*([\\s\\S]*?)\\s*</${escapedTagName}>`,
"i"
);
const pattern = new RegExp(`<${escapedTagName}>\\s*([\\s\\S]*?)\\s*</${escapedTagName}>`, "i");
const match = input.text.match(pattern);
if (!match) {
return null;
@@ -142,7 +139,7 @@ function readTaskNotificationTagValue(input: ReadTaskNotificationTagInput): stri
}
function parseTaskNotificationFromUserContent(
input: MapTaskNotificationUserContentToToolCallInput
input: MapTaskNotificationUserContentToToolCallInput,
): TaskNotificationEnvelope | null {
const parsedInput = MapTaskNotificationUserContentToToolCallInputSchema.safeParse(input);
if (!parsedInput.success) {
@@ -166,9 +163,7 @@ function parseTaskNotificationFromUserContent(
});
}
function parseTaskNotificationFromSystemRecord(
record: unknown
): TaskNotificationEnvelope | null {
function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificationEnvelope | null {
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
if (!parsedRecord.success) {
return null;
@@ -187,24 +182,18 @@ function parseTaskNotificationFromSystemRecord(
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
taskId:
toNonEmptyString(systemRecord.task_id) ??
(rawText
? readTaskNotificationTagValue({ text: rawText, tagName: "task-id" })
: null),
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }) : null),
status:
toNonEmptyString(systemRecord.status) ??
(rawText
? readTaskNotificationTagValue({ text: rawText, tagName: "status" })
: null),
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "status" }) : null),
summary:
toNonEmptyString(systemRecord.summary) ??
(rawText
? readTaskNotificationTagValue({ text: rawText, tagName: "summary" })
: null),
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "summary" }) : null),
outputFile:
toNonEmptyString(systemRecord.output_file) ??
(rawText
? readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ??
readTaskNotificationTagValue({ text: rawText, tagName: "output_file" })
? (readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ??
readTaskNotificationTagValue({ text: rawText, tagName: "output_file" }))
: null),
rawText,
});
@@ -231,12 +220,7 @@ function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string
}
const seed =
[
envelope.status,
envelope.summary,
envelope.outputFile,
envelope.rawText,
]
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
.filter((value): value is string => typeof value === "string")
.join("|") || "task_notification";
const digest = createHash("sha1").update(seed).digest("hex").slice(0, 12);
@@ -254,7 +238,7 @@ function buildTaskNotificationLabel(envelope: TaskNotificationEnvelope): string
}
function buildTaskNotificationStatus(
input: BuildTaskNotificationStatusInput
input: BuildTaskNotificationStatusInput,
): TaskNotificationLifecycle {
const normalizedStatus = input.status?.toLowerCase() ?? null;
if (normalizedStatus === "failed" || normalizedStatus === "error") {
@@ -270,7 +254,7 @@ function buildTaskNotificationStatus(
}
function toTaskNotificationToolCall(
envelope: TaskNotificationEnvelope
envelope: TaskNotificationEnvelope,
): TaskNotificationToolCallItem {
const lifecycle = buildTaskNotificationStatus({
status: envelope.status,
@@ -330,7 +314,7 @@ export function isTaskNotificationUserContent(content: unknown): boolean {
}
export function mapTaskNotificationUserContentToToolCall(
input: MapTaskNotificationUserContentToToolCallInput
input: MapTaskNotificationUserContentToToolCallInput,
): TaskNotificationToolCallItem | null {
const parsed = parseTaskNotificationFromUserContent(input);
if (!parsed) {
@@ -340,7 +324,7 @@ export function mapTaskNotificationUserContentToToolCall(
}
export function mapTaskNotificationSystemRecordToToolCall(
record: unknown
record: unknown,
): TaskNotificationToolCallItem | null {
const parsed = parseTaskNotificationFromSystemRecord(record);
if (!parsed) {
@@ -350,7 +334,7 @@ export function mapTaskNotificationSystemRecordToToolCall(
}
export function coerceTaskNotificationHistoryRecordToSystemMessage(
record: unknown
record: unknown,
): TaskNotificationSystemMessageLike | null {
const parsed = parseTaskNotificationFromSystemRecord(record);
if (!parsed) {
@@ -60,7 +60,12 @@ const ClaudeToolDetailPass2Schema = z.union([
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName(
"exec_command",
ToolShellInputSchema,
ToolShellOutputSchema,
toShellToolDetail,
),
toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
@@ -79,53 +84,116 @@ const ClaudeToolDetailPass2Schema = z.union([
}),
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName(
"write_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
toWriteToolDetail,
),
toolDetailBranchByName(
"create_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
toWriteToolDetail,
),
toolDetailBranchByName("Edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("MultiEdit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("multi_edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName(
"apply_patch",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail,
),
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName(
"str_replace_editor",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail
toEditToolDetail,
),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "web_search" })
toolDetailBranchByName(
"WebSearch",
ToolSearchInputSchema,
ToolWebSearchOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "web_search" }),
),
toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "web_search" })
toolDetailBranchByName(
"web_search",
ToolSearchInputSchema,
ToolWebSearchOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "web_search" }),
),
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "search" })
toSearchToolDetail({ input, toolName: "search" }),
),
toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "grep" })
toolDetailBranchByName(
"Grep",
ToolSearchInputSchema,
ToolGrepOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
),
toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "grep" })
toolDetailBranchByName(
"grep",
ToolSearchInputSchema,
ToolGrepOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
),
toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "glob" })
toolDetailBranchByName(
"Glob",
ToolSearchInputSchema,
ToolGlobOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "glob" }),
),
toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "glob" })
toolDetailBranchByName(
"glob",
ToolSearchInputSchema,
ToolGlobOutputSchema.nullable(),
(input, output) => toSearchToolDetail({ input, output, toolName: "glob" }),
),
toolDetailBranchByName(
"WebFetch",
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
toFetchToolDetail,
),
toolDetailBranchByName(
"web_fetch",
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
toFetchToolDetail,
),
toolDetailBranchByName(
"WebFetchTool",
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
toFetchToolDetail,
),
toolDetailBranchByName(
"web_fetch_tool",
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
toFetchToolDetail,
),
toolDetailBranchByName(
"webfetch",
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
toFetchToolDetail,
),
toolDetailBranchByName("WebFetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("web_fetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("WebFetchTool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("web_fetch_tool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("webfetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName(
"Skill",
z.object({ skill: z.string() }).passthrough(),
z.union([
z.object({ output: z.string() }).passthrough().transform((value) => value.output),
z.string(),
]).nullable(),
z
.union([
z
.object({ output: z.string() })
.passthrough()
.transform((value) => value.output),
z.string(),
])
.nullable(),
(input, output) => {
const skillName = input?.skill;
if (!skillName) {
@@ -137,7 +205,7 @@ const ClaudeToolDetailPass2Schema = z.union([
icon: "sparkles" as const,
...(output ? { text: output } : {}),
} satisfies ToolCallDetail;
}
},
),
ClaudeSpeakToolDetailSchema,
]);
@@ -145,7 +213,7 @@ const ClaudeToolDetailPass2Schema = z.union([
export function deriveClaudeToolDetail(
name: string,
input: unknown,
output: unknown
output: unknown,
): ToolCallDetail {
const pass1 = ClaudeToolEnvelopeSchema.safeParse({
name,
@@ -22,7 +22,7 @@ describe("claude tool-call mapper", () => {
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
}),
);
expect(item.type).toBe("tool_call");
@@ -43,7 +43,7 @@ describe("claude tool-call mapper", () => {
name: "Bash",
input: { command: "echo " },
output: null,
})
}),
);
expect(item.detail).toEqual({
@@ -59,7 +59,7 @@ describe("claude tool-call mapper", () => {
name: "read_file",
input: { file_path: "README.md" },
output: null,
})
}),
);
expect(readItem.detail).toEqual({
type: "read",
@@ -72,7 +72,7 @@ describe("claude tool-call mapper", () => {
name: "write_file",
input: { file_path: "src/new.ts" },
output: null,
})
}),
);
expect(writeItem.detail).toEqual({
type: "write",
@@ -85,7 +85,7 @@ describe("claude tool-call mapper", () => {
name: "apply_patch",
input: { file_path: "src/index.ts" },
output: null,
})
}),
);
expect(editItem.detail).toEqual({
type: "edit",
@@ -98,7 +98,7 @@ describe("claude tool-call mapper", () => {
name: "web_search",
input: { query: "tool call mapping" },
output: null,
})
}),
);
expect(searchItem.detail).toEqual({
type: "search",
@@ -114,7 +114,7 @@ describe("claude tool-call mapper", () => {
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
})
}),
);
expect(item.status).toBe("completed");
@@ -139,7 +139,7 @@ describe("claude tool-call mapper", () => {
{ type: "output_text", content: "beta" },
],
},
})
}),
);
expect(arrayContent.detail?.type).toBe("read");
@@ -157,7 +157,7 @@ describe("claude tool-call mapper", () => {
content: { type: "output_text", text: "gamma" },
},
},
})
}),
);
expect(objectContent.detail?.type).toBe("read");
@@ -174,7 +174,7 @@ describe("claude tool-call mapper", () => {
input: { command: "false" },
output: null,
error: { message: "Command failed" },
})
}),
);
expect(item.status).toBe("failed");
@@ -189,7 +189,7 @@ describe("claude tool-call mapper", () => {
name: "write_file",
input: { file_path: "src/new.ts", content: "export const x = 1;" },
output: null,
})
}),
);
expect(writeItem.detail?.type).toBe("write");
if (writeItem.detail?.type === "write") {
@@ -202,7 +202,7 @@ describe("claude tool-call mapper", () => {
name: "apply_patch",
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
output: null,
})
}),
);
expect(editItem.detail?.type).toBe("edit");
if (editItem.detail?.type === "edit") {
@@ -216,7 +216,7 @@ describe("claude tool-call mapper", () => {
name: "web_search",
input: { query: "tool call mapping" },
output: null,
})
}),
);
expect(searchItem.detail).toEqual({
type: "search",
@@ -232,7 +232,7 @@ describe("claude tool-call mapper", () => {
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
}),
);
expect(item.status).toBe("completed");
@@ -256,7 +256,7 @@ describe("claude tool-call mapper", () => {
filenames: ["a.txt", "b.txt"],
truncated: false,
},
})
}),
);
expect(item.status).toBe("completed");
@@ -292,7 +292,7 @@ describe("claude tool-call mapper", () => {
numLines: 1,
numMatches: 1,
},
})
}),
);
expect(item.status).toBe("completed");
@@ -330,7 +330,7 @@ describe("claude tool-call mapper", () => {
],
durationSeconds: 1.5,
},
})
}),
);
expect(item.detail).toEqual({
@@ -363,7 +363,7 @@ describe("claude tool-call mapper", () => {
durationMs: 250,
url: "https://example.com/article",
},
})
}),
);
expect(item.detail).toEqual({
@@ -385,7 +385,7 @@ describe("claude tool-call mapper", () => {
name: "mcp__paseo__speak",
input: { text: "Voice response from Claude." },
output: { ok: true },
})
}),
);
expect(item.name).toBe("speak");
@@ -403,7 +403,7 @@ describe("claude tool-call mapper", () => {
name: "mcp__paseo_voice__speak",
input: { text: "Hey! I can hear you." },
output: { ok: true },
})
}),
);
expect(item.name).toBe("speak");
@@ -12,12 +12,7 @@ type MapperParams = {
metadata?: Record<string, unknown>;
};
const ClaudeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
const ClaudeRawToolCallSchema = z
.object({
@@ -32,10 +27,7 @@ const ClaudeRawToolCallSchema = z
.passthrough();
const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
callId:
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId
: null,
callId: typeof raw.callId === "string" && raw.callId.trim().length > 0 ? raw.callId : null,
name: raw.name.trim(),
input: raw.input ?? null,
output: raw.output ?? null,
@@ -253,7 +245,7 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli
function mapClaudeToolCall(
params: MapperParams,
status: z.infer<typeof ClaudeToolCallStatusSchema>,
error: unknown | null
error: unknown | null,
): ToolCallTimelineItem | null {
const pass1 = ClaudeToolCallPass1Schema.safeParse({
...params,
@@ -277,26 +269,20 @@ function mapClaudeToolCall(
return toToolCallTimelineItem(pass2.data);
}
export function mapClaudeRunningToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "running", null);
}
export function mapClaudeCompletedToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "completed", null);
}
export function mapClaudeFailedToolCall(
params: MapperParams & { error: unknown }
params: MapperParams & { error: unknown },
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "failed", params.error);
}
export function mapClaudeCanceledToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "canceled", null);
}
@@ -21,21 +21,25 @@ function isCodexInstalled(): boolean {
}
describe("Codex app-server provider (e2e)", () => {
test.runIf(isCodexInstalled())("lists models and runs a simple prompt", async () => {
const client = new CodexAppServerAgentClient(createTestLogger());
const models = await client.listModels();
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
test.runIf(isCodexInstalled())(
"lists models and runs a simple prompt",
async () => {
const client = new CodexAppServerAgentClient(createTestLogger());
const models = await client.listModels();
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
const session = await client.createSession({
provider: "codex",
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
modeId: "auto",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const session = await client.createSession({
provider: "codex",
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
modeId: "auto",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
await session.close();
}, 30000);
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
await session.close();
},
30000,
);
});
@@ -19,7 +19,7 @@ describe("Codex app-server provider", () => {
{ type: "text", text: "hello" },
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
],
logger
logger,
);
const localImage = input.find((item) => (item as { type?: string })?.type === "localImage") as
| { type: "localImage"; path?: string }
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
function tmpDir(prefix: string): string {
@@ -32,14 +29,31 @@ describe("codex agent commands E2E", () => {
mkdirSync(skillsDir, { recursive: true });
writeFileSync(
path.join(promptsDir, "hello.md"),
["---", "description: Test prompt", "argument-hint: NAME=<name>", "---", "", "Say hello to $NAME and then output exactly PASEO_OK.", ""].join("\n"),
"utf8"
[
"---",
"description: Test prompt",
"argument-hint: NAME=<name>",
"---",
"",
"Say hello to $NAME and then output exactly PASEO_OK.",
"",
].join("\n"),
"utf8",
);
mkdirSync(path.join(skillsDir, "my-skill"), { recursive: true });
writeFileSync(
path.join(skillsDir, "my-skill", "SKILL.md"),
["---", "name: my-skill", "description: Test skill", "user-invocable: true", "---", "", "When invoked, respond with exactly PASEO_SKILL_OK.", ""].join("\n"),
"utf8"
[
"---",
"name: my-skill",
"description: Test skill",
"user-invocable: true",
"---",
"",
"When invoked, respond with exactly PASEO_SKILL_OK.",
"",
].join("\n"),
"utf8",
);
process.env.CODEX_HOME = codexHome;
@@ -85,8 +99,16 @@ describe("codex agent commands E2E", () => {
const promptPath = path.join(promptsDir, "paseo-test-sayok.md");
writeFileSync(
promptPath,
["---", "description: Say OK", "argument-hint: NAME=<name>", "---", "", "Output exactly: PASEO_OK $NAME", ""].join("\n"),
"utf8"
[
"---",
"description: Say OK",
"argument-hint: NAME=<name>",
"---",
"",
"Output exactly: PASEO_OK $NAME",
"",
].join("\n"),
"utf8",
);
const agent = await ctx.client.createAgent({
@@ -95,10 +117,7 @@ describe("codex agent commands E2E", () => {
title: "Codex Prompt Execute Test Agent",
});
await ctx.client.sendMessage(
agent.id,
"/prompts:paseo-test-sayok NAME=world"
);
await ctx.client.sendMessage(agent.id, "/prompts:paseo-test-sayok NAME=world");
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
@@ -115,7 +134,7 @@ describe("codex agent commands E2E", () => {
writeFileSync(
path.join(promptsDir, "paseo-test-sayok.md"),
["---", "description: Say OK", "---", "", "Output exactly: PASEO_OK", ""].join("\n"),
"utf8"
"utf8",
);
process.env.CODEX_HOME = codexHome;
@@ -149,10 +168,7 @@ describe("codex agent commands E2E", () => {
});
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
await ctx.client.sendMessage(
agent.id,
`/not-a-real-command respond with exactly: ${token}`
);
await ctx.client.sendMessage(agent.id, `/not-a-real-command respond with exactly: ${token}`);
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
@@ -337,7 +337,8 @@ describe("codex rollout parsing", () => {
payload: {
type: "custom_tool_call_output",
call_id: "call_patch_custom_1",
output: '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
output:
'{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
},
}),
];
@@ -19,10 +19,7 @@ function resolveCodexSessionRoot(): string | null {
return path.join(codexHome, "sessions");
}
async function findRolloutFile(
threadId: string,
root: string
): Promise<string | null> {
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
while (stack.length > 0) {
const { dir, depth } = stack.pop()!;
@@ -37,8 +34,7 @@ async function findRolloutFile(
if (entry.isFile()) {
const matchesThread = entry.name.includes(threadId);
const matchesPrefix = entry.name.startsWith("rollout-");
const matchesExtension =
entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
if (matchesThread && matchesPrefix && matchesExtension) {
return entryPath;
}
@@ -50,20 +46,19 @@ async function findRolloutFile(
return null;
}
const RolloutContentItemSchema = z
.union([
z.object({ type: z.literal("input_text"), text: z.string() }),
z.object({ type: z.literal("output_text"), text: z.string() }),
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
z.object({ type: z.literal("text"), text: z.string() }),
z
.object({
type: z.string(),
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough(),
]);
const RolloutContentItemSchema = z.union([
z.object({ type: z.literal("input_text"), text: z.string() }),
z.object({ type: z.literal("output_text"), text: z.string() }),
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
z.object({ type: z.literal("text"), text: z.string() }),
z
.object({
type: z.string(),
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough(),
]);
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
@@ -162,34 +157,33 @@ const RolloutEventUserMessagePayloadSchema = z.object({
.optional(),
});
type RolloutResponseReasoningPayload = z.infer<
typeof RolloutResponseReasoningPayloadSchema
>;
type RolloutResponseReasoningPayload = z.infer<typeof RolloutResponseReasoningPayloadSchema>;
type ParsedRolloutRecord =
| { kind: "timeline"; item: AgentTimelineItem }
| { kind: "call"; name: string; callId?: string; input?: unknown }
| { kind: "output"; callId: string; output: unknown }
| { kind: "ignore" };
const RolloutMessageContentSchema = z
.union([
z.string().transform((content) => content.trim()),
z.array(
const RolloutMessageContentSchema = z.union([
z.string().transform((content) => content.trim()),
z
.array(
z
.object({
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough()
).transform((content) =>
.passthrough(),
)
.transform((content) =>
content
.map((block) => block.text ?? block.message ?? "")
.map((text) => text.trim())
.filter(Boolean)
.join("\n")
.trim()
.trim(),
),
]);
]);
function extractMessageText(content: unknown): string {
const parsed = RolloutMessageContentSchema.safeParse(content);
@@ -199,17 +193,16 @@ function extractMessageText(content: unknown): string {
return parsed.data;
}
const RolloutEventMessageTextSchema = z
.union([
z.string(),
z
.object({
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough()
.transform((message) => message.message ?? message.text ?? ""),
]);
const RolloutEventMessageTextSchema = z.union([
z.string(),
z
.object({
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough()
.transform((message) => message.message ?? message.text ?? ""),
]);
function extractEventMessageText(message: unknown): string {
const parsed = RolloutEventMessageTextSchema.safeParse(message);
@@ -225,10 +218,7 @@ function isSyntheticRolloutUserMessage(text: string): boolean {
return false;
}
const lower = normalized.toLowerCase();
if (
lower.startsWith("# agents.md instructions for") &&
lower.includes("<instructions>")
) {
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
return true;
}
if (lower.startsWith("<environment_context>")) {
@@ -291,7 +281,7 @@ function mapCodexTerminalInteractionToToolCall(params: {
const processId = nonEmptyString(params.processId);
const callId = processId
? `terminal-session-${processId}`
: nonEmptyString(params.fallbackCallId) ?? "terminal-interaction";
: (nonEmptyString(params.fallbackCallId) ?? "terminal-interaction");
const label = nonEmptyString(params.command);
return {
type: "tool_call",
@@ -309,10 +299,13 @@ function mapCodexTerminalInteractionToToolCall(params: {
}
function buildTerminalCommandBySessionId(
parsedRecords: ParsedRolloutRecord[]
parsedRecords: ParsedRolloutRecord[],
): Map<string, string> {
const outputsByCallId = parsedRecords
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
.filter(
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
record.kind === "output",
)
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
const commandsBySessionId = new Map<string, string>();
@@ -325,7 +318,7 @@ function buildTerminalCommandBySessionId(
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
});
if (!mapped || mapped.detail.type !== "shell") {
continue;
@@ -350,91 +343,86 @@ function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
return Object.keys(rest).length > 0 ? rest : undefined;
}
const FunctionCallInputNormalizationSchema = z
.union([
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
z
.object({ command: z.array(z.string()) })
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
z.unknown().transform((input) => ({ name: "unknown", input })),
]);
const FunctionCallInputNormalizationSchema = z.union([
z
.object({ cmd: z.string() })
.transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
z
.object({ command: z.array(z.string()) })
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
z.unknown().transform((input) => ({ name: "unknown", input })),
]);
const RolloutResponseRecordSchema = z
.union([
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractMessageText(payload.content);
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
const shouldEmit = text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
return shouldEmit
? { kind: "timeline", item: { type: itemType, text } }
const RolloutResponseRecordSchema = z.union([
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractMessageText(payload.content);
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
const shouldEmit =
text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
return shouldEmit ? { kind: "timeline", item: { type: itemType, text } } : { kind: "ignore" };
}),
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractReasoningText(payload);
return text.length > 0
? { kind: "timeline", item: { type: "reasoning", text } }
: { kind: "ignore" };
}),
z
.union([RolloutResponseFunctionCallPayloadSchema, RolloutResponseCustomToolCallPayloadSchema])
.transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown";
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
const parsedArguments = parseJsonLikeValue(rawInput);
const normalized =
rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
: { name: rawName, input: parsedArguments };
return {
kind: "call",
name: normalized.name,
callId: payload.call_id,
input: normalized.input,
};
}),
z
.union([
RolloutResponseFunctionCallOutputPayloadSchema,
RolloutResponseCustomToolCallOutputPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
if (!payload.call_id) {
return { kind: "ignore" };
}
const output = readOutputPayloadValue(payload);
return output !== undefined
? { kind: "output", callId: payload.call_id, output }
: { kind: "ignore" };
}),
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractReasoningText(payload);
return text.length > 0
? { kind: "timeline", item: { type: "reasoning", text } }
: { kind: "ignore" };
}),
z
.union([
RolloutResponseFunctionCallPayloadSchema,
RolloutResponseCustomToolCallPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown";
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
const parsedArguments = parseJsonLikeValue(rawInput);
const normalized =
rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
: { name: rawName, input: parsedArguments };
return {
kind: "call",
name: normalized.name,
callId: payload.call_id,
input: normalized.input,
};
}),
z
.union([
RolloutResponseFunctionCallOutputPayloadSchema,
RolloutResponseCustomToolCallOutputPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
if (!payload.call_id) {
return { kind: "ignore" };
}
const output = readOutputPayloadValue(payload);
return output !== undefined
? { kind: "output", callId: payload.call_id, output }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutEventRecordSchema = z
.union([
RolloutEventAgentReasoningPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.text
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
: { kind: "ignore" }
),
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
return text.length > 0
? { kind: "timeline", item: { type: "assistant_message", text } }
: { kind: "ignore" };
}),
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
return shouldEmit
? { kind: "timeline", item: { type: "user_message", text } }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutEventRecordSchema = z.union([
RolloutEventAgentReasoningPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.text
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
: { kind: "ignore" },
),
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
return text.length > 0
? { kind: "timeline", item: { type: "assistant_message", text } }
: { kind: "ignore" };
}),
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
return shouldEmit
? { kind: "timeline", item: { type: "user_message", text } }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutRecordSchema = z
.object({
@@ -446,12 +434,10 @@ const RolloutRecordSchema = z
.transform((entry) =>
entry.type === "response_item"
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg)
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg),
);
function parseJsonRolloutTimeline(
parsed: unknown
): AgentTimelineItem[] | null {
function parseJsonRolloutTimeline(parsed: unknown): AgentTimelineItem[] | null {
if (!parsed || typeof parsed !== "object") {
return null;
}
@@ -464,8 +450,7 @@ function parseJsonRolloutTimeline(
if (!entry || typeof entry !== "object") {
continue;
}
const messagePayloadResult =
RolloutResponseMessagePayloadSchema.safeParse(entry);
const messagePayloadResult = RolloutResponseMessagePayloadSchema.safeParse(entry);
if (messagePayloadResult.success) {
const payload = messagePayloadResult.data;
const text = extractMessageText(payload.content);
@@ -482,8 +467,7 @@ function parseJsonRolloutTimeline(
continue;
}
const reasoningPayloadResult =
RolloutResponseReasoningPayloadSchema.safeParse(entry);
const reasoningPayloadResult = RolloutResponseReasoningPayloadSchema.safeParse(entry);
if (reasoningPayloadResult.success) {
const text = extractReasoningText(reasoningPayloadResult.data);
if (text) {
@@ -506,9 +490,7 @@ function timelineTextFingerprint(item: AgentTimelineItem): string | null {
}
}
function dedupeMirroredTextTimelineItems(
timeline: AgentTimelineItem[]
): AgentTimelineItem[] {
function dedupeMirroredTextTimelineItems(timeline: AgentTimelineItem[]): AgentTimelineItem[] {
const deduped: AgentTimelineItem[] = [];
for (const item of timeline) {
const prev = deduped[deduped.length - 1];
@@ -526,9 +508,7 @@ function dedupeMirroredTextTimelineItems(
return deduped;
}
export async function parseRolloutFile(
filePath: string
): Promise<AgentTimelineItem[]> {
export async function parseRolloutFile(filePath: string): Promise<AgentTimelineItem[]> {
const content = await fs.readFile(filePath, "utf8");
const trimmed = content.trim();
if (!trimmed) {
@@ -562,7 +542,10 @@ export async function parseRolloutFile(
.map((result) => result.data);
const outputsByCallId = parsedRecords
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
.filter(
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
record.kind === "output",
)
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
@@ -577,8 +560,7 @@ export async function parseRolloutFile(
? (record.input as { session_id?: unknown; sessionId?: unknown })
: null;
const sessionId =
readTerminalSessionId(input?.session_id) ??
readTerminalSessionId(input?.sessionId);
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
return [
mapCodexTerminalInteractionToToolCall({
processId: sessionId,
@@ -591,11 +573,11 @@ export async function parseRolloutFile(
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
});
return mapped ? [mapped] : [];
})()
: []
: [],
);
return dedupeMirroredTextTimelineItems(timeline);
}
@@ -608,7 +590,7 @@ export type CodexPersistedTimelineOptions = {
export async function loadCodexPersistedTimeline(
sessionId: string,
options?: CodexPersistedTimelineOptions,
logger?: Logger
logger?: Logger,
): Promise<AgentTimelineItem[]> {
const rolloutPath = options?.rolloutPath ?? null;
if (rolloutPath) {
@@ -633,11 +615,7 @@ export async function loadCodexPersistedTimeline(
if (preferredRoot) {
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
}
if (
!rolloutFile &&
fallbackRoot &&
fallbackRoot !== preferredRoot
) {
if (!rolloutFile && fallbackRoot && fallbackRoot !== preferredRoot) {
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
}
if (!rolloutFile) {
@@ -646,10 +624,7 @@ export async function loadCodexPersistedTimeline(
return await parseRolloutFile(rolloutFile);
} catch (error) {
logger?.warn(
{ err: error, sessionId },
"Failed to load persisted timeline"
);
logger?.warn({ err: error, sessionId }, "Failed to load persisted timeline");
return [];
}
}
@@ -64,7 +64,7 @@ const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
export function normalizeCodexFilePath(
filePath: string,
cwd: string | null | undefined
cwd: string | null | undefined,
): string | undefined {
if (!filePath) {
return undefined;
@@ -81,26 +81,41 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
}
const CodexToolDetailPass2Schema = z.union([
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
toolDetailBranchByNameWithCwd(
"Bash",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd("shell", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
toolDetailBranchByNameWithCwd(
"shell",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd("bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
toolDetailBranchByNameWithCwd(
"bash",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd("exec", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
toolDetailBranchByNameWithCwd(
"exec",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd(
"exec_command",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output)
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
toolDetailBranchByNameWithCwd(
"command",
ToolShellInputSchema,
ToolShellOutputSchema,
(input, output) => toShellToolDetail(input, output),
),
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
@@ -117,43 +132,55 @@ const CodexToolDetailPass2Schema = z.union([
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
normalizePath: normalizePathForCwd(cwd),
});
}
},
),
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
toolDetailBranchByNameWithCwd(
"write",
ToolWriteInputSchema,
ToolWriteOutputSchema,
(input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd(
"write_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
(input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd(
"create_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
(input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
toolDetailBranchByNameWithCwd(
"edit",
ToolEditInputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd(
"apply_patch",
ToolEditInputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
(input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd(
"apply_diff",
ToolEditInputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
(input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "search" })
toSearchToolDetail({ input, toolName: "search" }),
),
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "web_search" })
toSearchToolDetail({ input, toolName: "web_search" }),
),
CodexSpeakToolDetailSchema,
]);
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
mapCodexRolloutToolCall,
mapCodexToolCallFromThreadItem,
} from "./tool-call-mapper.js";
import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem } from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
@@ -75,15 +72,13 @@ describe("codex tool-call mapper", () => {
command: "echo hello",
cwd: "/tmp/repo",
aggregatedOutput:
"Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = \"tool\";",
'Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = "tool";',
exitCode: 0,
});
expect(item?.detail?.type).toBe("shell");
if (item?.detail?.type === "shell") {
expect(item.detail.output).toBe(
"214 export type AgentPermissionRequestKind = \"tool\";"
);
expect(item.detail.output).toBe('214 export type AgentPermissionRequestKind = "tool";');
expect(item.detail.output).not.toContain("Chunk ID:");
expect(item.detail.output).not.toContain("Wall time:");
expect(item.detail.output).not.toContain("Process exited with code");
@@ -102,7 +97,7 @@ describe("codex tool-call mapper", () => {
arguments: { path: "/tmp/repo/README.md" },
result: null,
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(readItem?.detail).toEqual({
type: "read",
@@ -118,7 +113,7 @@ describe("codex tool-call mapper", () => {
arguments: { file_path: "/tmp/repo/src/new.ts" },
result: null,
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(writeItem?.detail).toEqual({
type: "write",
@@ -134,7 +129,7 @@ describe("codex tool-call mapper", () => {
arguments: { file_path: "/tmp/repo/src/index.ts" },
result: null,
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(editItem?.detail).toEqual({
type: "edit",
@@ -165,7 +160,7 @@ describe("codex tool-call mapper", () => {
arguments: { path: "/tmp/repo/README.md" },
result: { content: "hello" },
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -189,10 +184,13 @@ describe("codex tool-call mapper", () => {
tool: "read_file",
arguments: { path: "/tmp/repo/README.md" },
result: {
content: [{ type: "text", text: "line one" }, { type: "text", text: "line two" }],
content: [
{ type: "text", text: "line one" },
{ type: "text", text: "line two" },
],
},
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -212,7 +210,7 @@ describe("codex tool-call mapper", () => {
status: "completed",
changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }],
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -231,9 +229,15 @@ describe("codex tool-call mapper", () => {
type: "fileChange",
id: "codex-content-1",
status: "completed",
changes: [{ path: "/tmp/repo/src/content-only.ts", kind: "modify", content: "line one\nline two\n" }],
changes: [
{
path: "/tmp/repo/src/content-only.ts",
kind: "modify",
content: "line one\nline two\n",
},
],
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -258,7 +262,7 @@ describe("codex tool-call mapper", () => {
},
},
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -284,7 +288,7 @@ describe("codex tool-call mapper", () => {
},
],
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
@@ -306,7 +310,7 @@ describe("codex tool-call mapper", () => {
arguments: { file_path: "/tmp/repo/src/new.ts", content: "export {}" },
result: null,
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(writeItem?.detail?.type).toBe("write");
if (writeItem?.detail?.type === "write") {
@@ -322,7 +326,7 @@ describe("codex tool-call mapper", () => {
arguments: { file_path: "/tmp/repo/src/index.ts", patch: "@@\\n-a\\n+b\\n" },
result: null,
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(editItem?.detail?.type).toBe("edit");
if (editItem?.detail?.type === "edit") {
@@ -368,7 +372,7 @@ describe("codex tool-call mapper", () => {
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
}),
);
expect(item.status).toBe("completed");
@@ -397,7 +401,7 @@ describe("codex tool-call mapper", () => {
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
})
}),
);
expect(item.status).toBe("completed");
@@ -434,7 +438,7 @@ describe("codex tool-call mapper", () => {
},
output: null,
cwd: "/tmp/repo",
})
}),
);
expect(item.detail.type).toBe("edit");
@@ -466,7 +470,7 @@ describe("codex tool-call mapper", () => {
status: "completed",
changes: [{ path: "/tmp/repo/src/from-file-change.ts", kind: "modify", content: patch }],
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item?.detail?.type).toBe("edit");
@@ -487,7 +491,7 @@ describe("codex tool-call mapper", () => {
status: "completed",
changes: [{ path: "/tmp/repo/src/path-only.ts", kind: "modify" }],
},
{ cwd: "/tmp/repo" }
{ cwd: "/tmp/repo" },
);
expect(item?.detail.type).toBe("unknown");
@@ -506,7 +510,7 @@ describe("codex tool-call mapper", () => {
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
})
}),
);
expect(item.detail.type).toBe("unknown");
@@ -542,7 +546,7 @@ describe("codex tool-call mapper", () => {
name: "paseo.speak",
input: { text: "Rollout speech text." },
output: { ok: true },
})
}),
);
expect(item.name).toBe("speak");
@@ -1,14 +1,8 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
extractCodexShellOutput,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import {
deriveCodexToolDetail,
normalizeCodexFilePath,
} from "./tool-call-detail-parser.js";
import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-utils.js";
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
type CodexMapperOptions = { cwd?: string | null };
@@ -17,12 +11,7 @@ const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "abor
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
const CodexToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const CodexToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
const CodexRolloutToolCallParamsSchema = z
.object({
@@ -80,7 +69,15 @@ const CodexEditToolNameSchema = z.union([
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
const CodexToolKindSchema = z.enum(["shell", "read", "write", "edit", "search", "speak", "unknown"]);
const CodexToolKindSchema = z.enum([
"shell",
"read",
"write",
"edit",
"search",
"speak",
"unknown",
]);
const CodexToolCallPass2BaseSchema = CodexNormalizedToolCallPass1Schema.extend({
toolKind: CodexToolKindSchema,
@@ -247,9 +244,7 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
);
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (!wrapperMatch) {
return trimmed;
}
@@ -407,7 +402,7 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
}
function classifyDiffLikeText(
text: string
text: string,
): { isDiff: true; text: string } | { isDiff: false; text: string } {
if (looksLikeUnifiedDiff(text)) {
return { isDiff: true, text };
@@ -418,9 +413,7 @@ function classifyDiffLikeText(
return { isDiff: false, text };
}
function asEditTextFields(
text: string | undefined
): { unifiedDiff?: string; newString?: string } {
function asEditTextFields(text: string | undefined): { unifiedDiff?: string; newString?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
@@ -489,9 +482,7 @@ function normalizeRolloutEditInput(input: unknown): unknown {
return normalized;
}
function asEditFileOutputFields(
text: string | undefined
): { patch?: string; content?: string } {
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
@@ -525,7 +516,7 @@ function hasRenderableEditDetail(detail: ToolCallTimelineItem["detail"]): boolea
function resolveStatus(
rawStatus: string | undefined,
error: unknown,
output: unknown
output: unknown,
): ToolCallTimelineItem["status"] {
if (error !== undefined && error !== null) {
return "failed";
@@ -569,7 +560,7 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
}
function toToolCallFromNormalizedEnvelope(
envelope: CodexNormalizedToolCallEnvelope
envelope: CodexNormalizedToolCallEnvelope,
): ToolCallTimelineItem | null {
const pass2Envelope = CodexToolCallPass2EnvelopeSchema.safeParse(envelope);
if (!pass2Envelope.success) {
@@ -583,7 +574,7 @@ function toToolCallFromNormalizedEnvelope(
}
function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>
item: z.infer<typeof CodexCommandExecutionItemSchema>,
): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
@@ -625,7 +616,7 @@ type CodexFileChangeEntry = {
function parseFileChangePath(
entry: Record<string, unknown>,
options?: CodexMapperOptions,
fallbackPath?: string
fallbackPath?: string,
): string | undefined {
const rawPath =
(typeof entry.path === "string" && entry.path.trim().length > 0
@@ -668,7 +659,7 @@ function parseFileChangeDiff(entry: Record<string, unknown>): string | undefined
function toFileChangeEntry(
entry: Record<string, unknown>,
options?: CodexMapperOptions,
fallbackPath?: string
fallbackPath?: string,
): CodexFileChangeEntry | null {
const path = parseFileChangePath(entry, options, fallbackPath);
if (!path) {
@@ -683,7 +674,7 @@ function toFileChangeEntry(
function parseFileChangeEntries(
changes: unknown,
options?: CodexMapperOptions
options?: CodexMapperOptions,
): CodexFileChangeEntry[] {
if (!changes) {
return [];
@@ -691,9 +682,7 @@ function parseFileChangeEntries(
if (Array.isArray(changes)) {
return changes
.map((entry) =>
isRecord(entry) ? toFileChangeEntry(entry, options) : null
)
.map((entry) => (isRecord(entry) ? toFileChangeEntry(entry, options) : null))
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
@@ -729,7 +718,7 @@ function parseFileChangeEntries(
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions
options?: CodexMapperOptions,
): CodexNormalizedToolCallEnvelope {
const files = parseFileChangeEntries(item.changes, options);
@@ -786,7 +775,7 @@ function mapFileChangeItem(
function mapMcpToolCallItem(
item: z.infer<typeof CodexMcpToolCallItemSchema>,
options?: CodexMapperOptions
options?: CodexMapperOptions,
): CodexNormalizedToolCallEnvelope | null {
const tool = item.tool.trim();
if (!tool) {
@@ -810,7 +799,7 @@ function mapMcpToolCallItem(
}
function mapWebSearchItem(
item: z.infer<typeof CodexWebSearchItemSchema>
item: z.infer<typeof CodexWebSearchItemSchema>,
): CodexNormalizedToolCallEnvelope {
const input = item.query !== undefined ? { query: item.query } : null;
const output = item.action ?? null;
@@ -831,7 +820,7 @@ function mapWebSearchItem(
function mapThreadItemToNormalizedEnvelope(
item: z.infer<typeof CodexThreadItemSchema>,
options?: CodexMapperOptions
options?: CodexMapperOptions,
): CodexNormalizedToolCallEnvelope | null {
switch (item.type) {
case "commandExecution":
@@ -855,7 +844,7 @@ function mapThreadItemToNormalizedEnvelope(
export function mapCodexToolCallFromThreadItem(
item: unknown,
options?: CodexMapperOptions
options?: CodexMapperOptions,
): ToolCallTimelineItem | null {
const parsed = CodexThreadItemSchema.safeParse(item);
if (!parsed.success) {
@@ -885,11 +874,10 @@ export function mapCodexRolloutToolCall(params: {
const normalizedInput =
normalizedName === "apply_patch" || normalizedName === "apply_diff"
? normalizeRolloutEditInput(parsed.data.input ?? null)
: parsed.data.input ?? null;
: (parsed.data.input ?? null);
const pass1 = CodexNormalizedToolCallPass1Schema.safeParse({
callId:
typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
callId: typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
name: normalizedName,
input: normalizedInput,
output: parsed.data.output ?? null,
@@ -44,9 +44,7 @@ interface TurnResult {
error?: string;
}
async function collectTurnEvents(
iterator: AsyncGenerator<AgentStreamEvent>
): Promise<TurnResult> {
async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Promise<TurnResult> {
const result: TurnResult = {
events: [],
assistantMessages: [],
@@ -108,16 +106,20 @@ const hasOpenCode = isBinaryInstalled("opencode");
const client = new OpenCodeAgentClient(logger);
const models = await client.listModels();
logger.info({ modelCount: models.length, elapsed: Date.now() - startTime }, "beforeAll: Retrieved models");
logger.info(
{ modelCount: models.length, elapsed: Date.now() - startTime },
"beforeAll: Retrieved models",
);
// Prefer fast models for tests - nano models are typically fastest
const fastModel = models.find((m) =>
m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-5-nano") ||
m.id.includes("gpt-5.1-codex-mini") ||
m.id.includes("gpt-4o-mini") ||
m.id.includes("gpt-3.5") ||
m.id.includes("free")
const fastModel = models.find(
(m) =>
m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-5-nano") ||
m.id.includes("gpt-5.1-codex-mini") ||
m.id.includes("gpt-4o-mini") ||
m.id.includes("gpt-3.5") ||
m.id.includes("free"),
);
if (fastModel) {
@@ -126,155 +128,139 @@ const hasOpenCode = isBinaryInstalled("opencode");
// Fallback to any available model
TEST_MODEL = models[0].id;
} else {
throw new Error("No OpenCode models available. Please authenticate with a provider (e.g., set OPENAI_API_KEY).");
throw new Error(
"No OpenCode models available. Please authenticate with a provider (e.g., set OPENAI_API_KEY).",
);
}
logger.info({ model: TEST_MODEL, totalElapsed: Date.now() - startTime }, "beforeAll: Selected OpenCode test model");
logger.info(
{ model: TEST_MODEL, totalElapsed: Date.now() - startTime },
"beforeAll: Selected OpenCode test model",
);
}, 30_000);
test(
"creates a session with valid id and provider",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
// HARD ASSERT: Session has required fields
expect(typeof session.id).toBe("string");
expect(session.id.length).toBeGreaterThan(0);
expect(session.provider).toBe("opencode");
// HARD ASSERT: Session has required fields
expect(typeof session.id).toBe("string");
expect(session.id.length).toBeGreaterThan(0);
expect(session.provider).toBe("opencode");
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
await session.close();
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test(
"single turn completes with streaming deltas",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
test("single turn completes with streaming deltas", async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
const iterator = session.stream("Say hello");
const turn = await collectTurnEvents(iterator);
const iterator = session.stream("Say hello");
const turn = await collectTurnEvents(iterator);
// HARD ASSERT: Turn completed successfully
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
// HARD ASSERT: Turn completed successfully
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
// HARD ASSERT: Got at least one assistant message
expect(turn.assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: Got at least one assistant message
expect(turn.assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: Each delta is non-empty
for (const msg of turn.assistantMessages) {
expect(msg.text.length).toBeGreaterThan(0);
}
// HARD ASSERT: Each delta is non-empty
for (const msg of turn.assistantMessages) {
expect(msg.text.length).toBeGreaterThan(0);
}
// HARD ASSERT: Concatenated deltas form non-empty response
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
expect(fullResponse.length).toBeGreaterThan(0);
// HARD ASSERT: Concatenated deltas form non-empty response
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
expect(fullResponse.length).toBeGreaterThan(0);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
await session.close();
rmSync(cwd, { recursive: true, force: true });
}, 120_000);
test(
"listModels returns models with required fields",
async () => {
const client = new OpenCodeAgentClient(logger);
const models = await client.listModels();
test("listModels returns models with required fields", async () => {
const client = new OpenCodeAgentClient(logger);
const models = await client.listModels();
// HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true);
// HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true);
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
expect(models.length).toBeGreaterThan(0);
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
expect(models.length).toBeGreaterThan(0);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
expect(model.provider).toBe("opencode");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
expect(model.provider).toBe("opencode");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/");
}
},
60_000
);
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/");
}
}, 60_000);
test(
"available modes include build and plan",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
test("available modes include build and plan", async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
const modes = await session.getAvailableModes();
expect(modes.some((mode) => mode.id === "build")).toBe(true);
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
expect(modes.some((mode) => mode.id === "build")).toBe(true);
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
await session.close();
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test(
"plan mode blocks edits while build mode can write files",
async () => {
const cwd = tmpCwd();
const planFile = path.join(cwd, "plan-mode-output.txt");
const buildFile = path.join(cwd, "build-mode-output.txt");
const client = new OpenCodeAgentClient(logger);
test("plan mode blocks edits while build mode can write files", async () => {
const cwd = tmpCwd();
const planFile = path.join(cwd, "plan-mode-output.txt");
const buildFile = path.join(cwd, "build-mode-output.txt");
const client = new OpenCodeAgentClient(logger);
const planSession = await client.createSession({
...buildConfig(cwd),
modeId: "plan",
});
const planSession = await client.createSession({
...buildConfig(cwd),
modeId: "plan",
});
const planTurn = await collectTurnEvents(
planSession.stream(
"Create a file named plan-mode-output.txt in the current directory containing exactly hello."
)
);
const planTurn = await collectTurnEvents(
planSession.stream(
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
),
);
expect(planTurn.turnCompleted).toBe(true);
expect(planTurn.turnFailed).toBe(false);
expect(existsSync(planFile)).toBe(false);
expect(planTurn.turnCompleted).toBe(true);
expect(planTurn.turnFailed).toBe(false);
expect(existsSync(planFile)).toBe(false);
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
expect(planResponse.toLowerCase()).toContain("plan mode");
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
expect(planResponse.toLowerCase()).toContain("plan mode");
await planSession.close();
await planSession.close();
const buildSession = await client.createSession({
...buildConfig(cwd),
modeId: "build",
});
const buildSession = await client.createSession({
...buildConfig(cwd),
modeId: "build",
});
const buildTurn = await collectTurnEvents(
buildSession.stream(
"Create a file named build-mode-output.txt in the current directory containing exactly hello."
)
);
const buildTurn = await collectTurnEvents(
buildSession.stream(
"Create a file named build-mode-output.txt in the current directory containing exactly hello.",
),
);
expect(buildTurn.turnCompleted).toBe(true);
expect(buildTurn.turnFailed).toBe(false);
expect(existsSync(buildFile)).toBe(true);
expect(readFileSync(buildFile, "utf8")).toContain("hello");
await buildSession.close();
rmSync(cwd, { recursive: true, force: true });
},
180_000
);
expect(buildTurn.turnCompleted).toBe(true);
expect(buildTurn.turnFailed).toBe(false);
expect(existsSync(buildFile)).toBe(true);
expect(readFileSync(buildFile, "utf8")).toContain("hello");
await buildSession.close();
rmSync(cwd, { recursive: true, force: true });
}, 180_000);
});
@@ -146,15 +146,16 @@ const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform(
error: part.error,
}));
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
mapOpencodeToolCall({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
})
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform(
(part) =>
mapOpencodeToolCall({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
}),
);
function resolveOpenCodeBinary(): string {
@@ -163,7 +164,7 @@ function resolveOpenCodeBinary(): string {
return found;
}
throw new Error(
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH."
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
);
}
@@ -198,9 +199,7 @@ function stringifyUnknownError(error: unknown): string {
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) =>
normalized.includes(token)
);
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
}
async function findAvailablePort(): Promise<number> {
@@ -219,10 +218,7 @@ async function findAvailablePort(): Promise<number> {
});
}
function resolvePartDedupeKey(
part: AgentMetadata,
partType: "text" | "reasoning"
): string | null {
function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null {
const partId = part.id;
if (typeof partId === "string" && partId.trim().length > 0) {
return `${partType}:${partId}`;
@@ -272,7 +268,7 @@ export class OpenCodeServerManager {
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings
runtimeSettings?: ProviderRuntimeSettings,
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
@@ -284,7 +280,7 @@ export class OpenCodeServerManager {
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings"
"OpenCode server manager already initialized with different runtime settings",
);
}
return OpenCodeServerManager.instance;
@@ -331,7 +327,7 @@ export class OpenCodeServerManager {
const url = `http://127.0.0.1:${this.port}`;
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
@@ -339,9 +335,9 @@ export class OpenCodeServerManager {
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)],
{
stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
}
stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
},
);
let started = false;
@@ -410,10 +406,7 @@ export class OpenCodeAgentClient implements AgentClient {
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.serverManager = OpenCodeServerManager.getInstance(
this.logger,
runtimeSettings
);
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
@@ -448,7 +441,7 @@ export class OpenCodeAgentClient implements AgentClient {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
if (!cwd) {
@@ -479,7 +472,15 @@ export class OpenCodeAgentClient implements AgentClient {
// Set a timeout for the API call to fail fast if OpenCode isn't responding
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers")), 10_000);
setTimeout(
() =>
reject(
new Error(
"OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers",
),
),
10_000,
);
});
const response = await Promise.race([
@@ -501,7 +502,9 @@ export class OpenCodeAgentClient implements AgentClient {
// Fail fast if no providers are connected
if (connectedProviderIds.size === 0) {
throw new Error("OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).");
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
);
}
const models: AgentModelDefinition[] = [];
@@ -543,7 +546,9 @@ export class OpenCodeAgentClient implements AgentClient {
return models;
}
async listPersistedAgents(_options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
// TODO: Implement by listing sessions from OpenCode
return [];
}
@@ -589,7 +594,7 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
export function translateOpenCodeEvent(
event: unknown,
state: OpenCodeEventTranslationState
state: OpenCodeEventTranslationState,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
@@ -728,12 +733,16 @@ export function translateOpenCodeEvent(
});
}
} else if (partType === "step-finish") {
const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined;
const tokens = part.tokens as
| { input?: number; output?: number; reasoning?: number }
| undefined;
const cost = part.cost as number | undefined;
if (tokens) {
state.accumulatedUsage.inputTokens = (state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
state.accumulatedUsage.outputTokens = (state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
state.accumulatedUsage.inputTokens =
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
state.accumulatedUsage.outputTokens =
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
}
if (cost !== undefined) {
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
@@ -823,11 +832,7 @@ class OpenCodeAgentSession implements AgentSession {
private emittedStructuredMessageIds = new Set<string>();
private availableModesCache: AgentMode[] | null = null;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string
) {
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
@@ -890,7 +895,7 @@ class OpenCodeAgentSession implements AgentSession {
async *stream(
prompt: AgentPromptInput,
options?: AgentRunOptions
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
this.abortController = new AbortController();
await this.ensureMcpServersConfigured();
@@ -986,12 +991,8 @@ class OpenCodeAgentSession implements AgentSession {
if (role === "user") {
// Extract user message text from parts
const textParts = parts.filter(
(p) => (p as { type?: string }).type === "text"
);
const text = textParts
.map((p) => (p as { text?: string }).text ?? "")
.join("");
const textParts = parts.filter((p) => (p as { type?: string }).type === "text");
const text = textParts.map((p) => (p as { text?: string }).text ?? "").join("");
if (text) {
yield {
@@ -1041,7 +1042,7 @@ class OpenCodeAgentSession implements AgentSession {
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(
(info as { structured?: unknown }).structured
(info as { structured?: unknown }).structured,
);
if (text) {
yield {
@@ -1172,38 +1173,33 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private async configureMcpServers(
mcpServers: Record<string, McpServerConfig>
): Promise<void> {
private async configureMcpServers(mcpServers: Record<string, McpServerConfig>): Promise<void> {
for (const [name, serverConfig] of Object.entries(mcpServers)) {
const mappedConfig = toOpenCodeMcpConfig(serverConfig);
await this.registerMcpServer(name, mappedConfig);
}
}
private async registerMcpServer(
name: string,
config: OpenCodeMcpConfig
): Promise<void> {
private async registerMcpServer(name: string, config: OpenCodeMcpConfig): Promise<void> {
await this.runMcpOperation("add", name, () =>
this.client.mcp.add({
directory: this.config.cwd,
name,
config,
})
}),
);
await this.runMcpOperation("connect", name, () =>
this.client.mcp.connect({
directory: this.config.cwd,
name,
})
}),
);
}
private async runMcpOperation(
operation: "add" | "connect",
name: string,
run: () => Promise<{ error?: unknown }>
run: () => Promise<{ error?: unknown }>,
): Promise<void> {
const response = await run();
const error = response.error;
@@ -1216,7 +1212,7 @@ class OpenCodeAgentSession implements AgentSession {
}
throw new Error(
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`,
);
}
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
translateOpenCodeEvent,
type OpenCodeEventTranslationState,
} from "../opencode-agent.js";
import { translateOpenCodeEvent, type OpenCodeEventTranslationState } from "../opencode-agent.js";
function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
return {
@@ -30,7 +27,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const streamed = translateOpenCodeEvent(
@@ -47,7 +44,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const completed = translateOpenCodeEvent(
@@ -64,11 +61,11 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const assistantEvents = [...streamed, ...completed].filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message"
(event) => event.type === "timeline" && event.item.type === "assistant_message",
);
expect(assistantEvents).toEqual([
@@ -94,7 +91,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const completed = translateOpenCodeEvent(
@@ -111,7 +108,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
expect(completed).toEqual([
@@ -140,7 +137,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const completed = translateOpenCodeEvent(
@@ -157,11 +154,11 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const reasoningEvents = [...streamed, ...completed].filter(
(event) => event.type === "timeline" && event.item.type === "reasoning"
(event) => event.type === "timeline" && event.item.type === "reasoning",
);
expect(reasoningEvents).toEqual([
@@ -189,7 +186,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
const second = translateOpenCodeEvent(
@@ -205,7 +202,7 @@ describe("translateOpenCodeEvent", () => {
},
},
},
state
state,
);
expect(first).toEqual([
@@ -20,9 +20,24 @@ import {
} from "../tool-call-detail-primitives.js";
const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName(
"shell",
ToolShellInputSchema,
ToolShellOutputSchema,
toShellToolDetail,
),
toolDetailBranchByToolName(
"bash",
ToolShellInputSchema,
ToolShellOutputSchema,
toShellToolDetail,
),
toolDetailBranchByToolName(
"exec_command",
ToolShellInputSchema,
ToolShellOutputSchema,
toShellToolDetail,
),
toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
@@ -31,24 +46,49 @@ const OpencodeKnownToolDetailSchema = z.union([
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName(
"write",
ToolWriteInputSchema,
ToolWriteOutputSchema,
toWriteToolDetail,
),
toolDetailBranchByToolName(
"write_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
toWriteToolDetail,
),
toolDetailBranchByToolName(
"create_file",
ToolWriteInputSchema,
ToolWriteOutputSchema,
toWriteToolDetail,
),
toolDetailBranchByToolName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName(
"apply_patch",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail,
),
toolDetailBranchByToolName(
"apply_diff",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail,
),
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "search" })
toSearchToolDetail({ input, toolName: "search" }),
),
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "web_search" })
toSearchToolDetail({ input, toolName: "web_search" }),
),
]);
export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown
output: unknown,
): ToolCallDetail {
const parsed = OpencodeKnownToolDetailSchema.safeParse({
toolName,
@@ -19,7 +19,7 @@ describe("opencode tool-call mapper", () => {
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
}),
);
expect(item.status).toBe("running");
@@ -39,7 +39,7 @@ describe("opencode tool-call mapper", () => {
status: "running",
input: { file_path: "README.md" },
output: null,
})
}),
);
expect(readItem.detail).toEqual({
type: "read",
@@ -53,7 +53,7 @@ describe("opencode tool-call mapper", () => {
status: "running",
input: { file_path: "src/new.ts" },
output: null,
})
}),
);
expect(writeItem.detail).toEqual({
type: "write",
@@ -67,7 +67,7 @@ describe("opencode tool-call mapper", () => {
status: "running",
input: { file_path: "src/index.ts" },
output: null,
})
}),
);
expect(editItem.detail).toEqual({
type: "edit",
@@ -81,7 +81,7 @@ describe("opencode tool-call mapper", () => {
status: "running",
input: { query: "opencode mapper" },
output: null,
})
}),
);
expect(searchItem.detail).toEqual({
type: "search",
@@ -98,7 +98,7 @@ describe("opencode tool-call mapper", () => {
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
})
}),
);
expect(item.status).toBe("completed");
@@ -124,7 +124,7 @@ describe("opencode tool-call mapper", () => {
{ type: "output_text", output: "beta" },
],
},
})
}),
);
expect(arrayContent.detail?.type).toBe("read");
@@ -143,7 +143,7 @@ describe("opencode tool-call mapper", () => {
content: { type: "output_text", text: "gamma" },
},
},
})
}),
);
expect(objectContent.detail?.type).toBe("read");
@@ -161,7 +161,7 @@ describe("opencode tool-call mapper", () => {
input: { command: "false" },
output: null,
error: "command failed",
})
}),
);
expect(item.status).toBe("failed");
@@ -177,7 +177,7 @@ describe("opencode tool-call mapper", () => {
status: "completed",
input: { file_path: "src/new.ts", content: "const x = 1;" },
output: null,
})
}),
);
expect(writeItem.detail?.type).toBe("write");
if (writeItem.detail?.type === "write") {
@@ -191,7 +191,7 @@ describe("opencode tool-call mapper", () => {
status: "completed",
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
output: null,
})
}),
);
expect(editItem.detail?.type).toBe("edit");
if (editItem.detail?.type === "edit") {
@@ -206,7 +206,7 @@ describe("opencode tool-call mapper", () => {
status: "completed",
input: { query: "opencode mapper" },
output: null,
})
}),
);
expect(searchItem.detail).toEqual({
type: "search",
@@ -223,7 +223,7 @@ describe("opencode tool-call mapper", () => {
status: "completed",
input: { foo: "bar" },
output: { ok: true },
})
}),
);
expect(item.status).toBe("completed");
@@ -243,7 +243,7 @@ describe("opencode tool-call mapper", () => {
status: "completed",
input: { text: "Voice response from OpenCode." },
output: { ok: true },
})
}),
);
expect(item.name).toBe("paseo_voice.speak");
@@ -17,12 +17,7 @@ const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
const OpencodeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const OpencodeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
const OpencodeRawToolCallSchema = z
.object({
@@ -41,9 +36,7 @@ const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transfor
const output = raw.output ?? null;
const error = raw.error ?? null;
const callId =
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId.trim()
: null;
typeof raw.callId === "string" && raw.callId.trim().length > 0 ? raw.callId.trim() : null;
let status: z.infer<typeof OpencodeToolCallStatusSchema>;
if (error !== null) {
@@ -29,7 +29,7 @@ export const ToolShellInputSchema = z
])
.transform((value) => {
const parsedCommand = CommandValueSchema.safeParse(
"command" in value ? value.command : value.cmd
"command" in value ? value.command : value.cmd,
);
const command = parsedCommand.success
? typeof parsedCommand.data === "string"
@@ -127,7 +127,10 @@ export const ToolShellOutputSchema = z.union([
]);
export const ToolPathInputSchema = z.union([
z.object({ path: z.string() }).passthrough().transform((value) => ({ filePath: value.path })),
z
.object({ path: z.string() })
.passthrough()
.transform((value) => ({ filePath: value.path })),
z
.object({ file_path: z.string() })
.passthrough()
@@ -154,7 +157,11 @@ export const ToolReadInputSchema = z.union([
limit: z.number().finite().optional(),
})
.passthrough()
.transform((value) => ({ filePath: value.file_path, offset: value.offset, limit: value.limit })),
.transform((value) => ({
filePath: value.file_path,
offset: value.offset,
limit: value.limit,
})),
z
.object({
filePath: z.string(),
@@ -189,7 +196,11 @@ const ToolReadChunkSchema = z.union([
.passthrough(),
]);
const ToolReadContentSchema = z.union([z.string(), ToolReadChunkSchema, z.array(ToolReadChunkSchema)]);
const ToolReadContentSchema = z.union([
z.string(),
ToolReadChunkSchema,
z.array(ToolReadChunkSchema),
]);
const ToolReadPayloadSchema = z.union([
z
@@ -216,7 +227,7 @@ const ToolReadPayloadSchema = z.union([
]);
function flattenReadContent(
value: z.infer<typeof ToolReadContentSchema> | undefined
value: z.infer<typeof ToolReadContentSchema> | undefined,
): string | undefined {
return flattenToolReadContent(value);
}
@@ -326,11 +337,8 @@ type ToolReadOutputValue = {
export const ToolReadOutputSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
ToolReadOutputContentSchema;
export const ToolReadOutputWithPathSchema: z.ZodType<
ToolReadOutputValue,
z.ZodTypeDef,
unknown
> = z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
export const ToolReadOutputWithPathSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
export const ToolWriteContentSchema = z
.object({
@@ -351,15 +359,13 @@ export const ToolWriteInputSchema = z
}));
export const ToolWriteOutputSchema = z.union([
z
.intersection(ToolPathInputSchema, ToolWriteContentSchema)
.transform((value) => ({
filePath: value.filePath,
content:
nonEmptyString(value.content) ??
nonEmptyString(value.new_content) ??
nonEmptyString(value.newContent),
})),
z.intersection(ToolPathInputSchema, ToolWriteContentSchema).transform((value) => ({
filePath: value.filePath,
content:
nonEmptyString(value.content) ??
nonEmptyString(value.new_content) ??
nonEmptyString(value.newContent),
})),
ToolWriteContentSchema.transform((value) => ({
filePath: undefined,
content:
@@ -406,7 +412,7 @@ export const ToolEditInputSchema = z
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
nonEmptyString(value.unifiedDiff),
),
}));
@@ -426,7 +432,7 @@ const ToolEditOutputFileSchema = z.union([
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
nonEmptyString(value.unifiedDiff),
),
})),
z
@@ -444,7 +450,7 @@ const ToolEditOutputFileSchema = z.union([
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
nonEmptyString(value.unifiedDiff),
),
})),
z
@@ -462,27 +468,25 @@ const ToolEditOutputFileSchema = z.union([
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
nonEmptyString(value.unifiedDiff),
),
})),
]);
export const ToolEditOutputSchema = z.union([
z
.intersection(ToolPathInputSchema, ToolEditTextSchema)
.transform((value) => ({
filePath: value.filePath,
newString:
nonEmptyString(value.newContent) ??
nonEmptyString(value.new_content) ??
nonEmptyString(value.content),
unifiedDiff: truncateDiffText(
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
),
})),
z.intersection(ToolPathInputSchema, ToolEditTextSchema).transform((value) => ({
filePath: value.filePath,
newString:
nonEmptyString(value.newContent) ??
nonEmptyString(value.new_content) ??
nonEmptyString(value.content),
unifiedDiff: truncateDiffText(
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff),
),
})),
z
.object({ files: z.array(ToolEditOutputFileSchema).min(1) })
.passthrough()
@@ -501,15 +505,24 @@ export const ToolEditOutputSchema = z.union([
nonEmptyString(value.patch) ??
nonEmptyString(value.diff) ??
nonEmptyString(value.unified_diff) ??
nonEmptyString(value.unifiedDiff)
nonEmptyString(value.unifiedDiff),
),
})),
]);
export const ToolSearchInputSchema = z.union([
z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })),
z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })),
z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })),
z
.object({ query: z.string() })
.passthrough()
.transform((value) => ({ query: value.query })),
z
.object({ q: z.string() })
.passthrough()
.transform((value) => ({ query: value.q })),
z
.object({ pattern: z.string() })
.passthrough()
.transform((value) => ({ query: value.pattern })),
]);
export const ToolGlobOutputSchema = z
@@ -597,7 +610,7 @@ type NormalizePathFn = (filePath: string) => string | undefined;
function normalizeDetailPath(
filePath: string | undefined,
normalizePath?: NormalizePathFn
normalizePath?: NormalizePathFn,
): string | undefined {
if (typeof filePath !== "string") {
return undefined;
@@ -610,26 +623,41 @@ function normalizeDetailPath(
}
function isParsedToolGlobOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
output:
| ParsedToolGrepOutput
| ParsedToolGlobOutput
| ParsedToolWebSearchOutput
| null
| undefined,
): output is ParsedToolGlobOutput {
return Boolean(output && "truncated" in output);
}
function isParsedToolGrepOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
output:
| ParsedToolGrepOutput
| ParsedToolGlobOutput
| ParsedToolWebSearchOutput
| null
| undefined,
): output is ParsedToolGrepOutput {
return Boolean(output && "filenames" in output && !("truncated" in output));
}
function isParsedToolWebSearchOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
output:
| ParsedToolGrepOutput
| ParsedToolGlobOutput
| ParsedToolWebSearchOutput
| null
| undefined,
): output is ParsedToolWebSearchOutput {
return Boolean(output && "results" in output);
}
export function toShellToolDetail(
input: ParsedToolShellInput | null,
output: ParsedToolShellOutput | null
output: ParsedToolShellOutput | null,
): ToolCallDetail | undefined {
const command = input?.command ?? output?.command;
if (!command) {
@@ -648,12 +676,9 @@ export function toShellToolDetail(
export function toReadToolDetail(
input: ParsedToolReadInput | null,
output: ParsedToolReadOutput | ParsedToolReadOutputWithPath | null,
options?: { normalizePath?: NormalizePathFn }
options?: { normalizePath?: NormalizePathFn },
): ToolCallDetail | undefined {
const filePath = normalizeDetailPath(
input?.filePath ?? output?.filePath,
options?.normalizePath
);
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
if (!filePath) {
return undefined;
}
@@ -670,12 +695,9 @@ export function toReadToolDetail(
export function toWriteToolDetail(
input: ParsedToolWriteInput | null,
output: ParsedToolWriteOutput | null,
options?: { normalizePath?: NormalizePathFn }
options?: { normalizePath?: NormalizePathFn },
): ToolCallDetail | undefined {
const filePath = normalizeDetailPath(
input?.filePath ?? output?.filePath,
options?.normalizePath
);
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
if (!filePath) {
return undefined;
}
@@ -683,19 +705,20 @@ export function toWriteToolDetail(
return {
type: "write",
filePath,
...(input?.content ? { content: input.content } : output?.content ? { content: output.content } : {}),
...(input?.content
? { content: input.content }
: output?.content
? { content: output.content }
: {}),
};
}
export function toEditToolDetail(
input: ParsedToolEditInput | null,
output: ParsedToolEditOutput | null,
options?: { normalizePath?: NormalizePathFn }
options?: { normalizePath?: NormalizePathFn },
): ToolCallDetail | undefined {
const filePath = normalizeDetailPath(
input?.filePath ?? output?.filePath,
options?.normalizePath
);
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
if (!filePath) {
return undefined;
}
@@ -704,7 +727,11 @@ export function toEditToolDetail(
type: "edit",
filePath,
...(input?.oldString ? { oldString: input.oldString } : {}),
...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}),
...(input?.newString
? { newString: input.newString }
: output?.newString
? { newString: output.newString }
: {}),
...(input?.unifiedDiff
? { unifiedDiff: input.unifiedDiff }
: output?.unifiedDiff
@@ -751,9 +778,7 @@ export function toSearchToolDetail(params: {
? { numMatches: output.numMatches }
: {}),
...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}),
...(isParsedToolWebSearchOutput(output)
? { durationSeconds: output.durationSeconds }
: {}),
...(isParsedToolWebSearchOutput(output) ? { durationSeconds: output.durationSeconds } : {}),
...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}),
...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}),
};
@@ -761,7 +786,7 @@ export function toSearchToolDetail(params: {
export function toFetchToolDetail(
input: ParsedToolWebFetchInput | null,
output: ParsedToolWebFetchOutput | null
output: ParsedToolWebFetchOutput | null,
): ToolCallDetail | undefined {
const url = input?.url ?? output?.url;
if (!url) {
@@ -790,8 +815,8 @@ export function toolDetailBranchByName<
outputSchema: OutputSchema,
mapper: (
input: z.infer<InputSchema> | null,
output: z.infer<OutputSchema> | null
) => ToolCallDetail | undefined
output: z.infer<OutputSchema> | null,
) => ToolCallDetail | undefined,
) {
return z
.object({
@@ -818,8 +843,8 @@ export function toolDetailBranchByToolName<
outputSchema: OutputSchema,
mapper: (
input: z.infer<InputSchema> | null,
output: z.infer<OutputSchema> | null
) => ToolCallDetail | undefined
output: z.infer<OutputSchema> | null,
) => ToolCallDetail | undefined,
) {
return z
.object({
@@ -847,8 +872,8 @@ export function toolDetailBranchByNameWithCwd<
mapper: (
input: z.infer<InputSchema> | null,
output: z.infer<OutputSchema> | null,
cwd: string | null
) => ToolCallDetail | undefined
cwd: string | null,
) => ToolCallDetail | undefined,
) {
return z
.object({
@@ -83,7 +83,7 @@ export function extractCodexTerminalSessionId(value: string | undefined): string
}
export function flattenReadContent<Chunk extends ReadChunkLike>(
value: string | Chunk | Chunk[] | undefined
value: string | Chunk | Chunk[] | undefined,
): string | undefined {
if (typeof value === "string") {
return nonEmptyString(value);
@@ -97,21 +97,19 @@ export function flattenReadContent<Chunk extends ReadChunkLike>(
(chunk) =>
nonEmptyString(chunk.text) ??
nonEmptyString(chunk.content) ??
nonEmptyString(chunk.output)
nonEmptyString(chunk.output),
)
.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts.join("\n") : undefined;
}
return (
nonEmptyString(value.text) ??
nonEmptyString(value.content) ??
nonEmptyString(value.output)
nonEmptyString(value.text) ?? nonEmptyString(value.content) ?? nonEmptyString(value.output)
);
}
export function truncateDiffText(
text: string | undefined,
maxChars: number = 12_000
maxChars: number = 12_000,
): string | undefined {
if (typeof text !== "string") {
return undefined;
@@ -21,4 +21,3 @@ export function resolveRecordingsDebugDir(explicitEnvVarName: string): string |
return resolve(process.cwd(), ".debug/recordings");
}
@@ -18,7 +18,7 @@ export interface DebugAudioMetadata {
export async function maybePersistDebugAudio(
audio: Buffer,
metadata: DebugAudioMetadata,
logger: pino.Logger
logger: pino.Logger,
): Promise<string | null> {
if (!debugDir) {
return null;
@@ -99,7 +99,7 @@ describe("STTManager", () => {
const manager = new STTManager(
"s1",
pino({ level: "silent" }),
new FakeStt({ text: "um", isLowConfidence: true, avgLogprob: -10 })
new FakeStt({ text: "um", isLowConfidence: true, avgLogprob: -10 }),
);
const result = await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000", {
@@ -114,7 +114,7 @@ describe("STTManager", () => {
const manager = new STTManager(
"s1",
pino({ level: "silent" }),
new FakeStt({ text: "hello world", language: "en", isLowConfidence: false })
new FakeStt({ text: "hello world", language: "en", isLowConfidence: false }),
);
const result = await manager.transcribe(Buffer.alloc(4), "audio/pcm;rate=24000");
@@ -131,7 +131,7 @@ describe("STTManager", () => {
const manager = new STTManager(
"s1",
pino({ level: "silent" }),
new SequencedFakeStt(["alpha", "beta", "gamma"])
new SequencedFakeStt(["alpha", "beta", "gamma"]),
);
const threeSecondsPcm = Buffer.alloc(24000 * 2 * 3);
+12 -13
View File
@@ -45,7 +45,7 @@ export class STTManager {
constructor(
sessionId: string,
logger: pino.Logger,
stt: Resolvable<SpeechToTextProvider | null>
stt: Resolvable<SpeechToTextProvider | null>,
) {
this.sessionId = sessionId;
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
@@ -58,7 +58,7 @@ export class STTManager {
public async transcribe(
audio: Buffer,
format: string,
metadata?: TranscriptionMetadata
metadata?: TranscriptionMetadata,
): Promise<SessionTranscriptionResult> {
const stt = this.resolveStt();
if (!stt) {
@@ -67,7 +67,7 @@ export class STTManager {
this.logger.debug(
{ bytes: audio.length, format, label: metadata?.label },
"Transcribing audio"
"Transcribing audio",
);
let debugRecordingPath: string | null = null;
@@ -81,7 +81,7 @@ export class STTManager {
label: metadata?.label,
format,
},
this.logger
this.logger,
);
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist debug audio");
@@ -100,8 +100,7 @@ export class STTManager {
pcm16 = parsed.pcm16;
} else if (format.toLowerCase().includes("audio/pcm")) {
inputRate =
parsePcmRateFromFormat(format, session.requiredSampleRate) ??
session.requiredSampleRate;
parsePcmRateFromFormat(format, session.requiredSampleRate) ?? session.requiredSampleRate;
pcm16 = audio;
} else {
throw new Error(`Unsupported audio format for STT: ${format}`);
@@ -193,7 +192,7 @@ export class STTManager {
const appendChunkBytes = Math.max(
1,
Math.round(session.requiredSampleRate * 2 * BATCH_APPEND_CHUNK_SECONDS)
Math.round(session.requiredSampleRate * 2 * BATCH_APPEND_CHUNK_SECONDS),
);
const commitEverySeconds = resolveBatchCommitEverySeconds();
const commitEveryBytes =
@@ -205,7 +204,7 @@ export class STTManager {
for (let offset = 0; offset < pcmForModel.length; offset += appendChunkBytes) {
const chunk = pcmForModel.subarray(
offset,
Math.min(pcmForModel.length, offset + appendChunkBytes)
Math.min(pcmForModel.length, offset + appendChunkBytes),
);
if (chunk.length === 0) {
continue;
@@ -234,7 +233,7 @@ export class STTManager {
receivedFinals: finalTranscriptSegmentIds.size,
label: metadata?.label,
},
"Timed out waiting for final STT segments; returning available transcripts"
"Timed out waiting for final STT segments; returning available transcripts",
);
settle?.();
}, BATCH_FINAL_TIMEOUT_MS);
@@ -259,13 +258,13 @@ export class STTManager {
.map((segmentId) => transcriptMetaBySegmentId.get(segmentId))
.filter(
(
meta
meta,
): meta is {
language?: string;
logprobs?: TranscriptionResult["logprobs"];
avgLogprob?: number;
isLowConfidence?: boolean;
} => Boolean(meta)
} => Boolean(meta),
);
const language = orderedFinalMeta.find((meta) => meta.language)?.language;
const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null;
@@ -288,7 +287,7 @@ export class STTManager {
if (result.isLowConfidence) {
this.logger.debug(
{ text: result.text, avgLogprob: result.avgLogprob },
"Filtered low-confidence transcription (likely non-speech)"
"Filtered low-confidence transcription (likely non-speech)",
);
// Return empty text to ignore this transcription
@@ -303,7 +302,7 @@ export class STTManager {
this.logger.debug(
{ text: result.text, avgLogprob: result.avgLogprob },
"Transcription complete"
"Transcription complete",
);
return {
@@ -1,14 +1,14 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, '../../../agent-prompt.md');
return readFileSync(promptPath, 'utf-8');
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
return readFileSync(promptPath, "utf-8");
}
// Cache the loaded prompt
@@ -8,7 +8,7 @@ export interface AppendTimelineItemIfAgentKnownOptions {
}
export async function appendTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
options: AppendTimelineItemIfAgentKnownOptions,
): Promise<boolean> {
try {
await options.agentManager.appendTimelineItem(options.agentId, options.item);
@@ -23,7 +23,7 @@ export async function appendTimelineItemIfAgentKnown(
}
export async function emitLiveTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
options: AppendTimelineItemIfAgentKnownOptions,
): Promise<boolean> {
try {
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
@@ -51,7 +51,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq
function mergeSeqRanges(
existing: TimelineSeqRange[],
incoming: TimelineSeqRange[]
incoming: TimelineSeqRange[],
): TimelineSeqRange[] {
let merged = [...existing];
for (const range of incoming) {
@@ -74,7 +74,7 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
function mergeToolCallItems(
existing: Extract<AgentTimelineItem, { type: "tool_call" }>,
incoming: Extract<AgentTimelineItem, { type: "tool_call" }>
incoming: Extract<AgentTimelineItem, { type: "tool_call" }>,
): Extract<AgentTimelineItem, { type: "tool_call" }> {
const mergedDetail = mergeToolCallDetail(existing.detail, incoming.detail);
const mergedMetadata =
@@ -102,7 +102,7 @@ function mergeToolCallItems(
function makeCanonicalEntries(
rows: readonly AgentTimelineRow[],
provider: AgentProvider
provider: AgentProvider,
): WorkingEntry[] {
return rows.map((row) => ({
provider,
@@ -175,10 +175,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
AgentTimelineItem,
{ type: "assistant_message" }
>;
const entryAssistant = entry.item as Extract<
AgentTimelineItem,
{ type: "assistant_message" }
>;
const entryAssistant = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
const collapsedKinds = new Set<TimelineProjectionKind>([
...previous.collapsed,
@@ -204,7 +201,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
export function projectTimelineRows(
rows: readonly AgentTimelineRow[],
provider: AgentProvider,
mode: TimelineProjectionMode
mode: TimelineProjectionMode,
): TimelineProjectionEntry[] {
const canonical = makeCanonicalEntries(rows, provider);
if (mode === "canonical") {
@@ -232,7 +229,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
const collapseTools = input.collapseToolLifecycle ?? true;
const canonical = makeCanonicalEntries(rows, provider);
const projectedAll = mergeAssistantChunks(
collapseTools ? collapseToolLifecycle(canonical) : canonical
collapseTools ? collapseToolLifecycle(canonical) : canonical,
);
if (projectedAll.length === 0) {
@@ -283,7 +280,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
// seqEnd values, which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
@@ -16,7 +16,7 @@ export interface TtsDebugAudioMetadata {
export async function maybePersistTtsDebugAudio(
audio: Buffer,
metadata: TtsDebugAudioMetadata,
logger: pino.Logger
logger: pino.Logger,
): Promise<string | null> {
if (!debugDir) {
return null;
@@ -36,7 +36,7 @@ describe("TTSManager", () => {
}
},
abort.signal,
true
true,
);
await task;
@@ -46,7 +46,7 @@ describe("TTSManager", () => {
const [audioMessage] = emitted.filter(isAudioOutputMessage);
expect(audioMessage).toBeDefined();
expect(audioMessage?.payload.groupId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(audioMessage?.payload.chunkIndex).toBe(0);
expect(audioMessage?.payload.isLastChunk).toBe(true);
@@ -78,7 +78,7 @@ describe("TTSManager", () => {
}
},
abort.signal,
true
true,
);
expect(calls.length).toBeGreaterThan(1);
@@ -135,7 +135,7 @@ describe("TTSManager", () => {
manager.confirmAudioPlayed(msg.payload.id);
},
abort.signal,
true
true,
);
expect(started).toEqual([segments[0], segments[1]]);
@@ -177,7 +177,7 @@ describe("TTSManager", () => {
});
const destroySpy = vi.spyOn(stream, "destroy").mockImplementation(function (
this: Readable,
error?: Error
error?: Error,
) {
destroyed.push(text);
return Readable.prototype.destroy.call(this, error);
@@ -206,7 +206,7 @@ describe("TTSManager", () => {
}
},
abort.signal,
true
true,
);
await vi.waitFor(() => {
@@ -235,7 +235,7 @@ describe("TTSManager", () => {
(async function* () {
yield Buffer.from("a");
throw new Error("stream exploded");
})()
})(),
);
return {
stream,
@@ -256,8 +256,8 @@ describe("TTSManager", () => {
}
},
abort.signal,
true
)
true,
),
).rejects.toThrow("stream exploded");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -283,13 +283,15 @@ describe("TTSManager", () => {
}
},
abort.signal,
true
true,
);
const firstAudio = emitted.find((msg) => msg.type === "audio_output");
expect(firstAudio?.type).toBe("audio_output");
manager.confirmAudioPlayed((firstAudio as Extract<SessionOutboundMessage, { type: "audio_output" }>).payload.id);
manager.confirmAudioPlayed(
(firstAudio as Extract<SessionOutboundMessage, { type: "audio_output" }>).payload.id,
);
expect(warnSpy).not.toHaveBeenCalled();
});
+40 -26
View File
@@ -137,7 +137,7 @@ export class TTSManager {
constructor(
sessionId: string,
logger: pino.Logger,
tts: Resolvable<TextToSpeechProvider | null>
tts: Resolvable<TextToSpeechProvider | null>,
) {
this.logger = logger.child({ module: "agent", component: "tts-manager", sessionId });
this.resolveTts = toResolver(tts);
@@ -151,7 +151,7 @@ export class TTSManager {
text: string,
emitMessage: (msg: SessionOutboundMessage) => void,
abortSignal: AbortSignal,
isVoiceMode: boolean
isVoiceMode: boolean,
): Promise<void> {
const ttsStartMs = Date.now();
this.logger.info(
@@ -160,23 +160,27 @@ export class TTSManager {
textLength: text.length,
text,
},
"TTS input text"
"TTS input text",
);
const segments = splitTextForTts(text);
this.logger.info(
{ segmentCount: segments.length, segments: segments.map((s) => ({ index: s.index, chars: s.text.length, text: s.text.slice(0, 80) })) },
`TTS split into ${segments.length} segment(s)`
{
segmentCount: segments.length,
segments: segments.map((s) => ({
index: s.index,
chars: s.text.length,
text: s.text.slice(0, 80),
})),
},
`TTS split into ${segments.length} segment(s)`,
);
const inflight = new Map<number, Promise<PreparedSegmentResult>>();
let nextSegmentToSchedule = 0;
const scheduleNextSegments = () => {
while (
nextSegmentToSchedule < segments.length &&
inflight.size < TTS_PREFETCH_SEGMENTS
) {
while (nextSegmentToSchedule < segments.length && inflight.size < TTS_PREFETCH_SEGMENTS) {
const segment = segments[nextSegmentToSchedule]!;
inflight.set(segment.index, this.scheduleSegmentSynthesis(segment, abortSignal));
nextSegmentToSchedule += 1;
@@ -207,8 +211,13 @@ export class TTSManager {
}
this.logger.info(
{ segmentIndex: segment.index, synthWaitMs, totalElapsedMs: Date.now() - ttsStartMs, chars: segment.text.length },
`TTS segment ${segment.index} synthesis ready (waited ${synthWaitMs}ms, total ${Date.now() - ttsStartMs}ms)`
{
segmentIndex: segment.index,
synthWaitMs,
totalElapsedMs: Date.now() - ttsStartMs,
chars: segment.text.length,
},
`TTS segment ${segment.index} synthesis ready (waited ${synthWaitMs}ms, total ${Date.now() - ttsStartMs}ms)`,
);
const emitStart = Date.now();
@@ -219,8 +228,12 @@ export class TTSManager {
isVoiceMode,
});
this.logger.info(
{ segmentIndex: segment.index, emitAndPlayMs: Date.now() - emitStart, totalElapsedMs: Date.now() - ttsStartMs },
`TTS segment ${segment.index} playback confirmed (emit+play ${Date.now() - emitStart}ms, total ${Date.now() - ttsStartMs}ms)`
{
segmentIndex: segment.index,
emitAndPlayMs: Date.now() - emitStart,
totalElapsedMs: Date.now() - ttsStartMs,
},
`TTS segment ${segment.index} playback confirmed (emit+play ${Date.now() - emitStart}ms, total ${Date.now() - ttsStartMs}ms)`,
);
scheduleNextSegments();
@@ -229,14 +242,14 @@ export class TTSManager {
this.cleanupPrefetchedSegments(inflight);
this.logger.info(
{ totalMs: Date.now() - ttsStartMs },
`TTS generateAndWaitForPlayback done (${Date.now() - ttsStartMs}ms)`
`TTS generateAndWaitForPlayback done (${Date.now() - ttsStartMs}ms)`,
);
}
}
private async synthesizeSegment(
segment: TtsSegment,
abortSignal: AbortSignal
abortSignal: AbortSignal,
): Promise<PreparedTtsSegment> {
const resolveStart = Date.now();
const tts = this.resolveTts();
@@ -252,8 +265,13 @@ export class TTSManager {
const synthStart = Date.now();
const { stream, format } = await tts.synthesizeSpeech(segment.text);
this.logger.info(
{ segmentIndex: segment.index, resolveMs, synthMs: Date.now() - synthStart, chars: segment.text.length },
`TTS segment ${segment.index} synthesized (resolve=${resolveMs}ms, synth=${Date.now() - synthStart}ms, ${segment.text.length} chars)`
{
segmentIndex: segment.index,
resolveMs,
synthMs: Date.now() - synthStart,
chars: segment.text.length,
},
`TTS segment ${segment.index} synthesized (resolve=${resolveMs}ms, synth=${Date.now() - synthStart}ms, ${segment.text.length} chars)`,
);
if (abortSignal.aborted) {
@@ -270,7 +288,7 @@ export class TTSManager {
private scheduleSegmentSynthesis(
segment: TtsSegment,
abortSignal: AbortSignal
abortSignal: AbortSignal,
): Promise<PreparedSegmentResult> {
return this.synthesizeSegment(segment, abortSignal).then(
(prepared) => {
@@ -285,13 +303,11 @@ export class TTSManager {
return { kind: "aborted" };
}
return { kind: "error", error };
}
},
);
}
private cleanupPrefetchedSegments(
inflight: Map<number, Promise<PreparedSegmentResult>>
): void {
private cleanupPrefetchedSegments(inflight: Map<number, Promise<PreparedSegmentResult>>): void {
if (inflight.size === 0) {
return;
}
@@ -431,9 +447,7 @@ export class TTSManager {
* Resolves the corresponding promise
*/
public confirmAudioPlayed(chunkId: string): void {
const [audioId] = chunkId.includes(":")
? chunkId.split(":")
: [chunkId];
const [audioId] = chunkId.includes(":") ? chunkId.split(":") : [chunkId];
const pending = this.pendingPlaybacks.get(audioId);
if (!pending) {
@@ -467,7 +481,7 @@ export class TTSManager {
this.logger.debug(
{ count: this.pendingPlaybacks.size, reason },
"Cancelling pending playbacks"
"Cancelling pending playbacks",
);
for (const [audioId, pending] of this.pendingPlaybacks.entries()) {
@@ -44,10 +44,7 @@ export class WaitForAgentTracker {
try {
cancel(reason);
} catch (error) {
this.logger.warn(
{ err: error, agentId },
"Cancel callback failed"
);
this.logger.warn({ err: error, agentId }, "Cancel callback failed");
}
}
return true;
@@ -43,4 +43,3 @@ describe("allowed hosts (vite-style)", () => {
expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
});
});
+4 -11
View File
@@ -56,7 +56,7 @@ function isDefaultAllowedHostname(hostname: string): boolean {
*/
export function isHostAllowed(
hostHeader: string | undefined,
allowedHosts: AllowedHostsConfig
allowedHosts: AllowedHostsConfig,
): boolean {
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
if (!hostname) return false;
@@ -73,9 +73,7 @@ export function isHostAllowed(
return false;
}
export function mergeAllowedHosts(
values: Array<AllowedHostsConfig>
): AllowedHostsConfig {
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -83,15 +81,11 @@ export function mergeAllowedHosts(
merged = merged.concat(value);
}
const deduped = Array.from(
new Set(merged.map((v) => v.trim()).filter((v) => v.length > 0))
);
const deduped = Array.from(new Set(merged.map((v) => v.trim()).filter((v) => v.length > 0)));
return deduped;
}
export function parseAllowedHostsEnv(
raw: string | undefined
): AllowedHostsConfig {
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
@@ -101,4 +95,3 @@ export function parseAllowedHostsEnv(
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
@@ -26,14 +26,11 @@ describe("paseo daemon bootstrap", () => {
},
});
try {
const response = await fetch(
`http://127.0.0.1:${daemonHandle.port}/api/health`,
{
headers: daemonHandle.agentMcpAuthHeader
? { Authorization: daemonHandle.agentMcpAuthHeader }
: undefined,
}
);
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/health`, {
headers: daemonHandle.agentMcpAuthHeader
? { Authorization: daemonHandle.agentMcpAuthHeader }
: undefined,
});
expect(response.ok).toBe(true);
const payload = await response.json();
expect(payload.status).toBe("ok");
@@ -73,7 +70,7 @@ describe("paseo daemon bootstrap", () => {
try {
await expect(createPaseoDaemon(config, pino({ level: "silent" }))).rejects.toThrow(
"Missing OpenAI credentials"
"Missing OpenAI credentials",
);
} finally {
await rm(paseoHomeRoot, { recursive: true, force: true });
@@ -87,7 +84,10 @@ describe("paseo daemon bootstrap", () => {
const fetchGate = new Promise<Response>((resolve) => {
releaseFetch = resolve;
});
vi.stubGlobal("fetch", vi.fn(() => fetchGate));
vi.stubGlobal(
"fetch",
vi.fn(() => fetchGate),
);
const daemonHandle = await createTestPaseoDaemon({
speech: {
@@ -116,7 +116,7 @@ describe("paseo daemon bootstrap", () => {
new Response(null, {
status: 500,
statusText: "test cleanup",
})
}),
);
await daemonHandle.close();
}
@@ -149,7 +149,7 @@ describe("paseo daemon bootstrap", () => {
lines.push(chunk.toString("utf8"));
callback();
},
})
}),
);
const config: PaseoDaemonConfig = {
+427 -440
View File
@@ -17,7 +17,7 @@ export type ListenTarget =
function resolveBoundListenTarget(
listenTarget: ListenTarget,
httpServer: ReturnType<typeof createHTTPServer>
httpServer: ReturnType<typeof createHTTPServer>,
): ListenTarget {
if (listenTarget.type !== "tcp") {
return listenTarget;
@@ -89,24 +89,15 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "./workspace-registry.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import {
createConnectionOfferV2,
encodeOfferToFragmentUrl,
} from "./connection-offer.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type {
AgentClient,
AgentProvider,
} from "./agent/agent-sdk-types.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
@@ -130,7 +121,7 @@ function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseAr
command: decision.resolved.command,
baseArgs: decision.resolved.baseArgs,
},
"Resolved voice MCP bridge command"
"Resolved voice MCP bridge command",
);
return decision.resolved;
}
@@ -197,7 +188,7 @@ export interface PaseoDaemon {
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger
rootLogger: Logger,
): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" });
const bootstrapStart = performance.now();
@@ -230,489 +221,488 @@ export async function createPaseoDaemon(
const app = express();
let boundListenTarget: ListenTarget | null = null;
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") {
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") {
app.use((req, res, next) => {
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
res.status(403).json({ error: "Invalid Host header" });
return;
}
next();
});
}
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
// Packaged desktop renderers may send `file://` or `null` as the origin.
"file://",
"null",
// For TCP, add localhost variants
...(listenTarget.type === "tcp"
? [
`http://${listenTarget.host}:${listenTarget.port}`,
`http://localhost:${listenTarget.port}`,
`http://127.0.0.1:${listenTarget.port}`,
]
: []),
]);
app.use((req, res, next) => {
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
res.status(403).json({ error: "Invalid Host header" });
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
});
}
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
// Packaged desktop renderers may send `file://` or `null` as the origin.
"file://",
"null",
// For TCP, add localhost variants
...(listenTarget.type === "tcp"
? [
`http://${listenTarget.host}:${listenTarget.port}`,
`http://localhost:${listenTarget.port}`,
`http://127.0.0.1:${listenTarget.port}`,
]
: []),
]);
// Serve static files from public directory
app.use("/public", express.static(staticDir));
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
});
// Middleware
app.use(express.json());
// Serve static files from public directory
app.use("/public", express.static(staticDir));
// Middleware
app.use(express.json());
// Health check endpoint
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.get("/api/status", (_req, res) => {
res.json({
status: "server_info",
serverId,
hostname: getHostname(),
version: daemonVersion,
listen: formatListenTarget(boundListenTarget ?? listenTarget),
// Health check endpoint
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
});
app.get("/pairing", async (_req, res) => {
try {
const offer = await generateLocalPairingOffer({
paseoHome: config.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
logger,
app.get("/api/status", (_req, res) => {
res.json({
status: "server_info",
serverId,
hostname: getHostname(),
version: daemonVersion,
listen: formatListenTarget(boundListenTarget ?? listenTarget),
});
res.json(offer);
} catch (error) {
logger.error({ err: error }, "Failed to generate pairing offer");
res.status(500).json({
relayEnabled: false,
url: null,
qr: null,
});
}
});
});
app.get("/api/files/download", async (req, res) => {
const token =
typeof req.query.token === "string" && req.query.token.trim().length > 0
? req.query.token.trim()
: null;
app.get("/pairing", async (_req, res) => {
try {
const offer = await generateLocalPairingOffer({
paseoHome: config.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
logger,
});
res.json(offer);
} catch (error) {
logger.error({ err: error }, "Failed to generate pairing offer");
res.status(500).json({
relayEnabled: false,
url: null,
qr: null,
});
}
});
if (!token) {
res.status(400).json({ error: "Missing download token" });
return;
}
app.get("/api/files/download", async (req, res) => {
const token =
typeof req.query.token === "string" && req.query.token.trim().length > 0
? req.query.token.trim()
: null;
const entry = downloadTokenStore.consumeToken(token);
if (!entry) {
res.status(403).json({ error: "Invalid or expired token" });
return;
}
try {
const fileStats = await stat(entry.absolutePath);
if (!fileStats.isFile()) {
res.status(404).json({ error: "File not found" });
if (!token) {
res.status(400).json({ error: "Missing download token" });
return;
}
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${safeFileName}"`
);
res.setHeader("Content-Length", entry.size.toString());
const stream = createReadStream(entry.absolutePath);
stream.on("error", (err) => {
logger.error({ err }, "Failed to stream download");
if (!res.headersSent) {
res.status(500).json({ error: "Failed to read file" });
} else {
res.end();
}
});
stream.pipe(res);
} catch (err) {
logger.error({ err }, "Failed to download file");
if (!res.headersSent) {
res.status(404).json({ error: "File not found" });
const entry = downloadTokenStore.consumeToken(token);
if (!entry) {
res.status(403).json({ error: "Invalid or expired token" });
return;
}
}
});
const httpServer = createHTTPServer(app);
try {
const fileStats = await stat(entry.absolutePath);
if (!fileStats.isFile()) {
res.status(404).json({ error: "File not found" });
return;
}
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger
);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
}),
...config.agentClients,
},
registry: agentStorage,
logger,
});
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Length", entry.size.toString());
const terminalManager = createTerminalManager();
const stream = createReadStream(entry.absolutePath);
stream.on("error", (err) => {
logger.error({ err }, "Failed to stream download");
if (!res.headersSent) {
res.status(500).json({ error: "Failed to read file" });
} else {
res.end();
}
});
stream.pipe(res);
} catch (err) {
logger.error({ err }, "Failed to download file");
if (!res.headersSent) {
res.status(404).json({ error: "File not found" });
}
}
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
agentStorage
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
const persistedRecords = await agentStorage.list();
logger.info(
{ elapsed: elapsed() },
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
);
logger.info(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)"
);
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
let unsubscribeSpeechReadiness: (() => void) | null = null;
const httpServer = createHTTPServer(app);
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
resolveCallerContext: (callerAgentId) => wsServer?.resolveVoiceCallerContext(callerAgentId) ?? null,
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger,
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
}),
...config.agentClients,
},
registry: agentStorage,
logger,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const terminalManager = createTerminalManager();
await agentMcpServer.connect(serverTransport);
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
agentStorage,
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
const persistedRecords = await agentStorage.list();
logger.info(
{ elapsed: elapsed() },
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`,
);
logger.info(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
);
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
let unsubscribeSpeechReadiness: (() => void) | null = null;
return clientTransport;
};
const mcpEnabled = config.mcpEnabled ?? true;
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
const createAgentMcpTransport = async (callerAgentId?: string) => {
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
resolveSpeakHandler: (callerAgentId) =>
wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
resolveCallerContext: (callerAgentId) =>
wsServer?.resolveVoiceCallerContext(callerAgentId) ?? null,
logger,
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
agentMcpTransports.set(sessionId, transport);
logger.debug({ sessionId }, "Agent MCP session initialized");
},
onsessionclosed: (sessionId) => {
agentMcpTransports.delete(sessionId);
logger.debug({ sessionId }, "Agent MCP session closed");
},
// NOTE: We enforce a Vite-like host allowlist at the app/websocket layer.
// StreamableHTTPServerTransport's built-in check requires exact Host header matches.
enableDnsRebindingProtection: false,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
transport.onclose = () => {
if (transport.sessionId) {
agentMcpTransports.delete(transport.sessionId);
}
};
transport.onerror = (err) => {
logger.error({ err }, "Agent MCP transport error");
};
await agentMcpServer.connect(serverTransport);
await agentMcpServer.connect(transport);
return transport;
return clientTransport;
};
const handleAgentMcpRequest: express.RequestHandler = async (req, res) => {
if (config.mcpDebug) {
logger.debug(
{
method: req.method,
url: req.originalUrl,
sessionId: req.header("mcp-session-id"),
authorization: req.header("authorization"),
body: req.body,
const mcpEnabled = config.mcpEnabled ?? true;
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
agentMcpTransports.set(sessionId, transport);
logger.debug({ sessionId }, "Agent MCP session initialized");
},
"Agent MCP request"
);
}
try {
const sessionId = req.header("mcp-session-id");
let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined;
onsessionclosed: (sessionId) => {
agentMcpTransports.delete(sessionId);
logger.debug({ sessionId }, "Agent MCP session closed");
},
// NOTE: We enforce a Vite-like host allowlist at the app/websocket layer.
// StreamableHTTPServerTransport's built-in check requires exact Host header matches.
enableDnsRebindingProtection: false,
});
if (!transport) {
if (req.method !== "POST") {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Missing or invalid MCP session",
},
id: null,
});
return;
transport.onclose = () => {
if (transport.sessionId) {
agentMcpTransports.delete(transport.sessionId);
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Initialization request expected",
},
id: null,
});
return;
}
const callerAgentIdRaw = req.query.callerAgentId;
const callerAgentId =
typeof callerAgentIdRaw === "string"
? callerAgentIdRaw
: Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string"
? callerAgentIdRaw[0]
: undefined;
transport = await createAgentMcpTransport(callerAgentId);
}
};
transport.onerror = (err) => {
logger.error({ err }, "Agent MCP transport error");
};
await transport.handleRequest(req as any, res as any, req.body);
} catch (err) {
logger.error({ err }, "Failed to handle Agent MCP request");
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal MCP server error",
await agentMcpServer.connect(transport);
return transport;
};
const handleAgentMcpRequest: express.RequestHandler = async (req, res) => {
if (config.mcpDebug) {
logger.debug(
{
method: req.method,
url: req.originalUrl,
sessionId: req.header("mcp-session-id"),
authorization: req.header("authorization"),
body: req.body,
},
id: null,
});
"Agent MCP request",
);
}
}
};
try {
const sessionId = req.header("mcp-session-id");
let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined;
app.post(agentMcpRoute, handleAgentMcpRequest);
app.get(agentMcpRoute, handleAgentMcpRequest);
app.delete(agentMcpRoute, handleAgentMcpRequest);
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
} else {
logger.info("Agent MCP HTTP endpoint disabled");
}
if (!transport) {
if (req.method !== "POST") {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Missing or invalid MCP session",
},
id: null,
});
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Initialization request expected",
},
id: null,
});
return;
}
const callerAgentIdRaw = req.query.callerAgentId;
const callerAgentId =
typeof callerAgentIdRaw === "string"
? callerAgentIdRaw
: Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string"
? callerAgentIdRaw[0]
: undefined;
transport = await createAgentMcpTransport(callerAgentId);
}
const voiceMcpSocketDir = path.join(config.paseoHome, "runtime", "voice-mcp");
const voiceMcpBridgeCommand = resolveVoiceMcpBridgeCommand(logger);
voiceMcpBridgeManager = createVoiceMcpSocketBridgeManager({
runtimeDir: voiceMcpSocketDir,
logger,
createAgentMcpServerForCaller: async (callerAgentId) => {
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
},
});
const {
resolveVoiceTurnDetection,
resolveVoiceStt,
resolveVoiceTts,
resolveDictationStt,
getSpeechReadiness,
subscribeSpeechReadiness,
cleanup: cleanupSpeechRuntime,
localModelConfig,
} = await initializeSpeechRuntime({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
logger.info({ elapsed: elapsed() }, "Speech runtime initialized");
await transport.handleRequest(req as any, res as any, req.body);
} catch (err) {
logger.error({ err }, "Failed to handle Agent MCP request");
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal MCP server error",
},
id: null,
});
}
}
};
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
createInMemoryAgentMcpTransport,
{ allowedOrigins, allowedHosts: config.allowedHosts },
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
terminalManager,
{
voiceAgentMcpStdio: {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
PASEO_HOME: config.paseoHome,
},
app.post(agentMcpRoute, handleAgentMcpRequest);
app.get(agentMcpRoute, handleAgentMcpRequest);
app.delete(agentMcpRoute, handleAgentMcpRequest);
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
} else {
logger.info("Agent MCP HTTP endpoint disabled");
}
const voiceMcpSocketDir = path.join(config.paseoHome, "runtime", "voice-mcp");
const voiceMcpBridgeCommand = resolveVoiceMcpBridgeCommand(logger);
voiceMcpBridgeManager = createVoiceMcpSocketBridgeManager({
runtimeDir: voiceMcpSocketDir,
logger,
createAgentMcpServerForCaller: async (callerAgentId) => {
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
},
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
removeVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
stt: resolveDictationStt,
localModels: localModelConfig ?? undefined,
});
const {
resolveVoiceTurnDetection,
resolveVoiceStt,
resolveVoiceTts,
resolveDictationStt,
getSpeechReadiness,
},
config.agentProviderSettings,
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
});
subscribeSpeechReadiness,
cleanup: cleanupSpeechRuntime,
localModelConfig,
} = await initializeSpeechRuntime({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
logger.info({ elapsed: elapsed() }, "Speech runtime initialized");
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
createInMemoryAgentMcpTransport,
{ allowedOrigins, allowedHosts: config.allowedHosts },
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
terminalManager,
{
voiceAgentMcpStdio: {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
PASEO_HOME: config.paseoHome,
},
},
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
removeVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
stt: resolveDictationStt,
localModels: localModelConfig ?? undefined,
getSpeechReadiness,
},
config.agentProviderSettings,
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
});
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
httpServer.off("error", onError);
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`
);
} else {
logger.info(
{ path: boundListenTarget.path, elapsed: elapsed() },
`Server listening on ${boundListenTarget.path}`
);
}
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
httpServer.off("error", onError);
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (boundListenTarget.type === "tcp") {
logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{ path: boundListenTarget.path, elapsed: elapsed() },
`Server listening on ${boundListenTarget.path}`,
);
}
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
}
httpServer.listen(listenTarget.path);
}
});
};
@@ -769,10 +759,7 @@ export async function createPaseoDaemon(
}
}
async function closeAllAgents(
logger: Logger,
agentManager: AgentManager
): Promise<void> {
async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> {
const agents = agentManager.listAgents();
for (const agent of agents) {
try {
@@ -1,14 +1,5 @@
import {
describe,
test,
expect,
beforeEach,
afterEach,
} from "vitest";
import {
createTestPaseoDaemon,
type TestPaseoDaemon,
} from "./test-utils/paseo-daemon.js";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "../shared/messages.js";
import type { AgentSnapshotPayload } from "./messages.js";
@@ -75,7 +66,7 @@ describe("client activity tracking", () => {
function waitForAttentionRequired(
client: DaemonClient,
agentId: string,
timeout = 60000
timeout = 60000,
): Promise<Extract<AgentStreamEventPayload, { type: "attention_required" }>> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -263,10 +254,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Neither should notify - user is actively watching on client2
expect(attention1.shouldNotify).toBe(false);
@@ -305,10 +293,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Both should notify - no one is watching
expect(attention1.shouldNotify).toBe(true);
@@ -352,10 +337,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Neither should notify - user sees it on web
expect(attention1.shouldNotify).toBe(false);
@@ -393,10 +375,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Neither should notify - user sees it on mobile
expect(attention1.shouldNotify).toBe(false);
@@ -434,10 +413,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Web stale → don't notify web, notify mobile instead
expect(attention1.shouldNotify).toBe(false);
@@ -474,10 +450,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent1.id);
await client1.sendMessage(agent1.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// User is active on web, notify them there (they'll see it)
expect(attention1.shouldNotify).toBe(true);
@@ -518,15 +491,12 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Mobile always notifies when no one is watching
// Web is stale so don't bother notifying there
expect(attention1.shouldNotify).toBe(false); // web stale
expect(attention2.shouldNotify).toBe(true); // mobile fallback
expect(attention2.shouldNotify).toBe(true); // mobile fallback
}, 120000);
});
@@ -561,10 +531,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Web stale → no notification
// Mobile has no heartbeat → treat as should notify (we don't know device type)
@@ -630,10 +597,7 @@ describe("client activity tracking", () => {
const attention2Promise = waitForAttentionRequired(client2, agent.id);
await client1.sendMessage(agent.id, "Say 'hello' and nothing else");
const [attention1, attention2] = await Promise.all([
attention1Promise,
attention2Promise,
]);
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
// Both have recent activity - user is present, no notification needed
expect(attention1.shouldNotify).toBe(false);
@@ -10,7 +10,7 @@ export function normalizeClientMessageId(clientMessageId: string | undefined): s
export function resolveClientMessageId(
clientMessageId: string | undefined,
generateId: () => string = uuidv4
generateId: () => string = uuidv4,
): string {
return normalizeClientMessageId(clientMessageId) ?? generateId();
}
+12 -20
View File
@@ -29,7 +29,9 @@ export type CliConfigOverrides = Partial<{
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null => (typeof value === "string" ? value.trim().toLowerCase() : null))
.transform((value): string | null =>
typeof value === "string" ? value.trim().toLowerCase() : null,
)
.pipe(z.union([AgentProviderSchema, z.null()]));
function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
@@ -42,7 +44,7 @@ export function loadConfig(
options?: {
env?: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
}
},
): PaseoDaemonConfig {
const env = options?.env ?? process.env;
const persisted = loadPersistedConfig(paseoHome);
@@ -53,10 +55,7 @@ export function loadConfig(
// - unix:///path/to/socket (Unix socket)
// Default is TCP at 127.0.0.1:6767
const listen =
options?.cli?.listen ??
env.PASEO_LISTEN ??
persisted.daemon?.listen ??
getDefaultListen();
options?.cli?.listen ?? env.PASEO_LISTEN ?? persisted.daemon?.listen ?? getDefaultListen();
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
@@ -70,24 +69,17 @@ export function loadConfig(
options?.cli?.allowedHosts,
]);
const mcpEnabled =
options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
const relayEnabled =
options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
const relayEndpoint =
env.PASEO_RELAY_ENDPOINT ??
persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
env.PASEO_RELAY_ENDPOINT ?? persisted.daemon?.relay?.endpoint ?? DEFAULT_RELAY_ENDPOINT;
const relayPublicEndpoint =
env.PASEO_RELAY_PUBLIC_ENDPOINT ??
persisted.daemon?.relay?.publicEndpoint ??
relayEndpoint;
env.PASEO_RELAY_PUBLIC_ENDPOINT ?? persisted.daemon?.relay?.publicEndpoint ?? relayEndpoint;
const appBaseUrl =
env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL;
const appBaseUrl = env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL;
const { openai, speech } = resolveSpeechConfig({
paseoHome,
@@ -97,7 +89,7 @@ export function loadConfig(
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
persisted.features?.voiceMode?.llm?.provider
persisted.features?.voiceMode?.llm?.provider,
);
const voiceLlmProvider = envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null;
const voiceLlmProviderExplicit =
@@ -108,7 +100,7 @@ export function loadConfig(
listen,
paseoHome,
corsAllowedOrigins: Array.from(
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0))
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
),
allowedHosts,
mcpEnabled,
+3 -10
View File
@@ -1,24 +1,17 @@
import os from "node:os";
import {
ConnectionOfferV2Schema,
type ConnectionOffer,
} from "../shared/connection-offer.js";
import { ConnectionOfferV2Schema, type ConnectionOffer } from "../shared/connection-offer.js";
type BuildOfferEndpointsArgs = {
listenHost: string;
port: number;
};
export function buildOfferEndpoints({
listenHost,
port,
}: BuildOfferEndpointsArgs): string[] {
export function buildOfferEndpoints({ listenHost, port }: BuildOfferEndpointsArgs): string[] {
const endpoints: string[] = [];
const isLoopbackHost = listenHost === "127.0.0.1" || listenHost === "localhost";
const isWildcardHost =
listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "[::]";
const isWildcardHost = listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "[::]";
if (isWildcardHost) {
const lanIp = getPrimaryLanIp();
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
mkdirSync,
readFileSync,
readdirSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
@@ -35,7 +40,9 @@ describe("daemon E2E", () => {
test("creates agent and receives response", async () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: "/tmp",
title: "Test Agent",
});
@@ -60,21 +67,19 @@ describe("daemon E2E", () => {
// Verify we received some stream events
const queue = collector.messages;
const streamEvents = queue.filter(
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id,
);
expect(streamEvents.length).toBeGreaterThan(0);
// Verify there was a turn_started event
const hasTurnStarted = streamEvents.some(
(m) =>
m.type === "agent_stream" && m.payload.event.type === "turn_started"
(m) => m.type === "agent_stream" && m.payload.event.type === "turn_started",
);
expect(hasTurnStarted).toBe(true);
// Verify there was a turn_completed event
const hasTurnCompleted = streamEvents.some(
(m) =>
m.type === "agent_stream" && m.payload.event.type === "turn_completed"
(m) => m.type === "agent_stream" && m.payload.event.type === "turn_completed",
);
expect(hasTurnCompleted).toBe(true);
@@ -89,18 +94,17 @@ describe("daemon E2E", () => {
expect(hasAssistantMessage).toBe(true);
}, 180000); // 3 minute timeout for E2E test
test("fails to create agent with non-existent cwd", async () => {
const nonExistentCwd = "/this/path/does/not/exist/12345";
await expect(
ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: nonExistentCwd,
title: "Should Fail Agent",
})
}),
).rejects.toThrow(nonExistentCwd);
});
});
@@ -1,11 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
mkdirSync,
readFileSync,
readdirSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
@@ -36,381 +41,364 @@ describe("daemon E2E", () => {
}, 60000);
describe("timestamp behavior", () => {
test(
"opening agent without interaction does not update timestamp",
async () => {
const cwd = tmpCwd();
test("opening agent without interaction does not update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Test Agent",
});
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = agent.updatedAt;
expect(initialUpdatedAt).toBeTruthy();
// Record the initial updatedAt timestamp
const initialUpdatedAt = agent.updatedAt;
expect(initialUpdatedAt).toBeTruthy();
// Wait a bit to ensure any timestamp update would be visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Wait a bit to ensure any timestamp update would be visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Clear captured messages before the "click" action
messages.length = 0;
// Clear captured messages before the "click" action
messages.length = 0;
// Simulate opening the agent in the UI:
// fetch timeline window + fetch current agent snapshot.
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
// Simulate opening the agent in the UI:
// fetch timeline window + fetch current agent snapshot.
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
// CRITICAL: The timestamp should NOT have changed
// Just opening/clicking an agent should not update its updatedAt
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
// CRITICAL: The timestamp should NOT have changed
// Just opening/clicking an agent should not update its updatedAt
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Also clear attention (what happens when opening an agent with notification)
await ctx.client.clearAgentAttention(agent.id);
// Also clear attention (what happens when opening an agent with notification)
await ctx.client.clearAgentAttention(agent.id);
// Get the state again after clearing attention
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const clearResult = await ctx.client.fetchAgent(agent.id);
// Get the state again after clearing attention
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const clearResult = await ctx.client.fetchAgent(agent.id);
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
},
60000
);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
test(
"sending message DOES update timestamp",
async () => {
const cwd = tmpCwd();
test("sending message DOES update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Update Test Agent",
});
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Update Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = new Date(agent.updatedAt);
// Record the initial updatedAt timestamp
const initialUpdatedAt = new Date(agent.updatedAt);
// Wait a bit to ensure timestamp difference is visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Wait a bit to ensure timestamp difference is visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Send a message (this SHOULD update the timestamp)
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
// Send a message (this SHOULD update the timestamp)
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// The timestamp SHOULD have been updated (should be later than initial)
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
// The timestamp SHOULD have been updated (should be later than initial)
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
// Cleanup
rmSync(cwd, { recursive: true, force: true });
},
180000
);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 180000);
});
describe("cancelAgent", () => {
test(
"cancels a running agent mid-execution",
async () => {
const cwd = tmpCwd();
test("cancels a running agent mid-execution", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-cancel" },
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-cancel" },
});
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Cancel Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that triggers a long-running operation
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30000,
);
// Record timestamp before cancel
const cancelStart = Date.now();
// Cancel the agent
await ctx.client.cancelAgent(agent.id);
// Wait for agent to reach idle or error state via server wait RPC
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
// Calculate how long the cancel took
const cancelDuration = Date.now() - cancelStart;
// Verify agent stopped within reasonable time (2 seconds)
expect(cancelDuration).toBeLessThan(3000);
// Verify agent is now idle or error
expect(["idle", "error"]).toContain(afterCancel.status);
// Verify no zombie sleep processes left (check for sleep 30)
const { execSync } = await import("child_process");
try {
const result = execSync("pgrep -f 'sleep 30'", {
encoding: "utf8",
timeout: 2000,
});
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Cancel Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that triggers a long-running operation
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30000
);
// Record timestamp before cancel
const cancelStart = Date.now();
// Cancel the agent
await ctx.client.cancelAgent(agent.id);
// Wait for agent to reach idle or error state via server wait RPC
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
// Calculate how long the cancel took
const cancelDuration = Date.now() - cancelStart;
// Verify agent stopped within reasonable time (2 seconds)
expect(cancelDuration).toBeLessThan(3000);
// Verify agent is now idle or error
expect(["idle", "error"]).toContain(afterCancel.status);
// Verify no zombie sleep processes left (check for sleep 30)
const { execSync } = await import("child_process");
try {
const result = execSync("pgrep -f 'sleep 30'", {
encoding: "utf8",
timeout: 2000,
});
// If pgrep succeeds, there are zombie processes
if (result.trim()) {
// Kill them and fail the test
execSync("pkill -f 'sleep 30'");
expect.fail("Found zombie sleep processes after cancel");
}
} catch {
// pgrep returns non-zero when no processes found - this is expected
// If pgrep succeeds, there are zombie processes
if (result.trim()) {
// Kill them and fail the test
execSync("pkill -f 'sleep 30'");
expect.fail("Found zombie sleep processes after cancel");
}
} catch {
// pgrep returns non-zero when no processes found - this is expected
}
// Cleanup
rmSync(cwd, { recursive: true, force: true });
},
60000
);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
});
describe("setAgentMode", () => {
test(
"switches agent mode and persists across messages",
async () => {
const cwd = tmpCwd();
test("switches agent mode and persists across messages", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-mode" },
});
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-mode" },
});
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Mode Switch Test Agent",
});
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Mode Switch Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Verify initial mode is "auto" (the default)
expect(agent.currentModeId).toBe("auto");
// Verify initial mode is "auto" (the default)
expect(agent.currentModeId).toBe("auto");
// Clear message queue before mode switch
messages.length = 0;
const startPosition = messages.length;
// Clear message queue before mode switch
messages.length = 0;
const startPosition = messages.length;
// Switch to "read-only" mode
await ctx.client.setAgentMode(agent.id, "read-only");
// Switch to "read-only" mode
await ctx.client.setAgentMode(agent.id, "read-only");
// Wait for agent_update upsert reflecting the new mode
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>(
(resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for mode change in agent_update"));
}, 10000);
// Wait for agent_update upsert reflecting the new mode
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for mode change in agent_update"));
}, 10000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "read-only"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "read-only"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
);
};
// Verify mode changed to "read-only"
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
const interval = setInterval(checkForModeChange, 50);
});
// Now verify the mode persists: send a message and check the mode is still "read-only"
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
// Verify mode changed to "read-only"
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Now verify the mode persists: send a message and check the mode is still "read-only"
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
// Mode should still be "read-only" after the message
expect(finalState.final?.currentModeId).toBe("read-only");
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Also verify runtimeInfo has the updated modeId
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
// Mode should still be "read-only" after the message
expect(finalState.final?.currentModeId).toBe("read-only");
// Switch to another mode: "full-access"
messages.length = 0;
const position2 = messages.length;
// Also verify runtimeInfo has the updated modeId
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
await ctx.client.setAgentMode(agent.id, "full-access");
// Switch to another mode: "full-access"
messages.length = 0;
const position2 = messages.length;
// Wait for agent_update upsert
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>(
(resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 10000);
await ctx.client.setAgentMode(agent.id, "full-access");
const checkForModeChange = (): void => {
const queue = messages;
for (let i = position2; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
// Wait for agent_update upsert
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 10000);
const interval = setInterval(checkForModeChange, 50);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = position2; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
);
};
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
const interval = setInterval(checkForModeChange, 50);
});
// Cleanup
rmSync(cwd, { recursive: true, force: true });
},
30_000
);
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
});
describe("listAgents", () => {
test(
"returns current agents and reflects create/delete operations",
async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
test("returns current agents and reflects create/delete operations", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
// Initially, there should be no agents (fresh session)
const initialAgents = await ctx.client.fetchAgents();
expect(initialAgents.entries).toHaveLength(0);
// Initially, there should be no agents (fresh session)
const initialAgents = await ctx.client.fetchAgents();
expect(initialAgents.entries).toHaveLength(0);
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd1,
title: "List Test Agent 1",
});
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd1,
title: "List Test Agent 1",
});
expect(agent1.id).toBeTruthy();
expect(agent1.status).toBe("idle");
expect(agent1.id).toBeTruthy();
expect(agent1.status).toBe("idle");
// fetchAgents should now return 1 agent
const afterFirst = await ctx.client.fetchAgents();
expect(afterFirst.entries).toHaveLength(1);
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
// Title may or may not be set depending on timing
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
// fetchAgents should now return 1 agent
const afterFirst = await ctx.client.fetchAgents();
expect(afterFirst.entries).toHaveLength(1);
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
// Title may or may not be set depending on timing
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd2,
title: "List Test Agent 2",
});
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd2,
title: "List Test Agent 2",
});
expect(agent2.id).toBeTruthy();
expect(agent2.status).toBe("idle");
expect(agent2.id).toBeTruthy();
expect(agent2.status).toBe("idle");
// fetchAgents should now return 2 agents
const afterSecond = await ctx.client.fetchAgents();
expect(afterSecond.entries).toHaveLength(2);
// fetchAgents should now return 2 agents
const afterSecond = await ctx.client.fetchAgents();
expect(afterSecond.entries).toHaveLength(2);
// Verify both agents are present with correct IDs and states
const ids = afterSecond.entries.map((entry) => entry.agent.id);
expect(ids).toContain(agent1.id);
expect(ids).toContain(agent2.id);
// Verify both agents are present with correct IDs and states
const ids = afterSecond.entries.map((entry) => entry.agent.id);
expect(ids).toContain(agent1.id);
expect(ids).toContain(agent2.id);
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
// Title may or may not be set depending on timing
expect(agent1State?.cwd).toBe(cwd1);
expect(agent1State?.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent1State?.cwd).toBe(cwd1);
expect(agent1State?.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent2State?.cwd).toBe(cwd2);
expect(agent2State?.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent2State?.cwd).toBe(cwd2);
expect(agent2State?.status).toBe("idle");
// Delete first agent
await ctx.client.deleteAgent(agent1.id);
// Delete first agent
await ctx.client.deleteAgent(agent1.id);
// fetchAgents should now return only 1 agent
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries).toHaveLength(1);
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
// fetchAgents should now return only 1 agent
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries).toHaveLength(1);
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
// Verify agent1 is no longer in the list
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
expect(deletedAgent).toBeUndefined();
// Verify agent1 is no longer in the list
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
expect(deletedAgent).toBeUndefined();
// Cleanup
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
// Cleanup
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
});

Some files were not shown because too many files have changed in this diff Show More