feat(HAI-082): display tool call details in AgentLogViewer
- Extend AgentLogEntry type with optional detail field for tool metadata - Thread tool args through engine executor callbacks to populate detail - Render tool detail (name, args) in AgentLogViewer component - Add unit tests for store, executor, and AgentLogViewer changes
This commit is contained in:
@@ -472,6 +472,30 @@ describe("TaskStore", () => {
|
||||
expect(events[0].taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it("appendAgentLog writes detail when provided", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await store.appendAgentLog(task.id, "Bash", "tool", "ls -la");
|
||||
await store.appendAgentLog(task.id, "Read", "tool", "packages/core/src/types.ts");
|
||||
await store.appendAgentLog(task.id, "some text", "text");
|
||||
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toHaveLength(3);
|
||||
expect(logs[0].detail).toBe("ls -la");
|
||||
expect(logs[1].detail).toBe("packages/core/src/types.ts");
|
||||
expect(logs[2].detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appendAgentLog omits detail field when not provided", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await store.appendAgentLog(task.id, "Bash", "tool");
|
||||
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0]).not.toHaveProperty("detail");
|
||||
});
|
||||
|
||||
it("handles multiple appends correctly (JSONL format)", async () => {
|
||||
const task = await createTestTask();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
|
||||
@@ -849,13 +849,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* @param taskId - The task ID (e.g. "HAI-001")
|
||||
* @param text - The text content (delta for "text", tool name for "tool")
|
||||
* @param type - Whether this is a "text" delta or a "tool" invocation marker
|
||||
* @param detail - Optional human-readable summary of tool args (e.g. file path, command)
|
||||
*/
|
||||
async appendAgentLog(taskId: string, text: string, type: "text" | "tool"): Promise<void> {
|
||||
async appendAgentLog(taskId: string, text: string, type: "text" | "tool", detail?: string): Promise<void> {
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
text,
|
||||
type,
|
||||
...(detail !== undefined && { detail }),
|
||||
};
|
||||
const dir = this.taskDir(taskId);
|
||||
const logPath = join(dir, "agent.log");
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface AgentLogEntry {
|
||||
text: string;
|
||||
/** Whether this is a text delta or a tool invocation marker */
|
||||
type: "text" | "tool";
|
||||
/** For tool entries: human-readable summary of tool args (e.g. file path, command) */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface TaskAttachment {
|
||||
|
||||
@@ -79,6 +79,18 @@ export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
|
||||
}}
|
||||
>
|
||||
⚡ {entry.text}
|
||||
{entry.detail && (
|
||||
<span
|
||||
className="agent-log-tool-detail"
|
||||
style={{
|
||||
color: "var(--text-muted, #888)",
|
||||
fontSize: "12px",
|
||||
marginLeft: "6px",
|
||||
}}
|
||||
>
|
||||
— {entry.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} className="agent-log-text">
|
||||
|
||||
@@ -57,6 +57,39 @@ describe("AgentLogViewer", () => {
|
||||
expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders tool entry detail when present", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain("ls -la packages/");
|
||||
});
|
||||
|
||||
it("does not render detail span when detail is absent", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Bash", type: "tool" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeNull();
|
||||
});
|
||||
|
||||
it("renders long detail text without breaking layout", () => {
|
||||
const longDetail = "a/very/long/path/".repeat(10) + "file.ts";
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool", detail: longDetail }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
// Verify the tool div still renders correctly
|
||||
const toolDiv = container.querySelector(".agent-log-tool");
|
||||
expect(toolDiv).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has a monospace font family", () => {
|
||||
const entries = [makeEntry()];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
|
||||
@@ -794,3 +794,44 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(agentPrompt).toContain("- **Build:** `npm run build`");
|
||||
});
|
||||
});
|
||||
|
||||
// Import the summarizeToolArgs helper directly (not affected by mocks above)
|
||||
describe("summarizeToolArgs", () => {
|
||||
// Dynamic import to avoid mock interference
|
||||
let summarizeToolArgs: (name: string, args?: Record<string, unknown>) => string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await vi.importActual<typeof import("./executor.js")>("./executor.js");
|
||||
summarizeToolArgs = mod.summarizeToolArgs;
|
||||
});
|
||||
|
||||
it("returns command for bash tool", () => {
|
||||
expect(summarizeToolArgs("Bash", { command: "ls -la" })).toBe("ls -la");
|
||||
expect(summarizeToolArgs("bash", { command: "echo hello" })).toBe("echo hello");
|
||||
});
|
||||
|
||||
it("truncates long bash commands to 80 chars", () => {
|
||||
const longCmd = "a".repeat(100);
|
||||
const result = summarizeToolArgs("Bash", { command: longCmd });
|
||||
expect(result).toBe("a".repeat(80) + "…");
|
||||
});
|
||||
|
||||
it("returns path for read/edit/write tools", () => {
|
||||
expect(summarizeToolArgs("Read", { path: "src/types.ts" })).toBe("src/types.ts");
|
||||
expect(summarizeToolArgs("edit", { path: "src/store.ts" })).toBe("src/store.ts");
|
||||
expect(summarizeToolArgs("Write", { path: "out.txt", content: "data" })).toBe("out.txt");
|
||||
});
|
||||
|
||||
it("returns first string arg for unknown tools", () => {
|
||||
expect(summarizeToolArgs("task_update", { step: 1, status: "done" })).toBe("done");
|
||||
});
|
||||
|
||||
it("returns undefined when no args provided", () => {
|
||||
expect(summarizeToolArgs("Bash")).toBeUndefined();
|
||||
expect(summarizeToolArgs("Bash", {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no string args found", () => {
|
||||
expect(summarizeToolArgs("unknown", { count: 42, flag: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import type { WorktreePool } from "./worktree-pool.js";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
/**
|
||||
* Produce a short human-readable summary from tool arguments.
|
||||
* Returns `undefined` for unknown tools or when no meaningful arg is found.
|
||||
*/
|
||||
export function summarizeToolArgs(name: string, args?: Record<string, unknown>): string | undefined {
|
||||
if (!args) return undefined;
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (lowerName === "bash") {
|
||||
const cmd = args.command;
|
||||
if (typeof cmd === "string") {
|
||||
return cmd.length > 80 ? cmd.slice(0, 80) + "…" : cmd;
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerName === "read" || lowerName === "edit" || lowerName === "write") {
|
||||
const p = args.path;
|
||||
if (typeof p === "string") return p;
|
||||
}
|
||||
|
||||
// Fallback: return first string-valued arg if short enough
|
||||
for (const val of Object.values(args)) {
|
||||
if (typeof val === "string" && val.length <= 80) return val;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
|
||||
|
||||
const taskUpdateParams = Type.Object({
|
||||
@@ -355,12 +383,13 @@ export class TaskExecutor {
|
||||
scheduleFlush();
|
||||
}
|
||||
},
|
||||
onToolStart: (name) => {
|
||||
onToolStart: (name, args) => {
|
||||
this.options.onAgentTool?.(task.id, name);
|
||||
// Flush any pending text before recording the tool entry
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
flushTextBuffer();
|
||||
this.store.appendAgentLog(task.id, name, "tool").catch(() => {});
|
||||
const detail = summarizeToolArgs(name, args);
|
||||
this.store.appendAgentLog(task.id, name, "tool", detail).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function aiMergeTask(
|
||||
systemPrompt: MERGE_SYSTEM_PROMPT,
|
||||
tools: "coding",
|
||||
onText: (delta) => options.onAgentText?.(delta),
|
||||
onToolStart: (name) => options.onAgentTool?.(name),
|
||||
onToolStart: (name, _args) => options.onAgentTool?.(name),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface AgentOptions {
|
||||
tools?: "coding" | "readonly";
|
||||
customTools?: ToolDefinition[];
|
||||
onText?: (delta: string) => void;
|
||||
onToolStart?: (name: string) => void;
|
||||
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
|
||||
onToolEnd?: (name: string, isError: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function createHaiAgent(options: AgentOptions): Promise<AgentResult
|
||||
options.onText?.(event.assistantMessageEvent.delta);
|
||||
}
|
||||
if (event.type === "tool_execution_start") {
|
||||
options.onToolStart?.(event.toolName);
|
||||
options.onToolStart?.(event.toolName, event.args as Record<string, unknown> | undefined);
|
||||
}
|
||||
if (event.type === "tool_execution_end") {
|
||||
options.onToolEnd?.(event.toolName, event.isError);
|
||||
|
||||
@@ -257,7 +257,7 @@ export class TriageProcessor {
|
||||
tools: "coding",
|
||||
customTools: this.createTriageTools(),
|
||||
onText: (delta) => this.options.onAgentText?.(task.id, delta),
|
||||
onToolStart: (name) =>
|
||||
onToolStart: (name, _args) =>
|
||||
console.log(`[triage] ${task.id} tool: ${name}`),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user