diff --git a/etc/agent.api.md b/etc/agent.api.md index 0565fab..0b28ef2 100644 --- a/etc/agent.api.md +++ b/etc/agent.api.md @@ -285,7 +285,7 @@ export abstract class BaseLLMClient { // Warning: (ae-forgotten-export) The symbol "bashParameters" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "BashResult" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const bashTool: Tool; // @public (undocumented) @@ -396,20 +396,26 @@ export type CreateAgentRuntimeFromConfigOptions = LoadAgentConfigOptions & Agent // @public export function createCommandPolicy(options?: CommandPolicyOptions): CommandPolicy; -// @public (undocumented) +// @public export function createDefaultToolRegistry(): ToolRegistry; +// @public (undocumented) +export const DEFAULT_WEB_FETCH_MAX_LENGTH = 20000; + +// @public (undocumented) +export const DEFAULT_WEB_FETCH_TIMEOUT_MS = 15000; + // Warning: (ae-forgotten-export) The symbol "editFileParameters" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const editFileTool: Tool; -// @public (undocumented) -export function errorToolResult(message: string, code: string, data?: TData): ToolErrorResult; +// @public +export function errorToolResult(message: string, code: string, data?: TData, display?: ToolErrorResult["display"]): ToolErrorResult; // @public export class EventBus { @@ -453,18 +459,21 @@ export type ExecutionTrace = { // Warning: (ae-forgotten-export) The symbol "globParameters" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const globTool: Tool; // Warning: (ae-forgotten-export) The symbol "grepParameters" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const grepTool: Tool; +// @public +export function inferToolTarget(toolName: string, ...candidates: unknown[]): string | undefined; + // @public (undocumented) export class JsonCheckpointStore implements CheckpointStore { constructor(storageRoot: string, runId: string); @@ -521,7 +530,7 @@ export const LLMConfigSchema: z.ZodObject<{ baseUrl: z.ZodOptional; }, z.core.$strip>; -// @public (undocumented) +// @public export type LLMToolParametersSchema = Record; // @public @@ -537,7 +546,13 @@ export type LoadAgentConfigOptions = { export type LoadPixelleConfigOptions = LoadAgentConfigOptions; // @public (undocumented) -export function okToolResult(message: string, data: TData): ToolSuccessResult; +export const MAX_WEB_FETCH_MAX_LENGTH = 200000; + +// @public (undocumented) +export const MAX_WEB_FETCH_TIMEOUT_MS = 60000; + +// @public +export function okToolResult(message: string, data: TData, display?: ToolSuccessResult["display"]): ToolSuccessResult; // @public (undocumented) export type PermissionConfig = { @@ -563,7 +578,7 @@ export type PixelleEvent = AgentEvent | RuntimeEvent; // Warning: (ae-forgotten-export) The symbol "readFileParameters" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const readFileTool: Tool = { definition: ToolDefinition & { parameters: TParameters; @@ -635,7 +650,7 @@ export type Tool; }; -// @public (undocumented) +// @public export type ToolContext = { workspaceRoot: string; signal?: AbortSignal; @@ -643,16 +658,17 @@ export type ToolContext = { fileWriter?: ToolFileWriter; workspaceProfile?: WorkspaceProfile; commandPolicy?: CommandPolicyLike; + emitStream?: (chunk: ToolStreamChunk) => void | Promise; }; -// @public (undocumented) +// @public export type ToolDefinition = { name: string; description: string; parameters: ToolParameterSchema; }; -// @public (undocumented) +// @public export class ToolError extends Error { constructor(options: ToolErrorOptions); // (undocumented) @@ -663,10 +679,10 @@ export class ToolError extends Error { readonly toolName?: string; } -// @public (undocumented) -export type ToolErrorCode = "TOOL_NOT_FOUND" | "TOOL_ALREADY_REGISTERED" | "TOOL_PERMISSION_DENIED" | "TOOL_APPROVAL_REQUIRED" | "TOOL_COMMAND_POLICY_DENIED" | "TOOL_INVALID_INPUT" | "TOOL_PATH_OUTSIDE_WORKSPACE" | "TOOL_EXECUTION_FAILED"; +// @public +export type ToolErrorCode = "TOOL_NOT_FOUND" | "TOOL_ALREADY_REGISTERED" | "TOOL_PERMISSION_DENIED" | "TOOL_APPROVAL_REQUIRED" | "TOOL_COMMAND_POLICY_DENIED" | "TOOL_INVALID_INPUT" | "TOOL_PATH_OUTSIDE_WORKSPACE" | "TOOL_EXECUTION_FAILED" | "TOOL_TIMEOUT" | "TOOL_ABORTED"; -// @public (undocumented) +// @public export type ToolErrorOptions = { code: ToolErrorCode; message: string; @@ -675,18 +691,19 @@ export type ToolErrorOptions = { cause?: unknown; }; -// @public (undocumented) +// @public export type ToolErrorResult = { ok: false; message: string; code: string; data?: TData; + display?: ToolResultDisplay; }; -// @public (undocumented) +// @public export type ToolExecute = (input: z.infer, context: ToolContext) => Promise> | ToolResult; -// @public (undocumented) +// @public export type ToolFileWriter = { writeFile(relativePath: string, content: string): Promise<{ path: string; @@ -694,10 +711,10 @@ export type ToolFileWriter = { }>; }; -// @public (undocumented) +// @public export type ToolParameterSchema = z.ZodTypeAny; -// @public (undocumented) +// @public export type ToolPermissions = { readFile?: boolean; writeFile?: boolean; @@ -705,41 +722,103 @@ export type ToolPermissions = { shell?: boolean; }; -// @public (undocumented) +// @public export class ToolRegistry { - // (undocumented) get(name: string): RegisteredTool | undefined; - // (undocumented) list(): RegisteredTool[]; - // (undocumented) listDefinitions(): ToolDefinition[]; // Warning: (ae-forgotten-export) The symbol "RegisteredTool" needs to be exported by the entry point index.d.ts - // - // (undocumented) register(tool: RegisteredTool): void; } -// @public (undocumented) +// @public export type ToolResult = ToolSuccessResult | ToolErrorResult; -// @public (undocumented) +// @public +export type ToolResultDisplay = { + title?: string; + summary?: string; + preview?: string; + stats?: Record; + truncated?: boolean; +}; + +// @public export class ToolRunner { - constructor(registry: ToolRegistry); - // (undocumented) - run(name: string, input: unknown, context: ToolContext): Promise; + constructor(registry: ToolRegistry, options?: ToolRunnerOptions); + run(name: string, input: unknown, context: ToolContext, options?: ToolRunOptions): Promise; } +// Warning: (ae-forgotten-export) The symbol "ToolRunnerEventBase" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "ToolRunnerTerminalEventBase" needs to be exported by the entry point index.d.ts +// // @public (undocumented) +export type ToolRunnerEvent = +/** Emitted before registry lookup, validation, or tool execution starts. */ +(ToolRunnerEventBase & { + type: "runner.tool.started"; + input?: unknown; +}) +/** Emitted when a running tool has incremental output available for display. */ +| (ToolRunnerEventBase & { + type: "runner.tool.streamed"; + stream: ToolStreamChunk; +}) +/** Emitted after a tool returns a successful ToolResult. */ +| (ToolRunnerTerminalEventBase & { + type: "runner.tool.completed"; +}) +/** Emitted after validation or execution returns a non-control failure. */ +| (ToolRunnerTerminalEventBase & { + type: "runner.tool.failed"; + errorCode: string; +}) +/** Emitted when ToolRunner's timeout control wins the execution race. */ +| (ToolRunnerTerminalEventBase & { + type: "runner.tool.timed_out"; + errorCode: "TOOL_TIMEOUT"; +}) +/** Emitted when an external or context abort signal cancels execution. */ +| (ToolRunnerTerminalEventBase & { + type: "runner.tool.aborted"; + errorCode: "TOOL_ABORTED"; +}); + +// @public +export type ToolRunnerOptions = { + defaultTimeoutMs?: number; + onEvent?: (event: ToolRunnerEvent) => void | Promise; + now?: () => number; + createCallId?: () => string; +}; + +// @public +export type ToolRunOptions = { + timeoutMs?: number | false; + signal?: AbortSignal; + callId?: string; + metadata?: Record; +}; + +// @public +export type ToolStreamChunk = { + type: "stdout" | "stderr" | "data"; + content: string; + metadata?: Record; +}; + +// @public export type ToolSuccessResult = { ok: true; message: string; data: TData; + display?: ToolResultDisplay; }; // @public (undocumented) export function toPosixPath(filePath: string): string; -// @public (undocumented) +// @public export function toToolError(error: unknown, fallback: Omit): ToolError; // @public (undocumented) @@ -805,12 +884,10 @@ export class Verifier { } // Warning: (ae-forgotten-export) The symbol "webFetchParameters" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "WebFetchResultData" needs to be exported by the entry point index.d.ts // -// @public (undocumented) -export const webFetchTool: Tool; +// @public +export const webFetchTool: Tool; // @public (undocumented) export type WorkspaceProfile = { @@ -829,7 +906,7 @@ export class WorkspaceScanner { // Warning: (ae-forgotten-export) The symbol "writeFileParameters" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export const writeFileTool: Tool(); private readonly pendingToolRunnerTerminalEvents = new Map< string, - Exclude + Exclude< + ToolRunnerEvent, + {type: "runner.tool.started"} | {type: "runner.tool.streamed"} + > >(); /** Creates an agent runtime from explicit dependencies and runtime configuration. */ @@ -600,9 +608,9 @@ export class Agent { ); await input.context.traceStore?.update((trace) => { trace.modelCalls.push({ - request, + request: createTraceModelRequest(request), response: { - ...rawResponse, + ...createTraceModelResponse(rawResponse), iteration: input.iteration, runId: input.context.runId, }, @@ -727,6 +735,9 @@ export class Agent { fileWriter: context.fileWriter, workspaceProfile: context.workspaceProfile, commandPolicy: this.commandPolicy, + emitStream: this.usesToolRunnerEventAdapter + ? undefined + : (stream) => this.emitToolCallStream(toolCall, metadata, options, stream), }), { callId: toolCall.id, @@ -753,8 +764,10 @@ export class Agent { type: "tool.call_completed", id: toolCall.id, name: toolCall.name, + result: toolResult.result, output: toolResult.result.data, summary: toolResult.result.message, + display: toolResult.result.display, metadata, }, options, @@ -766,9 +779,11 @@ export class Agent { type: "tool.call_failed", id: toolCall.id, name: toolCall.name, + result: toolResult.result, error: toolResult.result.message, code: toolResult.result.code, data: toolResult.result.data, + display: toolResult.result.display, metadata, }, options, @@ -778,11 +793,16 @@ export class Agent { return toolResult; } - /** Handles ToolRunner events for the default runner without duplicating Agent events. */ + /** Handles default ToolRunner events without exposing raw terminal results. + * + * Live runner events are safe to publish immediately. Terminal runner events + * are cached for their timing/metadata only; the public terminal Agent event + * is emitted after afterTool middleware has produced the final ToolResult. + */ private emitToolRunnerEvent(event: ToolRunnerEvent): void { const traceId = typeof event.metadata?.traceId === "string" ? event.metadata.traceId : undefined; - if (event.type !== "runner.tool.started") { + if (event.type !== "runner.tool.started" && event.type !== "runner.tool.streamed") { if (traceId) { this.pendingToolRunnerTerminalEvents.set( this.toolRunnerEventKey(traceId, event.callId), @@ -790,17 +810,39 @@ export class Agent { ); return; } + + return; } const options = traceId ? this.runOptionsByTraceId.get(traceId) : undefined; - emitToolRunnerEventAsAgentEvent({ + emitRunnerLiveEventAsAgentEvent({ eventBus: this.eventBus, event, options: options ?? {}, }); } + /** Emits stream chunks for externally supplied ToolRunner instances. */ + private emitToolCallStream( + toolCall: AgentToolCall, + metadata: Record, + options: RunInternalOptions, + stream: ToolStreamChunk, + ): void { + emitAgentEvent( + this.eventBus, + { + type: "tool.call_stream", + id: toolCall.id, + name: toolCall.name, + stream, + metadata, + }, + options, + ); + } + /** Emits the final Agent tool event after afterTool middleware has finalized the result. */ private emitFinalToolRunnerEvent( callId: string, @@ -823,59 +865,14 @@ export class Agent { return; } - emitToolRunnerEventAsAgentEvent({ + emitFinalToolResultAsAgentEvent({ eventBus: this.eventBus, - event: this.withToolRunnerEventResult(terminalEvent, result), + runnerEvent: terminalEvent, + result, options: options ?? {}, }); } - /** Rebuilds a terminal runner event using the Agent's final post-middleware result. */ - private withToolRunnerEventResult( - event: Exclude, - result: AgentToolResult["result"], - ): Exclude { - const base = { - callId: event.callId, - toolName: event.toolName, - startedAt: event.startedAt, - endedAt: event.endedAt, - durationMs: event.durationMs, - result, - timeoutMs: event.timeoutMs, - metadata: event.metadata, - }; - - if (result.ok) { - return { - ...base, - type: "runner.tool.completed", - }; - } - - if (result.code === "TOOL_TIMEOUT") { - return { - ...base, - type: "runner.tool.timed_out", - errorCode: "TOOL_TIMEOUT", - }; - } - - if (result.code === "TOOL_ABORTED") { - return { - ...base, - type: "runner.tool.aborted", - errorCode: "TOOL_ABORTED", - }; - } - - return { - ...base, - type: "runner.tool.failed", - errorCode: result.code, - }; - } - /** Creates a stable key for pending runner terminal events within one trace. */ private toolRunnerEventKey(traceId: string, callId: string): string { return `${traceId}:${callId}`; @@ -1217,3 +1214,84 @@ function buildRepairPrompt(failure: VerificationResult, repairAttempt: number): output.slice(0, 12_000), ].join("\n\n"); } + +const TRACE_MESSAGE_CONTENT_LIMIT = 4_000; +const TRACE_TOOL_ARGUMENT_LIMIT = 4_000; +const TRACE_TOOL_COUNT_LIMIT = 32; + +function createTraceModelRequest(request: AgentModelRequest): AgentModelRequest { + return { + ...request, + messages: request.messages.map(createTraceMessage), + tools: request.tools?.slice(0, TRACE_TOOL_COUNT_LIMIT), + }; +} + +function createTraceModelResponse(response: AgentModelResponse): AgentModelResponse { + return { + ...response, + content: truncateTraceString(response.content, TRACE_MESSAGE_CONTENT_LIMIT), + toolCalls: response.toolCalls.map((toolCall) => ({ + ...toolCall, + arguments: truncateTraceRecord(toolCall.arguments, TRACE_TOOL_ARGUMENT_LIMIT), + })), + }; +} + +function createTraceMessage(message: LLMMessage): LLMMessage { + if (message.role === "assistant") { + return { + ...message, + content: + message.content === undefined + ? undefined + : truncateTraceString(message.content, TRACE_MESSAGE_CONTENT_LIMIT), + toolCalls: message.toolCalls?.map((toolCall) => ({ + ...toolCall, + arguments: truncateTraceRecord(toolCall.arguments, TRACE_TOOL_ARGUMENT_LIMIT), + })), + }; + } + + return { + ...message, + content: truncateTraceString(message.content, TRACE_MESSAGE_CONTENT_LIMIT), + }; +} + +function truncateTraceString(value: string, limit: number): string { + if (value.length <= limit) { + return value; + } + + return `${value.slice(0, limit)}\n\n[trace truncated ${value.length - limit} chars]`; +} + +function truncateTraceJson(value: unknown, limit: number): unknown { + const serialized = safeJsonStringify(value); + if (!serialized || serialized.length <= limit) { + return value; + } + + return `[trace truncated ${serialized.length - limit} chars] ${serialized.slice(0, limit)}`; +} + +function truncateTraceRecord( + value: Record, + limit: number, +): Record { + const truncated = truncateTraceJson(value, limit); + if (typeof truncated === "string") { + return {__pixelleTraceTruncated: truncated}; + } + + return value; +} + +function safeJsonStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} diff --git a/src/agent/runtime-utils.ts b/src/agent/runtime-utils.ts index 07d4ea6..fcd4751 100644 --- a/src/agent/runtime-utils.ts +++ b/src/agent/runtime-utils.ts @@ -8,6 +8,7 @@ import { type ToolContext, type ToolFileWriter, type ToolPermissions, + type ToolStreamChunk, type ToolRegistry, type ToolResult, } from "../tool/index.js"; @@ -123,6 +124,7 @@ export function createToolContext(input: { fileWriter?: ToolFileWriter; workspaceProfile?: WorkspaceProfile; commandPolicy?: CommandPolicyLike; + emitStream?: (chunk: ToolStreamChunk) => void | Promise; }): ToolContext { return { workspaceRoot: input.workspaceRoot, @@ -131,6 +133,7 @@ export function createToolContext(input: { fileWriter: input.fileWriter, workspaceProfile: input.workspaceProfile, commandPolicy: input.commandPolicy, + emitStream: input.emitStream, }; } diff --git a/src/agent/tool-runner-events.ts b/src/agent/tool-runner-events.ts index daae49c..a817843 100644 --- a/src/agent/tool-runner-events.ts +++ b/src/agent/tool-runner-events.ts @@ -1,11 +1,26 @@ import type {EventBus, PixelleEvent} from "../events/index.js"; -import type {ToolRunnerEvent} from "../tool/index.js"; +import type {ToolResult, ToolRunnerEvent} from "../tool/index.js"; +import {inferToolTarget} from "../tool/tool-target.js"; import {emitAgentEvent} from "./runtime-utils.js"; import type {RunInternalOptions} from "./types.js"; -export function emitToolRunnerEventAsAgentEvent(input: { +type RunnerLiveEvent = Extract< + ToolRunnerEvent, + {type: "runner.tool.started"} | {type: "runner.tool.streamed"} +>; + +type RunnerTerminalEvent = Exclude; + +/** Bridges live ToolRunner lifecycle events into public Agent events. + * + * Runner events describe raw execution mechanics. Agent events are the public + * semantic stream consumed by CLI, trace, replay, and external observers. Only + * started/streamed events are bridged immediately; terminal events are emitted + * separately after Agent middleware has finalized the ToolResult. + */ +export function emitRunnerLiveEventAsAgentEvent(input: { eventBus: EventBus; - event: ToolRunnerEvent; + event: RunnerLiveEvent; options: RunInternalOptions; }): void { const metadata = input.event.metadata; @@ -18,6 +33,7 @@ export function emitToolRunnerEventAsAgentEvent(input: { type: "tool.call_started", id: input.event.callId, name: input.event.toolName, + target: inferToolTarget(input.event.toolName, input.event.input), input: input.event.input, status: "running", metadata, @@ -26,37 +42,79 @@ export function emitToolRunnerEventAsAgentEvent(input: { ); return; - case "runner.tool.completed": + case "runner.tool.streamed": emitAgentEvent( input.eventBus, { - type: "tool.call_completed", + type: "tool.call_stream", id: input.event.callId, name: input.event.toolName, - output: input.event.result.data, - summary: input.event.result.message, + stream: input.event.stream, metadata, }, input.options, ); return; + } +} - case "runner.tool.failed": - case "runner.tool.timed_out": - case "runner.tool.aborted": - emitAgentEvent( - input.eventBus, - { - type: "tool.call_failed", - id: input.event.callId, - name: input.event.toolName, - error: input.event.result.message, - code: input.event.errorCode, - data: input.event.result.data, - metadata, - }, - input.options, - ); - return; +/** Emits the final public Agent tool event from the post-middleware ToolResult. + * + * The terminal runner event contributes timing and metadata only. Its raw + * result is intentionally ignored so afterTool middleware remains the last word + * on what CLI, Trace, Replay, and external consumers observe. + */ +export function emitFinalToolResultAsAgentEvent(input: { + eventBus: EventBus; + runnerEvent: RunnerTerminalEvent; + result: ToolResult; + options: RunInternalOptions; +}): void { + const metadata = input.runnerEvent.metadata; + + if (input.result.ok) { + emitAgentEvent( + input.eventBus, + { + type: "tool.call_completed", + id: input.runnerEvent.callId, + name: input.runnerEvent.toolName, + result: input.result, + durationMs: input.runnerEvent.durationMs, + target: inferToolTarget( + input.runnerEvent.toolName, + input.result.display, + input.result.data, + ), + output: input.result.data, + summary: input.result.message, + display: input.result.display, + metadata, + }, + input.options, + ); + return; } + + emitAgentEvent( + input.eventBus, + { + type: "tool.call_failed", + id: input.runnerEvent.callId, + name: input.runnerEvent.toolName, + result: input.result, + durationMs: input.runnerEvent.durationMs, + target: inferToolTarget( + input.runnerEvent.toolName, + input.result.display, + input.result.data, + ), + error: input.result.message, + code: input.result.code, + data: input.result.data, + display: input.result.display, + metadata, + }, + input.options, + ); } diff --git a/src/cli/components/markdown/CodeBlock.tsx b/src/cli/components/markdown/CodeBlock.tsx index 8b053b0..6d7e953 100644 --- a/src/cli/components/markdown/CodeBlock.tsx +++ b/src/cli/components/markdown/CodeBlock.tsx @@ -1,87 +1,126 @@ +import {useMemo} from "react"; import {Box, Text} from "ink"; import {highlight} from "cli-highlight"; import wrapAnsi from "wrap-ansi"; import {theme} from "../../utils/theme.js"; +import {DiffPreview} from "../timeline/DiffPreview.js"; type CodeBlockProps = { code: string; language?: string; - reveal?: boolean; + streaming?: boolean; closed?: boolean; width: number; + maxLines?: number; + showLineNumbers?: boolean; }; export function CodeBlock({ code, language, - reveal = false, + streaming = false, closed = true, width, + maxLines = 160, + showLineNumbers = true, }: CodeBlockProps) { - const lines = code.length === 0 ? [""] : code.split("\n"); - const isDiff = language === "diff" || language === "patch"; - const title = language ?? (isDiff ? "diff" : "text"); - const contentWidth = Math.max(20, width - 6); + const lines = useMemo(() => splitCodeLines(code), [code]); + const normalizedLanguage = normalizeCodeLanguage(language); + const isDiff = normalizedLanguage === "diff" || normalizedLanguage === "patch"; + const visibleLines = useMemo(() => lines.slice(0, maxLines), [lines, maxLines]); + const truncated = lines.length > maxLines; + const title = formatLanguageLabel(normalizedLanguage); + const blockWidth = Math.max(24, Math.min(width, 120)); + const lineNumberWidth = getLineNumberWidth(visibleLines.length); + const gutterWidth = getGutterWidth(lineNumberWidth, showLineNumbers); + const contentWidth = Math.max(12, blockWidth - gutterWidth - 4); + const highlightedLines = useMemo( + () => + isDiff + ? [] + : visibleLines.map((line) => + highlightLine(line.length === 0 ? " " : line, normalizedLanguage), + ), + [isDiff, normalizedLanguage, visibleLines], + ); return ( - + {title} - {reveal || !closed ? / streaming : null} + {streaming && !closed ? streaming : null} - {lines.map((line, index) => ( - - ))} + ) : ( + <> + {visibleLines.map((line, index) => ( + + ))} + {truncated ? ( + + {`... code truncated, showing first ${maxLines} lines`} + + ) : null} + + )} ); } function CodeLine({ - line, - language, - isDiff, + lineNumber, + lineNumberWidth, + highlighted, width, + showLineNumbers, }: { - line: string; - language?: string; - isDiff: boolean; + lineNumber: number; + lineNumberWidth: number; + highlighted: string; width: number; + showLineNumbers: boolean; }) { - const renderedLine = line.length === 0 ? " " : line; - const highlighted = isDiff ? renderedLine : highlightLine(renderedLine, language); - const wrapped = wrapAnsi(highlighted, width, {hard: true}).split("\n"); + const wrapped = wrapAnsi(highlighted, width, {hard: true, trim: false}).split("\n"); + const continuationGutter = " ".repeat(getGutterWidth(lineNumberWidth, showLineNumbers)); return ( <> {wrapped.map((part, index) => ( - - {index === 0 ? "│ " : " "} + + {index === 0 + ? formatGutter(lineNumber, lineNumberWidth, showLineNumbers) + : continuationGutter} - {isDiff ? ( - {part} - ) : ( - {part} - )} + {part} ))} ); } +export function splitCodeLines(code: string): string[] { + return code.length === 0 ? [""] : code.split(/\r?\n/); +} + +export function normalizeCodeLanguage(language: string | undefined): string | undefined { + const normalized = language?.trim().toLowerCase(); + return normalized || undefined; +} + function highlightLine(line: string, language: string | undefined): string { if (!language) { return line; @@ -94,42 +133,46 @@ function highlightLine(line: string, language: string | undefined): string { } } -function getGutterColor(line: string, isDiff: boolean): string { - if (!isDiff) { - return theme.rail; - } - - if (line.startsWith("+")) { - return theme.success; - } - - if (line.startsWith("-")) { - return theme.danger; - } - - if (line.startsWith("@@")) { - return theme.accent; - } - - return theme.rail; +export function getLineNumberWidth(lineCount: number): number { + return Math.max(2, String(Math.max(1, lineCount)).length); } -function getLineColor(line: string, isDiff: boolean): string | undefined { - if (!isDiff) { - return theme.code; - } - - if (line.startsWith("+")) { - return theme.success; - } +export function getGutterWidth( + lineNumberWidth: number, + showLineNumbers: boolean, +): number { + return showLineNumbers ? lineNumberWidth + 3 : 2; +} - if (line.startsWith("-")) { - return theme.danger; - } +export function formatGutter( + lineNumber: number, + lineNumberWidth: number, + showLineNumbers: boolean, +): string { + return showLineNumbers + ? `${String(lineNumber).padStart(lineNumberWidth, " ")} │ ` + : "│ "; +} - if (line.startsWith("@@")) { - return theme.accent; +function formatLanguageLabel(language: string | undefined): string { + switch (language) { + case "ts": + return "typescript"; + case "tsx": + return "tsx"; + case "js": + return "javascript"; + case "jsx": + return "jsx"; + case "sh": + return "shell"; + case "bash": + return "bash"; + case "patch": + return "patch"; + case "diff": + return "diff"; + default: + return language ?? "text"; } - - return theme.text; } diff --git a/src/cli/components/markdown/MessageMarkdown.tsx b/src/cli/components/markdown/MessageMarkdown.tsx index 36b5b84..bce4384 100644 --- a/src/cli/components/markdown/MessageMarkdown.tsx +++ b/src/cli/components/markdown/MessageMarkdown.tsx @@ -76,7 +76,7 @@ export function MessageMarkdown({ key={index} code={block.code} language={block.language} - reveal={streaming} + streaming={streaming && !block.closed} closed={block.closed} width={width} /> diff --git a/src/cli/components/timeline/DiffPreview.tsx b/src/cli/components/timeline/DiffPreview.tsx index e420bdd..9880c22 100644 --- a/src/cli/components/timeline/DiffPreview.tsx +++ b/src/cli/components/timeline/DiffPreview.tsx @@ -1,43 +1,206 @@ import {Box, Text} from "ink"; +import wrapAnsi from "wrap-ansi"; -import type {ChangedFileState} from "../../types.js"; -import {createFileDiff} from "../../utils/diff.js"; import {theme} from "../../utils/theme.js"; type DiffPreviewProps = { - file: ChangedFileState; + diff: string; + maxLines?: number; + showLineNumbers?: boolean; + width?: number; }; -export function DiffPreview({file}: DiffPreviewProps) { - const diff = createFileDiff(file); +type ParsedDiffLine = { + kind: "add" | "delete" | "context" | "hunk" | "file" | "meta"; + text: string; + oldLine?: number; + newLine?: number; +}; + +const DEFAULT_MAX_LINES = 120; - if (!diff) { - return null; +export function DiffPreview({ + diff, + maxLines = DEFAULT_MAX_LINES, + showLineNumbers = true, + width = 100, +}: DiffPreviewProps) { + const lines = parseUnifiedDiff(diff); + + if (!lines.length) { + return ( + + no diff available + + ); } + const visibleLines = lines.slice(0, maxLines); + const truncated = lines.length > maxLines; + const maxOldLine = Math.max(0, ...visibleLines.map((line) => line.oldLine ?? 0)); + const maxNewLine = Math.max(0, ...visibleLines.map((line) => line.newLine ?? 0)); + const lineNumberWidth = Math.max(2, String(Math.max(maxOldLine, maxNewLine)).length); + const gutterWidth = showLineNumbers ? lineNumberWidth + 4 : 2; + const contentWidth = Math.max(20, width - gutterWidth - 2); + return ( - {diff.split("\n").map((line, index) => ( - - {line} - + {visibleLines.map((line, index) => ( + ))} + {truncated ? ( + + {`... diff truncated, showing first ${maxLines} lines`} + + ) : null} ); } -function getLineColor(line: string): string { - if (line.startsWith("+") && !line.startsWith("+++")) { - return theme.success; +function DiffLine({ + line, + contentWidth, + lineNumberWidth, + showLineNumbers, +}: { + line: ParsedDiffLine; + contentWidth: number; + lineNumberWidth: number; + showLineNumbers: boolean; +}) { + const color = getLineColor(line.kind); + const marker = getMarker(line.kind); + const text = line.text || " "; + const wrapped = wrapAnsi(text, contentWidth, {hard: true}).split("\n"); + + return ( + <> + {wrapped.map((part, index) => ( + + {index === 0 ? ( + + ) : ( + + {" ".repeat(showLineNumbers ? lineNumberWidth + 4 : 2)} + + )} + {part} + + ))} + + ); +} + +function DiffGutter({ + line, + lineNumberWidth, + marker, + showLineNumbers, +}: { + line: ParsedDiffLine; + lineNumberWidth: number; + marker: string; + showLineNumbers: boolean; +}) { + if (!showLineNumbers || line.kind === "hunk" || line.kind === "file") { + return {`${marker} `}; } - if (line.startsWith("-") && !line.startsWith("---")) { - return theme.danger; + const number = line.kind === "add" ? line.newLine : (line.oldLine ?? line.newLine); + const lineNumber = number === undefined ? "" : String(number); + + return ( + + {lineNumber.padStart(lineNumberWidth, " ")} {marker}{" "} + + ); +} + +function parseUnifiedDiff(diff: string): ParsedDiffLine[] { + const lines = diff.replace(/\s+$/g, "").split(/\r?\n/); + const parsed: ParsedDiffLine[] = []; + let oldLine: number | undefined; + let newLine: number | undefined; + + for (const rawLine of lines) { + const hunk = rawLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + parsed.push({kind: "hunk", text: rawLine}); + continue; + } + + if (rawLine.startsWith("---") || rawLine.startsWith("+++")) { + parsed.push({kind: "file", text: rawLine}); + continue; + } + + if (rawLine.startsWith("+")) { + parsed.push({kind: "add", text: rawLine, newLine}); + newLine = incrementLine(newLine); + continue; + } + + if (rawLine.startsWith("-")) { + parsed.push({kind: "delete", text: rawLine, oldLine}); + oldLine = incrementLine(oldLine); + continue; + } + + if (rawLine.startsWith(" ")) { + parsed.push({kind: "context", text: rawLine, oldLine, newLine}); + oldLine = incrementLine(oldLine); + newLine = incrementLine(newLine); + continue; + } + + parsed.push({kind: "meta", text: rawLine}); } - if (line.startsWith("@@")) { - return theme.accent; + return parsed.filter((line) => line.text.length > 0); +} + +function incrementLine(line: number | undefined): number | undefined { + return line === undefined ? undefined : line + 1; +} + +function getMarker(kind: ParsedDiffLine["kind"]): string { + switch (kind) { + case "add": + return "+"; + case "delete": + return "-"; + case "hunk": + return ">"; + default: + return "│"; } +} - return theme.muted; +function getLineColor(kind: ParsedDiffLine["kind"]): string { + switch (kind) { + case "add": + return theme.success; + case "delete": + return theme.danger; + case "hunk": + return theme.accent; + case "file": + case "meta": + return theme.faint; + case "context": + return theme.muted; + } } diff --git a/src/cli/components/timeline/FileChangeCard.tsx b/src/cli/components/timeline/FileChangeCard.tsx index 21354e5..aa174f2 100644 --- a/src/cli/components/timeline/FileChangeCard.tsx +++ b/src/cli/components/timeline/FileChangeCard.tsx @@ -1,6 +1,11 @@ import {Box, Text} from "ink"; import type {ChangeSetState, ChangedFileState} from "../../types.js"; +import { + createFileChangeViewModel, + type FileChangeKind, + type FileChangeViewModel, +} from "../../utils/diff.js"; import {theme} from "../../utils/theme.js"; import {DiffPreview} from "./DiffPreview.js"; @@ -11,6 +16,7 @@ type FileChangeCardProps = { export function FileChangeCard({changeSet, debug}: FileChangeCardProps) { const counts = summarizeFiles(changeSet.files); + const changes = changeSet.files.map(createFileChangeViewModel); return ( - {changeSet.files.map((file) => ( - - - {formatStatus(file.status)} - - {file.path} - - + {changes.map((change) => ( + + + {change.diff ? ( + + ) : ( + + no diff available + + )} ))} @@ -49,6 +57,27 @@ export function FileChangeCard({changeSet, debug}: FileChangeCardProps) { ); } +function FileChangeHeadline({change}: {change: FileChangeViewModel}) { + return ( + + {formatKind(change.kind)} + + {change.oldPath ? ( + <> + {change.oldPath} + + + ) : null} + {change.filePath} + ( + +{change.addedLines ?? 0} + + -{change.removedLines ?? 0} + ) + + ); +} + function summarizeFiles(files: readonly ChangedFileState[]): Record { return files.reduce>((counts, file) => { counts[file.status] = (counts[file.status] ?? 0) + 1; @@ -66,22 +95,25 @@ function formatCounts(counts: Record): string { .join(", "); } -function formatStatus(status: ChangedFileState["status"]): string { - switch (status) { +function formatKind(kind: FileChangeKind): string { + switch (kind) { case "created": - return "created"; - case "modified": - return "modified"; + return "Created"; + case "edited": + return "Edited"; case "deleted": - return "deleted"; + return "Deleted"; + case "renamed": + return "Renamed"; } } -function getStatusColor(status: ChangedFileState["status"]): string { - switch (status) { +function getKindColor(kind: FileChangeKind): string { + switch (kind) { case "created": return theme.success; - case "modified": + case "edited": + case "renamed": return theme.accent; case "deleted": return theme.danger; diff --git a/src/cli/components/timeline/Timeline.tsx b/src/cli/components/timeline/Timeline.tsx index f2283c5..8c6b79c 100644 --- a/src/cli/components/timeline/Timeline.tsx +++ b/src/cli/components/timeline/Timeline.tsx @@ -57,7 +57,15 @@ function getMarker(item: CliTimelineItem): string { } if (item.kind === "tool") { - return item.tool.status === "error" ? icons.error : icons.tool; + if (item.tool.status === "error") { + return icons.error; + } + + if (item.tool.status === "success" || item.tool.status === "done") { + return icons.done; + } + + return icons.tool; } if (item.kind === "change_set") { diff --git a/src/cli/components/timeline/ToolStatus.tsx b/src/cli/components/timeline/ToolStatus.tsx index 1b0ad71..471128d 100644 --- a/src/cli/components/timeline/ToolStatus.tsx +++ b/src/cli/components/timeline/ToolStatus.tsx @@ -1,49 +1,40 @@ import {Box, Text} from "ink"; -import type {ToolCallState} from "../../types.js"; +import type {ToolCallState, ToolStreamState} from "../../types.js"; import {formatUnknown, hasLongDetail} from "../../utils/format.js"; -import {icons, theme} from "../../utils/theme.js"; +import {theme} from "../../utils/theme.js"; type ToolStatusProps = { tool: ToolCallState; debug: boolean; }; +const MAX_PREVIEW_LINES = 20; +const MAX_PREVIEW_CHARS = 4000; + export function ToolStatus({tool, debug}: ToolStatusProps) { - const color = getColor(tool.status); - const icon = getIcon(tool.status); - const detail = getInlineDetail(tool); const expanded = debug && (hasLongDetail(tool.input) || + hasLongDetail(tool.result) || hasLongDetail(tool.output) || hasLongDetail(tool.error)); + const preview = buildPreview(tool); return ( - - - {icon}{" "} - {formatToolName(tool.name)} - · {formatStatus(tool.status)} - {formatDuration(tool) ? ( - · {formatDuration(tool)} - ) : null} - {detail ? · {detail} : null} - + + + {preview ? : null} {expanded ? ( - + {tool.input !== undefined ? ( input {formatUnknown(tool.input, 500)} ) : null} {tool.output !== undefined ? ( output {formatUnknown(tool.output, 500)} ) : null} + {tool.result !== undefined ? ( + result {formatUnknown(tool.result, 500)} + ) : null} {getPolicyDecision(tool) ? ( policy {formatPolicyDecision(getPolicyDecision(tool))} @@ -56,29 +47,184 @@ export function ToolStatus({tool, debug}: ToolStatusProps) { ); } -function getInlineDetail(tool: ToolCallState): string { - const target = getToolTarget(tool.input); +function ToolHeadline({tool}: {tool: ToolCallState}) { + const title = getTitle(tool); + const presentation = getToolPresentation(tool); - if (tool.status === "pending") { - return target ?? tool.description ?? "Queued"; + if (tool.status === "pending" || tool.status === "running") { + return ( + + + {title ? {title} : null} + + ); } - if (tool.status === "running") { - return target ?? tool.description ?? "Running..."; - } + return ( + + + {title ? {title} : null} + · + + {tool.status === "error" ? "failed" : "success"} + + {formatDuration(tool) ? ( + <> + · + {formatDuration(tool)} + + ) : null} + {getTerminalSummary(tool) ? ( + <> + · + {getTerminalSummary(tool)} + + ) : null} + + ); +} - if (tool.status === "success" || tool.status === "done") { - return target ?? tool.summary ?? formatUnknown(tool.output ?? "done", 80); - } +function ToolName({tool}: {tool: ToolCallState}) { + const presentation = getToolPresentation(tool); + + return ( + + {presentation.icon}{" "} + + {presentation.label} + + + ); +} - const policyDecision = getPolicyDecision(tool); - if (policyDecision) { - return formatPolicyDecision(policyDecision); +function PreviewBlock({preview}: {preview: PreviewText}) { + return ( + + {preview.notice ? {preview.notice} : null} + {preview.text.split(/\r?\n/).map((line, index) => ( + + {line || " "} + + ))} + + ); +} + +function getTitle(tool: ToolCallState): string | undefined { + return ( + tool.result?.display?.target ?? + tool.display?.target ?? + tool.target ?? + tool.result?.display?.title ?? + tool.display?.title ?? + getToolTarget(tool.input) ?? + tool.description + ); +} + +type ToolPresentation = { + icon: string; + label: string; + color: string; + detailColor: string; + bold?: boolean; +}; + +function getToolPresentation(tool: ToolCallState): ToolPresentation { + const presentations: Record> = { + command: { + icon: "▸", + color: "yellow", + detailColor: theme.faint, + bold: true, + }, + file: { + icon: "▤", + color: "cyan", + detailColor: theme.muted, + }, + search: { + icon: "⌕", + color: "magenta", + detailColor: theme.muted, + bold: true, + }, + network: { + icon: "◌", + color: "blue", + detailColor: theme.muted, + }, + edit: { + icon: "✎", + color: "green", + detailColor: theme.muted, + bold: true, + }, + list: { + icon: "⌘", + color: "gray", + detailColor: theme.muted, + }, + json: { + icon: "◇", + color: theme.accent, + detailColor: theme.muted, + }, + text: { + icon: "◇", + color: theme.accent, + detailColor: theme.muted, + }, + }; + const kind = tool.result?.display?.kind ?? tool.display?.kind; + const presentation = kind ? presentations[kind] : undefined; + const fallback = presentation ?? { + icon: "◇", + color: theme.accent, + detailColor: theme.muted, + }; + + return { + ...fallback, + label: formatToolName(tool.name), + }; +} + +function formatToolName(name: string): string { + return name + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function getTerminalSummary(tool: ToolCallState): string | undefined { + if (tool.status === "error") { + const policyDecision = getPolicyDecision(tool); + + if (policyDecision) { + return formatPolicyDecision(policyDecision); + } + + return ( + tool.result?.display?.summary ?? + tool.display?.summary ?? + tool.result?.message ?? + tool.error ?? + tool.errorCode ?? + "Failed" + ); } - return tool.errorCode - ? `${tool.errorCode}: ${tool.error ?? "Failed"}` - : (tool.error ?? "Failed"); + return ( + tool.result?.display?.summary ?? + tool.display?.summary ?? + tool.result?.message ?? + tool.summary ?? + formatUnknown(tool.output ?? tool.result?.data ?? "done", 80) + ); } function getToolTarget(input: unknown): string | undefined { @@ -97,25 +243,85 @@ function getToolTarget(input: unknown): string | undefined { return undefined; } -function formatToolName(name: string): string { - return name - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); +type PreviewText = { + kind: ToolStreamState["type"] | "preview"; + text: string; + notice?: string; +}; + +function buildPreview(tool: ToolCallState): PreviewText | undefined { + const streamPreview = buildStreamPreview(tool); + const display = tool.result?.display ?? tool.display; + const fallbackText = + tool.status === "error" + ? streamPreview?.text || display?.preview + : streamPreview?.text || display?.preview; + + if (!fallbackText) { + return undefined; + } + + const kind = streamPreview?.kind ?? "preview"; + const truncated = truncatePreview(fallbackText); + const displayTruncated = display?.truncated && !truncated.notice; + + return { + kind, + text: truncated.text, + notice: + truncated.notice ?? + (displayTruncated + ? `… output truncated, showing last ${MAX_PREVIEW_LINES} lines` + : undefined), + }; } -function getIcon(status: ToolCallState["status"]): string { - switch (status) { - case "pending": - return icons.running; - case "running": - return icons.running; - case "success": - case "done": - return icons.done; - case "error": - return icons.error; +function buildStreamPreview(tool: ToolCallState): PreviewText | undefined { + if (!tool.streams?.length) { + return undefined; + } + + const preferredType = + tool.status === "error" && tool.streams.some((stream) => stream.type === "stderr") + ? "stderr" + : undefined; + const streams = preferredType + ? tool.streams.filter((stream) => stream.type === preferredType) + : tool.streams; + const text = streams + .map((stream) => ("content" in stream ? (stream.content ?? "") : "")) + .join(""); + + if (!text) { + return undefined; } + + return { + kind: preferredType ?? "data", + text, + }; +} + +function truncatePreview(text: string): {text: string; notice?: string} { + const normalized = text.replace(/\s+$/g, ""); + const lines = normalized.split(/\r?\n/); + const tooManyLines = lines.length > MAX_PREVIEW_LINES; + const tooManyChars = normalized.length > MAX_PREVIEW_CHARS; + + if (!tooManyLines && !tooManyChars) { + return {text: normalized}; + } + + const lineLimited = lines.slice(-MAX_PREVIEW_LINES).join("\n"); + const textLimited = + lineLimited.length > MAX_PREVIEW_CHARS + ? lineLimited.slice(lineLimited.length - MAX_PREVIEW_CHARS) + : lineLimited; + + return { + text: textLimited, + notice: `… output truncated, showing last ${MAX_PREVIEW_LINES} lines`, + }; } function getColor(status: ToolCallState["status"]): string { @@ -132,10 +338,6 @@ function getColor(status: ToolCallState["status"]): string { } } -function formatStatus(status: ToolCallState["status"]): string { - return status === "done" ? "success" : status; -} - function formatDuration(tool: ToolCallState): string | undefined { if (tool.durationMs !== undefined) { return `${tool.durationMs}ms`; diff --git a/src/cli/local/run-local-cli.ts b/src/cli/local/run-local-cli.ts index ea05915..1558041 100644 --- a/src/cli/local/run-local-cli.ts +++ b/src/cli/local/run-local-cli.ts @@ -8,7 +8,6 @@ import { saveLocalCliConfig, type LocalCliConfig, } from "../../config/local-cli-config.js"; -import {agentEventToCliEvent} from "../runtime-events.js"; import {renderCli, type CliEvent, type CliHandle} from "../index.js"; import {readGitSummary} from "./git.js"; import {runLocalCliSetup} from "./setup.js"; @@ -200,10 +199,7 @@ export async function runLocalCli(options: {reconfigure?: boolean} = {}): Promis network: false, }, })) { - const cliEvent = agentEventToCliEvent(event); - if (cliEvent) { - emit(cliEvent); - } + emit(event); } } catch (error) { emitError(error instanceof Error ? error.message : "Agent run failed.", error); diff --git a/src/cli/runtime-events.ts b/src/cli/runtime-events.ts index b29ec36..3ea4e7a 100644 --- a/src/cli/runtime-events.ts +++ b/src/cli/runtime-events.ts @@ -1,5 +1,6 @@ import type {PixelleEvent} from "../events/index.js"; -import type {CliEvent} from "./types.js"; +import {inferToolTarget} from "../tool/tool-target.js"; +import type {ChangedFileState, CliEvent} from "./types.js"; export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined { switch (event.type) { @@ -36,6 +37,7 @@ export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined type: "tool_start", id: String(event.id), name: event.name, + target: inferToolTarget(event.name, event.target, event.input), input: event.input, description: event.description, status: event.status, @@ -46,8 +48,17 @@ export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined type: "tool_done", id: String(event.id), name: event.name, - output: event.output, - summary: event.summary, + target: inferToolTarget( + event.name, + event.target, + event.result?.display, + event.display, + event.result?.data, + event.output, + ), + output: event.result?.data ?? event.output, + summary: event.result?.message ?? event.summary, + display: event.result?.display ?? event.display, createdAt: event.createdAt, }; case "tool.call_failed": @@ -55,9 +66,26 @@ export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined type: "tool_error", id: String(event.id), name: event.name, - error: event.error, - code: event.code, - data: event.data, + target: inferToolTarget( + event.name, + event.target, + event.result?.display, + event.display, + event.result?.data, + event.data, + ), + error: event.result?.message ?? event.error, + code: event.result?.code ?? event.code, + data: event.result?.data ?? event.data, + display: event.result?.display ?? event.display, + createdAt: event.createdAt, + }; + case "tool.call_stream": + return { + type: "tool_stream", + id: String(event.id), + name: event.name, + stream: event.stream, createdAt: event.createdAt, }; case "runtime.error": @@ -72,12 +100,7 @@ export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined type: "change_set", id: event.id, files: - event.changes?.map((file) => ({ - path: file.path, - beforeContent: file.beforeContent, - afterContent: file.afterContent, - status: file.status, - })) ?? + event.changes?.map(normalizeChangedFile) ?? event.files.map((filePath) => ({ path: filePath, status: "modified" as const, @@ -109,3 +132,23 @@ export function agentEventToCliEvent(event: PixelleEvent): CliEvent | undefined return undefined; } } + +function normalizeChangedFile( + file: { + path: string; + beforeContent?: string; + afterContent?: string; + status: ChangedFileState["status"]; + } & Partial, +): ChangedFileState { + return { + path: file.path, + oldPath: file.oldPath, + beforeContent: file.beforeContent, + afterContent: file.afterContent, + status: file.status, + diff: file.diff, + addedLines: file.addedLines, + removedLines: file.removedLines, + }; +} diff --git a/src/cli/state/cli-state.ts b/src/cli/state/cli-state.ts index a849339..753ef0e 100644 --- a/src/cli/state/cli-state.ts +++ b/src/cli/state/cli-state.ts @@ -1,9 +1,11 @@ import type { ChangeSetState, + ChangedFileState, CliEvent, CliMessage, ImagePreviewState, ToolCallState, + ToolStreamState, TraceState, VerificationState, } from "../types.js"; @@ -37,8 +39,17 @@ export const initialCliState: CliViewState = { export type CliAction = {type: "event"; event: CliEvent}; +const MAX_MESSAGES = 80; +const MAX_TOOLS = 80; +const MAX_IMAGES = 40; +const MAX_CHANGE_SETS = 40; +const MAX_VERIFICATIONS = 40; +const MAX_TRACES = 40; +const MAX_MESSAGE_CONTENT_LENGTH = 24_000; +const MAX_TOOL_PAYLOAD_LENGTH = 12_000; + export function reduceCliState(state: CliViewState, action: CliAction): CliViewState { - return reduceCliEvent(state, action.event); + return compactCliState(reduceCliEvent(state, action.event)); } function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { @@ -85,6 +96,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { showHelp: false, }; + case "conversation.user_message": case "user_message": return { ...state, @@ -93,7 +105,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { messages: [ ...state.messages, { - id: event.id ?? createId("user"), + id: event.id ? String(event.id) : createId("user"), role: "user", content: event.content, createdAt: eventCreatedAt, @@ -102,8 +114,10 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ], }; + case "conversation.assistant_delta": case "assistant_delta": { - const existing = state.messages.find((message) => message.id === event.messageId); + const messageId = String(event.messageId); + const existing = state.messages.find((message) => message.id === messageId); if (!existing) { return { ...state, @@ -112,7 +126,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { messages: [ ...state.messages, { - id: event.messageId, + id: messageId, role: "assistant", content: event.delta, createdAt: eventCreatedAt, @@ -128,7 +142,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ...state, ...viewEventStats, messages: state.messages.map((message) => - message.id === event.messageId + message.id === messageId ? { ...message, content: message.content + event.delta, @@ -142,8 +156,10 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { }; } + case "conversation.assistant_stage": case "assistant_stage": { - const existing = state.messages.find((message) => message.id === event.messageId); + const messageId = String(event.messageId); + const existing = state.messages.find((message) => message.id === messageId); if (!existing) { return { ...state, @@ -152,7 +168,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { messages: [ ...state.messages, { - id: event.messageId, + id: messageId, role: "assistant", content: "", createdAt: eventCreatedAt, @@ -169,7 +185,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ...viewEventStats, showHelp: false, messages: state.messages.map((message) => - message.id === event.messageId + message.id === messageId ? { ...message, stage: event.stage, @@ -180,12 +196,13 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { }; } + case "conversation.assistant_done": case "assistant_done": return { ...state, ...viewEventStats, messages: state.messages.map((message) => - message.id === event.messageId + message.id === String(event.messageId) ? { ...message, streaming: false, @@ -195,24 +212,51 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ), }; - case "tool_start": + case "tool.call_started": + case "tool_start": { + const display = "display" in event ? event.display : undefined; return { ...state, ...viewEventStats, tools: [ - ...state.tools.filter((tool) => tool.id !== event.id), + ...state.tools.filter((tool) => tool.id !== String(event.id)), { - id: event.id, + id: String(event.id), name: event.name, + target: display?.target ?? event.target, status: event.status ?? "running", input: event.input, description: event.description, + display, createdAt: eventCreatedAt, order: eventOrder, startedAt: event.status === "pending" ? undefined : eventCreatedAt, }, ], }; + } + + case "tool.call_completed": + return { + ...state, + ...viewEventStats, + tools: state.tools.map((tool) => { + const display = event.result?.display ?? event.display; + return tool.id === String(event.id) + ? { + ...tool, + status: "success", + result: event.result, + target: display?.target ?? event.target ?? tool.target, + output: event.result?.data ?? event.output, + summary: event.result?.message ?? event.summary, + display, + completedAt: eventCreatedAt, + durationMs: event.durationMs ?? getDurationMs(tool, eventCreatedAt), + } + : tool; + }), + }; case "tool_done": return { @@ -223,8 +267,10 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ? { ...tool, status: "success", + target: event.display?.target ?? event.target ?? tool.target, output: event.output, summary: event.summary, + display: event.display, completedAt: eventCreatedAt, durationMs: getDurationMs(tool, eventCreatedAt), } @@ -232,6 +278,30 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ), }; + case "tool.call_failed": + return { + ...state, + ...viewEventStats, + tools: state.tools.map((tool) => { + const display = event.result?.display ?? event.display; + return tool.id === String(event.id) + ? { + ...tool, + status: "error", + result: event.result, + target: display?.target ?? event.target ?? tool.target, + error: event.result?.message ?? event.error, + errorCode: event.result?.code ?? event.code, + errorData: event.result?.data ?? event.data, + display, + completedAt: eventCreatedAt, + durationMs: event.durationMs ?? getDurationMs(tool, eventCreatedAt), + } + : tool; + }), + lastError: event.result?.message ?? event.error, + }; + case "tool_error": return { ...state, @@ -241,9 +311,11 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ? { ...tool, status: "error", + target: event.display?.target ?? event.target ?? tool.target, error: event.error, errorCode: event.code, errorData: event.data, + display: event.display, completedAt: eventCreatedAt, durationMs: getDurationMs(tool, eventCreatedAt), } @@ -252,6 +324,23 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { lastError: event.error, }; + case "tool.call_stream": + case "tool_stream": + return { + ...state, + ...viewEventStats, + tools: state.tools.map((tool) => + tool.id === String(event.id) + ? { + ...tool, + streams: [...(tool.streams ?? []), event.stream], + createdAt: tool.createdAt, + order: tool.order, + } + : tool, + ), + }; + case "image_preview": return { ...state, @@ -268,6 +357,27 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ], }; + case "change_set.applied": + return { + ...state, + ...viewEventStats, + changeSets: [ + ...state.changeSets, + { + id: event.id, + files: + event.changes?.map(normalizeChangedFile) ?? + event.files.map((filePath) => ({ + path: filePath, + status: "modified" as const, + })), + checkpointPath: event.checkpointPath, + createdAt: eventCreatedAt, + order: eventOrder, + }, + ], + }; + case "change_set": return { ...state, @@ -284,6 +394,38 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ], }; + case "verification.started": + return { + ...state, + ...viewEventStats, + verifications: [ + ...state.verifications, + { + id: createId("verification"), + status: "running", + commands: event.commands, + createdAt: eventCreatedAt, + order: eventOrder, + }, + ], + }; + + case "verification.completed": + return { + ...state, + ...viewEventStats, + verifications: [ + ...state.verifications, + { + id: createId("verification"), + status: event.passed ? "passed" : "failed", + commands: event.commands, + createdAt: eventCreatedAt, + order: eventOrder, + }, + ], + }; + case "verification": return { ...state, @@ -300,6 +442,21 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ], }; + case "trace.persisted": + return { + ...state, + ...viewEventStats, + traces: [ + ...state.traces, + { + id: createId("trace"), + path: event.path, + createdAt: eventCreatedAt, + order: eventOrder, + }, + ], + }; + case "trace": return { ...state, @@ -315,6 +472,7 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { ], }; + case "runtime.error": case "error": return { ...state, @@ -331,6 +489,12 @@ function reduceCliEvent(state: CliViewState, event: CliEvent): CliViewState { }, ], }; + + default: + return { + ...state, + ...viewEventStats, + }; } } @@ -342,3 +506,108 @@ function getDurationMs(tool: ToolCallState, completedAt: number): number | undef return completedAt - startedAt; } + +function normalizeChangedFile( + file: { + path: string; + beforeContent?: string; + afterContent?: string; + status: ChangedFileState["status"]; + } & Partial, +): ChangedFileState { + return { + path: file.path, + oldPath: file.oldPath, + beforeContent: file.beforeContent, + afterContent: file.afterContent, + status: file.status, + diff: file.diff, + addedLines: file.addedLines, + removedLines: file.removedLines, + }; +} + +function compactCliState(state: CliViewState): CliViewState { + return { + ...state, + messages: state.messages.slice(-MAX_MESSAGES).map((message) => ({ + ...message, + content: truncateText(message.content, MAX_MESSAGE_CONTENT_LENGTH), + })), + tools: state.tools.slice(-MAX_TOOLS).map((tool) => ({ + ...tool, + input: compactPayload(tool.input), + output: compactPayload(tool.output), + errorData: compactPayload(tool.errorData), + display: compactDisplay(tool.display), + streams: compactStreams(tool.streams), + })), + images: state.images.slice(-MAX_IMAGES), + changeSets: state.changeSets.slice(-MAX_CHANGE_SETS), + verifications: state.verifications.slice(-MAX_VERIFICATIONS), + traces: state.traces.slice(-MAX_TRACES), + }; +} + +function compactDisplay( + display: TDisplay, +): TDisplay { + if (!display?.preview) { + return display; + } + + return { + ...display, + preview: truncateText(display.preview, MAX_TOOL_PAYLOAD_LENGTH), + }; +} + +function compactStreams( + streams: ToolStreamState[] | undefined, +): ToolStreamState[] | undefined { + if (!streams?.length) { + return streams; + } + + const compacted = streams.map((stream): ToolStreamState => { + if (!("content" in stream) || typeof stream.content !== "string") { + return stream; + } + + return { + ...stream, + content: truncateText(stream.content, MAX_TOOL_PAYLOAD_LENGTH), + }; + }); + + return compacted.slice(-80); +} + +function compactPayload(value: unknown): unknown { + if (value === undefined) { + return undefined; + } + + const serialized = safeJsonStringify(value); + if (!serialized || serialized.length <= MAX_TOOL_PAYLOAD_LENGTH) { + return value; + } + + return `[cli truncated ${serialized.length - MAX_TOOL_PAYLOAD_LENGTH} chars] ${serialized.slice(0, MAX_TOOL_PAYLOAD_LENGTH)}`; +} + +function truncateText(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}\n\n[cli truncated ${value.length - maxLength} chars]`; +} + +function safeJsonStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} diff --git a/src/cli/types.ts b/src/cli/types.ts index ca97bfe..264dbe9 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -1,4 +1,10 @@ -import type {BaseEvent, EventBus} from "../events/index.js"; +import type {BaseEvent, EventBus, PixelleEvent} from "../events/index.js"; +import type { + ToolResult, + ToolResultDisplay, + ToolResultDisplayKind, + ToolStreamChunk, +} from "../tool/index.js"; export type RuntimeCommandEvent = BaseEvent<"runtime_command"> & { command: string; @@ -7,6 +13,7 @@ export type RuntimeCommandEvent = BaseEvent<"runtime_command"> & { }; export type CliEvent = + | PixelleEvent | BaseEvent<"cli_clear"> | BaseEvent<"cli_debug_toggle"> | BaseEvent<"cli_help_toggle"> @@ -30,6 +37,7 @@ export type CliEvent = | (BaseEvent<"tool_start"> & { id: string; name: string; + target?: string; input?: unknown; description?: string; status?: Extract; @@ -37,15 +45,24 @@ export type CliEvent = | (BaseEvent<"tool_done"> & { id: string; name: string; + target?: string; output?: unknown; summary?: string; + display?: ToolResultDisplayState; }) | (BaseEvent<"tool_error"> & { id: string; name: string; + target?: string; error: string; code?: string; data?: unknown; + display?: ToolResultDisplayState; + }) + | (BaseEvent<"tool_stream"> & { + id: string; + name: string; + stream: ToolStreamState; }) | (BaseEvent<"image_preview"> & { id?: string; @@ -89,17 +106,33 @@ export type CliMessage = { export type ToolCallStatus = "pending" | "running" | "success" | "done" | "error"; +export type ToolResultDisplayState = { + kind?: ToolResultDisplayKind; + title?: string; + target?: string; + summary?: string; + preview?: string; + stats?: Record; + truncated?: boolean; +} & ToolResultDisplay; + +export type ToolStreamState = ToolStreamChunk; + export type ToolCallState = { id: string; name: string; + target?: string; status: ToolCallStatus; input?: unknown; output?: unknown; + result?: ToolResult; error?: string; errorCode?: string; errorData?: unknown; description?: string; summary?: string; + display?: ToolResultDisplayState; + streams?: ToolStreamState[]; createdAt: number; order: number; startedAt?: number; @@ -118,9 +151,13 @@ export type ImagePreviewState = { export type ChangedFileState = { path: string; + oldPath?: string; beforeContent?: string; afterContent?: string; status: "created" | "modified" | "deleted"; + diff?: string; + addedLines?: number; + removedLines?: number; }; export type ChangeSetState = { diff --git a/src/cli/utils/diff.ts b/src/cli/utils/diff.ts index 864b5cd..70f3c0e 100644 --- a/src/cli/utils/diff.ts +++ b/src/cli/utils/diff.ts @@ -2,9 +2,23 @@ import {createTwoFilesPatch} from "diff"; import type {ChangedFileState} from "../types.js"; -const MAX_DIFF_LINES = 80; +const MAX_DIFF_LINES = 120; -export function createFileDiff(file: ChangedFileState): string | undefined { +export type FileChangeKind = "created" | "edited" | "deleted" | "renamed"; + +export type FileChangeViewModel = { + kind: FileChangeKind; + filePath: string; + oldPath?: string; + addedLines?: number; + removedLines?: number; + diff?: string; +}; + +export function createFileDiff( + file: ChangedFileState, + options: {maxLines?: number} = {}, +): string | undefined { const before = file.beforeContent ?? ""; const after = file.afterContent ?? ""; @@ -22,17 +36,62 @@ export function createFileDiff(file: ChangedFileState): string | undefined { {context: 3}, ); - return truncateDiff(diff); + return truncateDiff(diff, options.maxLines ?? MAX_DIFF_LINES); +} + +export function createFileChangeViewModel(file: ChangedFileState): FileChangeViewModel { + const diff = file.diff ?? createFileDiff(file); + const countableDiff = + file.diff ?? createFileDiff(file, {maxLines: Number.POSITIVE_INFINITY}); + const counts = countableDiff ? countDiffLines(countableDiff) : undefined; + + return { + kind: getFileChangeKind(file.status), + filePath: file.path, + oldPath: file.oldPath, + addedLines: file.addedLines ?? counts?.addedLines, + removedLines: file.removedLines ?? counts?.removedLines, + diff, + }; +} + +export function countDiffLines(diff: string): { + addedLines: number; + removedLines: number; +} { + return diff.split(/\r?\n/).reduce( + (counts, line) => { + if (line.startsWith("+") && !line.startsWith("+++")) { + counts.addedLines += 1; + } else if (line.startsWith("-") && !line.startsWith("---")) { + counts.removedLines += 1; + } + + return counts; + }, + {addedLines: 0, removedLines: 0}, + ); +} + +function getFileChangeKind(status: ChangedFileState["status"]): FileChangeKind { + switch (status) { + case "created": + return "created"; + case "modified": + return "edited"; + case "deleted": + return "deleted"; + } } -function truncateDiff(diff: string): string { +function truncateDiff(diff: string, maxLines: number): string { const lines = diff.trimEnd().split("\n"); - if (lines.length <= MAX_DIFF_LINES) { + if (lines.length <= maxLines) { return lines.join("\n"); } return [ - ...lines.slice(0, MAX_DIFF_LINES), - `... truncated ${lines.length - MAX_DIFF_LINES} diff lines`, + ...lines.slice(0, maxLines), + `... diff truncated, showing first ${maxLines} lines`, ].join("\n"); } diff --git a/src/cli/utils/markdown.ts b/src/cli/utils/markdown.ts index 9ac62c8..95106ca 100644 --- a/src/cli/utils/markdown.ts +++ b/src/cli/utils/markdown.ts @@ -44,7 +44,7 @@ export function parseMarkdown(markdown: string): MarkdownBlock[] { continue; } - const fence = line.match(/^```(\S+)?\s*$/); + const fence = line.match(/^```\s*([^\s`]*)?.*$/); if (fence) { const codeLines: string[] = []; index += 1; @@ -58,7 +58,7 @@ export function parseMarkdown(markdown: string): MarkdownBlock[] { } blocks.push({ type: "code", - language: fence[1], + language: normalizeFenceLanguage(fence[1]), code: codeLines.join("\n"), closed, }); @@ -153,6 +153,11 @@ export function parseMarkdown(markdown: string): MarkdownBlock[] { return blocks; } +function normalizeFenceLanguage(language: string | undefined): string | undefined { + const normalized = language?.trim().toLowerCase(); + return normalized ? normalized : undefined; +} + function looksLikeTableLine(line: string): boolean { const trimmed = line.trim(); return trimmed.includes("|") && trimmed.split("|").length >= 3; diff --git a/src/cli/utils/theme.ts b/src/cli/utils/theme.ts index 041f181..45d81c0 100644 --- a/src/cli/utils/theme.ts +++ b/src/cli/utils/theme.ts @@ -18,7 +18,7 @@ export const icons = { tool: "◇", running: "●", done: "✓", - error: "!", + error: "✕", cursor: "▌", image: "▧", rail: "│", diff --git a/src/events/event-bus.ts b/src/events/event-bus.ts index 23a99ea..0d748da 100644 --- a/src/events/event-bus.ts +++ b/src/events/event-bus.ts @@ -10,6 +10,53 @@ type AgentStage = "thinking" | "planning" | "executing" | "complete"; type ToolCallStatus = "pending" | "running" | "success" | "done" | "error"; +type ToolResultDisplay = { + kind?: "command" | "file" | "edit" | "search" | "list" | "network" | "text" | "json"; + title?: string; + target?: string; + summary?: string; + preview?: string; + stats?: Record; + truncated?: boolean; +}; + +type ToolStreamChunk = + | { + type: "stdout" | "stderr" | "log"; + content: string; + level?: "debug" | "info" | "warn" | "error"; + metadata?: Record; + } + | { + type: "data"; + data?: unknown; + content?: string; + metadata?: Record; + } + | { + type: "progress"; + label?: string; + current?: number; + total?: number; + percent?: number; + metadata?: Record; + }; + +type ToolSuccessResult = { + ok: true; + message: string; + data: unknown; + display?: ToolResultDisplay; +}; + +type ToolErrorResult = { + ok: false; + message: string; + code: string; + data?: unknown; + display?: ToolResultDisplay; +}; + type EventChangedFileStatus = "created" | "modified" | "deleted"; type EventChangedFile = { @@ -78,22 +125,37 @@ type AgentEvent = | (BaseEvent<"tool.call_started"> & { id: ToolCallId | string; name: string; + target?: string; input?: unknown; description?: string; + display?: ToolResultDisplay; status?: Extract; }) | (BaseEvent<"tool.call_completed"> & { id: ToolCallId | string; name: string; + result?: ToolSuccessResult; + durationMs?: number; + target?: string; output?: unknown; summary?: string; + display?: ToolResultDisplay; }) | (BaseEvent<"tool.call_failed"> & { id: ToolCallId | string; name: string; + result?: ToolErrorResult; + durationMs?: number; + target?: string; error: string; code?: string; data?: unknown; + display?: ToolResultDisplay; + }) + | (BaseEvent<"tool.call_stream"> & { + id: ToolCallId | string; + name: string; + stream: ToolStreamChunk; }) | (BaseEvent<"runtime.status_changed"> & { status: "idle" | "running" | "waiting" | "complete" | "error"; diff --git a/src/tool/bash/bash.tool.ts b/src/tool/bash/bash.tool.ts index ed6b562..4ebba52 100644 --- a/src/tool/bash/bash.tool.ts +++ b/src/tool/bash/bash.tool.ts @@ -4,7 +4,7 @@ import {z} from "zod"; import {createCommandPolicy} from "../../runtime/index.js"; import {ToolError} from "../tool-error.js"; import {errorToolResult, okToolResult} from "../tool-result.js"; -import type {Tool} from "../types.js"; +import type {Tool, ToolStreamChunk} from "../types.js"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_LENGTH = 20_000; @@ -103,18 +103,37 @@ export const bashTool: Tool = { const result = await runShellCommand({ command: input.command, cwd: context.workspaceRoot, + emitStream: context.emitStream, maxOutputLength, signal: context.signal, timeoutMs, }); - return okToolResult("Executed shell command.", result); + const preferredOutput = result.stderr || result.stdout; + + return okToolResult("Executed shell command.", result, { + kind: "command", + title: input.command, + target: input.command, + summary: `exit ${result.exitCode ?? "unknown"}${result.timedOut ? " · timed out" : ""}`, + preview: preferredOutput, + stats: { + exitCode: result.exitCode ?? "null", + stdoutBytes: result.stdout.length, + stderrBytes: result.stderr.length, + timedOut: result.timedOut, + }, + truncated: + result.stdout.length >= maxOutputLength || + result.stderr.length >= maxOutputLength, + }); }, }; type RunShellCommandInput = { command: string; cwd: string; + emitStream?: (chunk: ToolStreamChunk) => void | Promise; maxOutputLength: number; signal?: AbortSignal; timeoutMs: number; @@ -152,10 +171,12 @@ async function runShellCommand(input: RunShellCommandInput): Promise child.stdout?.on("data", (chunk: string) => { // Bound output so a noisy command cannot flood the model context. stdout = appendLimited(stdout, chunk, input.maxOutputLength); + void input.emitStream?.({type: "stdout", content: chunk}); }); child.stderr?.on("data", (chunk: string) => { stderr = appendLimited(stderr, chunk, input.maxOutputLength); + void input.emitStream?.({type: "stderr", content: chunk}); }); child.on("error", (error) => { diff --git a/src/tool/fs/edit-file.tool.ts b/src/tool/fs/edit-file.tool.ts index 80851a8..a7b81ff 100644 --- a/src/tool/fs/edit-file.tool.ts +++ b/src/tool/fs/edit-file.tool.ts @@ -71,11 +71,26 @@ export const editFileTool: Tool< ? await context.fileWriter.writeFile(safePath.relativePath, next) : await fallbackWrite(context, safePath.relativePath, next); - return okToolResult("Edited file content.", { - path: written.path, - replacements: input.replaceAll ? count : 1, - bytesWritten: written.bytesWritten, - }); + const replacements = input.replaceAll ? count : 1; + + return okToolResult( + "Edited file content.", + { + path: written.path, + replacements, + bytesWritten: written.bytesWritten, + }, + { + kind: "edit", + title: written.path, + target: written.path, + summary: `${replacements} ${replacements === 1 ? "replacement" : "replacements"}`, + stats: { + replacements, + bytesWritten: written.bytesWritten, + }, + }, + ); }, }; diff --git a/src/tool/fs/glob-tool.ts b/src/tool/fs/glob-tool.ts index 07f7170..3d0bb76 100644 --- a/src/tool/fs/glob-tool.ts +++ b/src/tool/fs/glob-tool.ts @@ -59,7 +59,21 @@ export const globTool: Tool = { context.signal, ); - return okToolResult("Listed workspace files.", {paths}); + return okToolResult( + "Listed workspace files.", + {paths}, + { + kind: "list", + title: input.pattern ?? "workspace files", + target: input.pattern ?? "workspace files", + summary: `${paths.length} ${paths.length === 1 ? "file" : "files"}`, + preview: paths.slice(0, 20).join("\n"), + stats: { + files: paths.length, + }, + truncated: paths.length >= maxResults, + }, + ); }, }; diff --git a/src/tool/fs/grep-tool.ts b/src/tool/fs/grep-tool.ts index a454c8a..e9b87da 100644 --- a/src/tool/fs/grep-tool.ts +++ b/src/tool/fs/grep-tool.ts @@ -63,7 +63,24 @@ export const grepTool: Tool = { context.signal, )); - return okToolResult("Searched workspace file contents.", {matches}); + return okToolResult( + "Searched workspace file contents.", + {matches}, + { + kind: "search", + title: input.pattern, + target: input.pattern, + summary: `${matches.length} ${matches.length === 1 ? "match" : "matches"}`, + preview: matches + .slice(0, 20) + .map((match) => `${match.path}:${match.line}: ${match.text}`) + .join("\n"), + stats: { + matches: matches.length, + }, + truncated: matches.length >= maxResults, + }, + ); }, }; diff --git a/src/tool/fs/read-file.tool.ts b/src/tool/fs/read-file.tool.ts index 921287b..b4f4580 100644 --- a/src/tool/fs/read-file.tool.ts +++ b/src/tool/fs/read-file.tool.ts @@ -38,14 +38,44 @@ export const readFileTool: Tool< const safePath = resolveWorkspacePath(context.workspaceRoot, input.path); throwIfAborted(context, "read_file"); const content = await readFile(safePath.absolutePath, "utf8"); + const lineCount = countLines(content); - return okToolResult("Read file content.", { - path: safePath.relativePath, - content, - }); + return okToolResult( + "Read file content.", + { + path: safePath.relativePath, + content, + }, + { + kind: "file", + title: safePath.relativePath, + target: safePath.relativePath, + summary: `${lineCount} ${lineCount === 1 ? "line" : "lines"} · ${formatBytes(Buffer.byteLength(content, "utf8"))}`, + stats: { + lines: lineCount, + bytes: Buffer.byteLength(content, "utf8"), + }, + }, + ); }, }; +function countLines(content: string): number { + if (!content) { + return 0; + } + + return content.split(/\r?\n/).length; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + return `${(bytes / 1024).toFixed(1)} KB`; +} + /** Ensures this run granted read access to workspace files. */ function requireReadPermission(context: ToolContext, toolName: string): void { if (!context.permissions?.readFile) { diff --git a/src/tool/fs/write-file.tool.ts b/src/tool/fs/write-file.tool.ts index 26f1c38..b3b21af 100644 --- a/src/tool/fs/write-file.tool.ts +++ b/src/tool/fs/write-file.tool.ts @@ -45,7 +45,15 @@ export const writeFileTool: Tool< throwIfAborted(context, "write_file"); const result = await context.fileWriter.writeFile(input.path, input.content); - return okToolResult("Wrote file content.", result); + return okToolResult("Wrote file content.", result, { + kind: "file", + title: result.path, + target: result.path, + summary: `wrote ${formatBytes(result.bytesWritten)}`, + stats: { + bytesWritten: result.bytesWritten, + }, + }); } const safePath = resolveWorkspacePath(context.workspaceRoot, input.path); @@ -54,13 +62,35 @@ export const writeFileTool: Tool< throwIfAborted(context, "write_file"); await writeFile(safePath.absolutePath, input.content, "utf8"); - return okToolResult("Wrote file content.", { - path: safePath.relativePath, - bytesWritten: Buffer.byteLength(input.content, "utf8"), - }); + const bytesWritten = Buffer.byteLength(input.content, "utf8"); + + return okToolResult( + "Wrote file content.", + { + path: safePath.relativePath, + bytesWritten, + }, + { + kind: "file", + title: safePath.relativePath, + target: safePath.relativePath, + summary: `wrote ${formatBytes(bytesWritten)}`, + stats: { + bytesWritten, + }, + }, + ); }, }; +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + return `${(bytes / 1024).toFixed(1)} KB`; +} + /** Ensures this run granted write access to workspace files. */ function requireWritePermission(context: ToolContext, toolName: string): void { if (!context.permissions?.writeFile) { diff --git a/src/tool/index.ts b/src/tool/index.ts index 4547833..88e2dbe 100644 --- a/src/tool/index.ts +++ b/src/tool/index.ts @@ -21,6 +21,7 @@ export {toLLMToolParametersSchema} from "./tool-parameters.js"; export {ToolError, toToolError} from "./tool-error.js"; export {ToolRegistry} from "./tool-registry.js"; export {errorToolResult, okToolResult} from "./tool-result.js"; +export {inferToolTarget} from "./tool-target.js"; export {ToolRunner} from "./tool-runner.js"; export { DEFAULT_WEB_FETCH_MAX_LENGTH, @@ -45,6 +46,9 @@ export type { ToolFileWriter, ToolParameterSchema, ToolPermissions, + ToolResultDisplay, + ToolResultDisplayKind, + ToolStreamChunk, } from "./types.js"; /** Creates the standard tool registry used by Agent when no custom registry is supplied. */ diff --git a/src/tool/tool-result.ts b/src/tool/tool-result.ts index b05a265..58a3fa1 100644 --- a/src/tool/tool-result.ts +++ b/src/tool/tool-result.ts @@ -4,11 +4,13 @@ import type {ToolErrorResult, ToolResult, ToolSuccessResult} from "./types.js"; export function okToolResult( message: string, data: TData, + display?: ToolSuccessResult["display"], ): ToolSuccessResult { return { ok: true, message, data, + ...(display === undefined ? {} : {display}), }; } @@ -17,12 +19,14 @@ export function errorToolResult( message: string, code: string, data?: TData, + display?: ToolErrorResult["display"], ): ToolErrorResult { return { ok: false, message, code, ...(data === undefined ? {} : {data}), + ...(display === undefined ? {} : {display}), }; } diff --git a/src/tool/tool-runner-types.ts b/src/tool/tool-runner-types.ts index d1f9b81..dc9c58e 100644 --- a/src/tool/tool-runner-types.ts +++ b/src/tool/tool-runner-types.ts @@ -1,4 +1,4 @@ -import type {ToolResult} from "./types.js"; +import type {ToolResult, ToolStreamChunk} from "./types.js"; /** Constructor-level options that configure ToolRunner behavior for all calls. */ export type ToolRunnerOptions = { @@ -44,6 +44,11 @@ export type ToolRunnerEvent = type: "runner.tool.started"; input?: unknown; }) + /** Emitted when a running tool has incremental output available for display. */ + | (ToolRunnerEventBase & { + type: "runner.tool.streamed"; + stream: ToolStreamChunk; + }) /** Emitted after a tool returns a successful ToolResult. */ | (ToolRunnerTerminalEventBase & { type: "runner.tool.completed"; diff --git a/src/tool/tool-runner.ts b/src/tool/tool-runner.ts index f20062e..2953056 100644 --- a/src/tool/tool-runner.ts +++ b/src/tool/tool-runner.ts @@ -125,6 +125,16 @@ export class ToolRunner { const executionContext: ToolContext = { ...context, signal: control.signal, + emitStream: (stream) => + this.emitEvent({ + type: "runner.tool.streamed", + callId, + toolName: name, + startedAt, + timeoutMs, + metadata: options.metadata, + stream, + }), }; const outcome = await Promise.race([ Promise.resolve(tool.execute(parsedInput.data, executionContext)), @@ -319,11 +329,19 @@ function toTerminalEvent( event: Omit< Extract< ToolRunnerEvent, - {type: Exclude} + { + type: Exclude< + ToolRunnerEvent["type"], + "runner.tool.started" | "runner.tool.streamed" + >; + } >, "type" | "errorCode" >, -): Exclude { +): Exclude< + ToolRunnerEvent, + {type: "runner.tool.started"} | {type: "runner.tool.streamed"} +> { if (event.result.ok) { return { ...event, diff --git a/src/tool/tool-target.ts b/src/tool/tool-target.ts new file mode 100644 index 0000000..96ba75f --- /dev/null +++ b/src/tool/tool-target.ts @@ -0,0 +1,68 @@ +/** Derives a short human-readable object a tool call acts on. */ +export function inferToolTarget( + toolName: string, + ...candidates: unknown[] +): string | undefined { + for (const candidate of candidates) { + const target = inferTargetFromValue(toolName, candidate); + if (target) { + return target; + } + } + + return undefined; +} + +function inferTargetFromValue(toolName: string, value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) { + return value; + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + + for (const key of getTargetKeys(toolName)) { + const target = getStringValue(record[key]); + if (target) { + return target; + } + } + + return undefined; +} + +function getTargetKeys(toolName: string): readonly string[] { + switch (toolName) { + case "bash": + return ["target", "command", "title", "cwd"]; + case "read_file": + case "write_file": + case "edit_file": + return ["target", "path", "title"]; + case "grep": + return ["target", "pattern", "path", "title"]; + case "glob": + return ["target", "pattern", "path", "title"]; + case "web_fetch": + return ["target", "url", "finalUrl", "requestedUrl", "title"]; + default: + return [ + "target", + "path", + "file", + "directory", + "command", + "pattern", + "query", + "url", + "title", + ]; + } +} + +function getStringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} diff --git a/src/tool/types.ts b/src/tool/types.ts index 88afbe5..5b2c7b3 100644 --- a/src/tool/types.ts +++ b/src/tool/types.ts @@ -6,6 +6,7 @@ export type ToolSuccessResult = { ok: true; message: string; data: TData; + display?: ToolResultDisplay; }; /** Error result returned by a tool when the failure should be reported to the model. */ @@ -14,11 +15,56 @@ export type ToolErrorResult = { message: string; code: string; data?: TData; + display?: ToolResultDisplay; }; /** Normalized result shape produced by every tool implementation. */ export type ToolResult = ToolSuccessResult | ToolErrorResult; +export type ToolResultDisplayKind = + | "command" + | "file" + | "edit" + | "search" + | "list" + | "network" + | "text" + | "json"; + +/** Optional UI-oriented metadata tools can return without changing their data contract. */ +export type ToolResultDisplay = { + kind?: ToolResultDisplayKind; + title?: string; + target?: string; + summary?: string; + preview?: string; + stats?: Record; + truncated?: boolean; +}; + +/** Incremental output chunk emitted while a tool is still running. */ +export type ToolStreamChunk = + | { + type: "stdout" | "stderr" | "log"; + content: string; + level?: "debug" | "info" | "warn" | "error"; + metadata?: Record; + } + | { + type: "data"; + data?: unknown; + content?: string; + metadata?: Record; + } + | { + type: "progress"; + label?: string; + current?: number; + total?: number; + percent?: number; + metadata?: Record; + }; + /** Zod schema used to validate model-provided tool input before execution. */ export type ToolParameterSchema = z.ZodTypeAny; @@ -53,6 +99,7 @@ export type ToolContext = { fileWriter?: ToolFileWriter; workspaceProfile?: WorkspaceProfile; commandPolicy?: CommandPolicyLike; + emitStream?: (chunk: ToolStreamChunk) => void | Promise; }; /** Implementation function for a tool after ToolRunner validates its input. */ diff --git a/src/tool/web/web-fetch.tool.ts b/src/tool/web/web-fetch.tool.ts index 326499b..66fbc92 100644 --- a/src/tool/web/web-fetch.tool.ts +++ b/src/tool/web/web-fetch.tool.ts @@ -96,12 +96,28 @@ export const webFetchTool: Tool = const {text, truncated} = await readResponseTextWithLimit(response, maxLength); - return okToolResult("Fetched webpage text.", { - ...details, - text, - truncated, - maxLength, - }); + return okToolResult( + "Fetched webpage text.", + { + ...details, + text, + truncated, + maxLength, + }, + { + kind: "network", + title: details.finalUrl, + target: details.finalUrl, + summary: `${details.status} ${details.statusText || "OK"} · ${details.contentType ?? "unknown content type"}`, + preview: text, + stats: { + status: details.status, + characters: text.length, + maxLength, + }, + truncated, + }, + ); } catch (error) { throw normalizeFetchError(error, { requestedUrl, diff --git a/tests/agent/runtime-loop.test.ts b/tests/agent/runtime-loop.test.ts index 7117382..3b2c0ca 100644 --- a/tests/agent/runtime-loop.test.ts +++ b/tests/agent/runtime-loop.test.ts @@ -197,6 +197,11 @@ describe("Agent runtime loop", () => { type: "tool.call_completed", id: "call-1", name: "echo", + result: { + ok: true, + message: "Echoed.", + data: {text: "hello"}, + }, output: {text: "hello"}, summary: "Echoed.", }); @@ -244,6 +249,12 @@ describe("Agent runtime loop", () => { type: "tool.call_failed", id: "fail-1", name: "fail", + result: { + ok: false, + message: "Tool failed.", + code: "TOOL_EXECUTION_FAILED", + data: {x: 1}, + }, error: "Tool failed.", code: "TOOL_EXECUTION_FAILED", data: {x: 1}, @@ -300,6 +311,11 @@ describe("Agent runtime loop", () => { expect(collectToolEvents(events)[1]).toMatchObject({ type: "tool.call_completed", id: "mw-1", + result: { + ok: true, + message: "Updated.", + data: {text: "updated"}, + }, output: {text: "updated"}, summary: "Updated.", }); diff --git a/tests/cli-diff.test.ts b/tests/cli-diff.test.ts index 24c5c72..191b194 100644 --- a/tests/cli-diff.test.ts +++ b/tests/cli-diff.test.ts @@ -1,6 +1,10 @@ import {describe, expect, it} from "vitest"; -import {createFileDiff} from "../src/cli/utils/diff.js"; +import { + countDiffLines, + createFileChangeViewModel, + createFileDiff, +} from "../src/cli/utils/diff.js"; describe("createFileDiff", () => { it("creates a readable diff for modified files", () => { @@ -27,4 +31,48 @@ describe("createFileDiff", () => { }), ).toBeUndefined(); }); + + it("creates a file change view model with line counts", () => { + expect( + createFileChangeViewModel({ + path: "src/app.ts", + status: "modified", + beforeContent: "const value = 1;\n", + afterContent: "const value = 2;\nconst next = 3;\n", + }), + ).toMatchObject({ + kind: "edited", + filePath: "src/app.ts", + addedLines: 2, + removedLines: 1, + }); + }); + + it("counts diff lines without file headers", () => { + expect( + countDiffLines( + [ + "--- src/app.ts (before)", + "+++ src/app.ts (after)", + "@@ -1 +1 @@", + "-const value = 1;", + "+const value = 2;", + ].join("\n"), + ), + ).toEqual({addedLines: 1, removedLines: 1}); + }); + + it("truncates large diffs with a clear notice", () => { + const diff = createFileDiff( + { + path: "src/app.ts", + status: "modified", + beforeContent: Array.from({length: 20}, (_, index) => `old ${index}`).join("\n"), + afterContent: Array.from({length: 20}, (_, index) => `new ${index}`).join("\n"), + }, + {maxLines: 8}, + ); + + expect(diff).toContain("... diff truncated, showing first 8 lines"); + }); }); diff --git a/tests/cli-markdown.test.ts b/tests/cli-markdown.test.ts index ff28098..08b1856 100644 --- a/tests/cli-markdown.test.ts +++ b/tests/cli-markdown.test.ts @@ -1,5 +1,11 @@ import {describe, expect, it} from "vitest"; +import { + formatGutter, + getLineNumberWidth, + normalizeCodeLanguage, + splitCodeLines, +} from "../src/cli/components/markdown/CodeBlock.js"; import {parseMarkdown} from "../src/cli/utils/markdown.js"; describe("parseMarkdown", () => { @@ -46,4 +52,59 @@ describe("parseMarkdown", () => { {type: "code", language: "ts", code: "const value = 1;", closed: false}, ]); }); + + it("normalizes fenced code block languages", () => { + expect( + parseMarkdown("```TSX title=App\nexport function App() {}\n```"), + ).toMatchObject([ + { + type: "code", + language: "tsx", + code: "export function App() {}", + closed: true, + }, + ]); + }); + + it("parses diff fences", () => { + expect(parseMarkdown("```diff\n-a\n+b\n```")).toMatchObject([ + {type: "code", language: "diff", code: "-a\n+b", closed: true}, + ]); + }); + + it("preserves code block indentation", () => { + expect( + parseMarkdown("```ts\n const value = 1;\n return value;\n```"), + ).toMatchObject([ + { + type: "code", + language: "ts", + code: " const value = 1;\n return value;", + closed: true, + }, + ]); + }); +}); + +describe("CodeBlock formatting helpers", () => { + it("right-aligns line number gutters", () => { + expect(getLineNumberWidth(9)).toBe(2); + expect(getLineNumberWidth(120)).toBe(3); + expect(formatGutter(1, 2, true)).toBe(" 1 │ "); + expect(formatGutter(12, 2, true)).toBe("12 │ "); + expect(formatGutter(120, 3, true)).toBe("120 │ "); + }); + + it("splits code lines without trimming indentation", () => { + expect(splitCodeLines(" const value = 1;\n return value;")).toEqual([ + " const value = 1;", + " return value;", + ]); + }); + + it("normalizes code languages without requiring a known language", () => { + expect(normalizeCodeLanguage(" TS ")).toBe("ts"); + expect(normalizeCodeLanguage("unknown-lang")).toBe("unknown-lang"); + expect(normalizeCodeLanguage(undefined)).toBeUndefined(); + }); }); diff --git a/tests/cli-runtime-events.test.ts b/tests/cli-runtime-events.test.ts index 47b91e0..a047c09 100644 --- a/tests/cli-runtime-events.test.ts +++ b/tests/cli-runtime-events.test.ts @@ -92,4 +92,93 @@ describe("agentEventToCliEvent", () => { }, }); }); + + it("maps tool display and stream events", () => { + expect( + agentEventToCliEvent({ + type: "tool.call_completed", + id: "call-1", + name: "read_file", + output: {path: "README.md"}, + summary: "Read file content.", + display: { + title: "README.md", + summary: "20 lines", + }, + }), + ).toMatchObject({ + type: "tool_done", + id: "call-1", + display: { + title: "README.md", + summary: "20 lines", + }, + target: "README.md", + }); + + expect( + agentEventToCliEvent({ + type: "tool.call_stream", + id: "call-1", + name: "bash", + stream: {type: "stderr", content: "warning\n"}, + }), + ).toMatchObject({ + type: "tool_stream", + id: "call-1", + name: "bash", + stream: {type: "stderr", content: "warning\n"}, + }); + }); + + it("derives tool targets from common tool inputs and display data", () => { + expect( + agentEventToCliEvent({ + type: "tool.call_started", + id: "call-bash", + name: "bash", + input: {command: "pnpm test"}, + }), + ).toMatchObject({ + type: "tool_start", + target: "pnpm test", + }); + + expect( + agentEventToCliEvent({ + type: "tool.call_started", + id: "call-read", + name: "read_file", + input: {path: "src/index.ts"}, + }), + ).toMatchObject({ + type: "tool_start", + target: "src/index.ts", + }); + + expect( + agentEventToCliEvent({ + type: "tool.call_completed", + id: "call-grep", + name: "grep", + display: {title: "ToolCallState", summary: "2 matches"}, + }), + ).toMatchObject({ + type: "tool_done", + target: "ToolCallState", + }); + + expect( + agentEventToCliEvent({ + type: "tool.call_failed", + id: "call-fetch", + name: "web_fetch", + error: "Network permission is required.", + data: {requestedUrl: "https://example.com/"}, + }), + ).toMatchObject({ + type: "tool_error", + target: "https://example.com/", + }); + }); }); diff --git a/tests/cli-timeline-order.test.ts b/tests/cli-timeline-order.test.ts index 9322d8f..d151e2d 100644 --- a/tests/cli-timeline-order.test.ts +++ b/tests/cli-timeline-order.test.ts @@ -85,4 +85,135 @@ describe("CLI timeline ordering", () => { }, }); }); + + it("appends tool stream chunks to the existing timeline item", () => { + const state = [ + { + type: "tool_start" as const, + id: "tool-1", + name: "bash", + input: {command: "pnpm test"}, + createdAt: 1, + }, + { + type: "tool_stream" as const, + id: "tool-1", + name: "bash", + stream: {type: "stdout" as const, content: "running\n"}, + createdAt: 2, + }, + { + type: "tool_stream" as const, + id: "tool-1", + name: "bash", + stream: {type: "stderr" as const, content: "warning\n"}, + createdAt: 3, + }, + { + type: "tool_done" as const, + id: "tool-1", + name: "bash", + summary: "Executed shell command.", + display: {summary: "exit 0"}, + createdAt: 4, + }, + ].reduce( + (current, event) => reduceCliState(current, {type: "event", event}), + initialCliState, + ); + + expect(selectTimelineItems(state)).toHaveLength(1); + expect(state.tools[0]).toMatchObject({ + status: "success", + display: {summary: "exit 0"}, + streams: [ + {type: "stdout", content: "running\n"}, + {type: "stderr", content: "warning\n"}, + ], + }); + }); + + it("reduces direct Pixelle tool events without the CliEvent adapter", () => { + const state = [ + { + type: "tool.call_started" as const, + id: "tool-1", + name: "grep", + input: {pattern: "ToolResult"}, + createdAt: 1, + }, + { + type: "tool.call_stream" as const, + id: "tool-1", + name: "grep", + stream: {type: "progress" as const, label: "searching", percent: 50}, + createdAt: 2, + }, + { + type: "tool.call_completed" as const, + id: "tool-1", + name: "grep", + result: { + ok: true as const, + message: "Searched workspace file contents.", + data: {matches: []}, + display: { + kind: "search" as const, + target: "ToolResult", + summary: "0 matches", + }, + }, + durationMs: 12, + createdAt: 3, + }, + ].reduce( + (current, event) => reduceCliState(current, {type: "event", event}), + initialCliState, + ); + + expect(selectTimelineItems(state)).toHaveLength(1); + expect(state.tools[0]).toMatchObject({ + status: "success", + target: "ToolResult", + result: { + ok: true, + message: "Searched workspace file contents.", + }, + display: { + kind: "search", + target: "ToolResult", + summary: "0 matches", + }, + durationMs: 12, + streams: [{type: "progress", label: "searching", percent: 50}], + }); + }); + + it("stores the tool target across the tool lifecycle", () => { + const state = [ + { + type: "tool_start" as const, + id: "tool-1", + name: "read_file", + target: "src/cli/types.ts", + input: {path: "src/cli/types.ts"}, + createdAt: 1, + }, + { + type: "tool_done" as const, + id: "tool-1", + name: "read_file", + summary: "Read file content.", + createdAt: 2, + }, + ].reduce( + (current, event) => reduceCliState(current, {type: "event", event}), + initialCliState, + ); + + expect(state.tools[0]).toMatchObject({ + status: "success", + target: "src/cli/types.ts", + }); + }); }); diff --git a/tests/tool/bash-policy.test.ts b/tests/tool/bash-policy.test.ts index d1c1b9c..35d4899 100644 --- a/tests/tool/bash-policy.test.ts +++ b/tests/tool/bash-policy.test.ts @@ -53,6 +53,42 @@ describe("bashTool policy integration", () => { expect(result.ok ? result.data.stdout : "").toContain("policy-ok"); }); + it("streams stdout and stderr while executing commands", async () => { + const streams: Array<{type: string; content: string}> = []; + const result = await bashTool.execute( + { + reason: "test", + command: "node -e \"process.stdout.write('out'); process.stderr.write('err')\"", + }, + { + workspaceRoot: process.cwd(), + workspaceProfile: profile, + commandPolicy: policy({ + effect: "allow", + allowed: true, + risk: "low", + category: "verification", + ruleId: "test-allow", + reason: "Allowed by test.", + }), + emitStream: (chunk) => { + streams.push(chunk); + }, + }, + ); + + expect(result).toMatchObject({ + ok: true, + display: {summary: "exit 0"}, + }); + expect(streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({type: "stdout", content: "out"}), + expect.objectContaining({type: "stderr", content: "err"}), + ]), + ); + }); + it("returns structured approval results without executing ask decisions", async () => { const result = await bashTool.execute( {reason: "test", command: "node -e \"throw new Error('should not run')\""}, diff --git a/tests/tool/tool-registry.test.ts b/tests/tool/tool-registry.test.ts index dc68337..caa6566 100644 --- a/tests/tool/tool-registry.test.ts +++ b/tests/tool/tool-registry.test.ts @@ -241,6 +241,47 @@ describe("ToolRunner", () => { }); }); + it("emits stream events from tool context", async () => { + const registry = new ToolRegistry(); + registry.register({ + definition: { + name: "streamer", + description: "Streams output.", + parameters: z.object({}), + }, + execute: async (_input, context) => { + await context.emitStream?.({type: "stdout", content: "hello\n"}); + await context.emitStream?.({type: "stderr", content: "warn\n"}); + + return okToolResult("Streamed.", {ok: true}, {summary: "done"}); + }, + }); + const events: ToolRunnerEvent[] = []; + + const result = await new ToolRunner(registry, { + createCallId: () => "call-1", + onEvent: (event) => { + events.push(event); + }, + }).run("streamer", {}, {workspaceRoot: process.cwd()}); + + expect(result).toMatchObject({ok: true, display: {summary: "done"}}); + expect(events.map((event) => event.type)).toEqual([ + "runner.tool.started", + "runner.tool.streamed", + "runner.tool.streamed", + "runner.tool.completed", + ]); + expect(events[1]).toMatchObject({ + type: "runner.tool.streamed", + callId: "call-1", + stream: {type: "stdout", content: "hello\n"}, + }); + expect(events[2]).toMatchObject({ + stream: {type: "stderr", content: "warn\n"}, + }); + }); + it("returns TOOL_TIMEOUT when the runner timeout wins", async () => { const registry = new ToolRegistry(); registry.register({