Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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
+1298
-1296
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?.();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+5
-7
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+7
-11
@@ -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
+12
-21
@@ -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}`,
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user