import { describe, it, expect, beforeEach, vi } from "vitest";
import { dispatchTools, buildAssistantContent } from "./tool-dispatch.js";
import type { ToolExecutor, ToolDispatchOptions } from "./tool-dispatch.js";
import { LoopDetector } from "./agent-core.js";
import type { ToolUseBlock } from "./types.js";
// ============================================================================
// Helpers
// ============================================================================
function makeToolCall(overrides?: Partial<ToolUseBlock>): ToolUseBlock {
return {
id: overrides?.id ?? `tool_${Math.random().toString(36).slice(2, 8)}`,
name: overrides?.name ?? "test_tool",
input: overrides?.input ?? {},
};
}
function successExecutor(output = "ok"): ToolExecutor {
return async (_name, _input) => ({ success: true, output });
}
function failExecutor(output = "something went wrong"): ToolExecutor {
return async (_name, _input) => ({ success: false, output });
}
function defaultOpts(overrides?: Partial<ToolDispatchOptions>): ToolDispatchOptions {
return {
loopDetector: new LoopDetector(),
...overrides,
};
}
// ============================================================================
// dispatchTools — basic execution
// ============================================================================
describe("dispatchTools", () => {
let detector: LoopDetector;
beforeEach(() => {
detector = new LoopDetector();
});
it("executes a single tool and returns its result", async () => {
const tc = makeToolCall({ id: "t1", name: "read_file" });
const { results, bailOut } = await dispatchTools(
[tc],
successExecutor("file contents here"),
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(1);
expect(results[0].tool_use_id).toBe("t1");
expect(results[0].content).toBe("file contents here");
expect(bailOut).toBe(false);
});
it("returns results in original tool call order", async () => {
const calls = [
makeToolCall({ id: "a", name: "tool_a" }),
makeToolCall({ id: "b", name: "tool_b" }),
makeToolCall({ id: "c", name: "tool_c" }),
];
// Executor adds a random delay to simulate non-deterministic completion
const executor: ToolExecutor = async (name) => {
await new Promise((r) => setTimeout(r, Math.random() * 10));
return { success: true, output: `result_${name}` };
};
const { results } = await dispatchTools(calls, executor, defaultOpts({ loopDetector: detector }));
expect(results).toHaveLength(3);
expect(results[0].tool_use_id).toBe("a");
expect(results[1].tool_use_id).toBe("b");
expect(results[2].tool_use_id).toBe("c");
expect(results[0].content).toBe("result_tool_a");
expect(results[1].content).toBe("result_tool_b");
expect(results[2].content).toBe("result_tool_c");
});
it("handles empty tool call array", async () => {
const { results, bailOut } = await dispatchTools(
[],
successExecutor(),
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(0);
expect(bailOut).toBe(false);
});
it("wraps failed tool results in JSON error format", async () => {
const tc = makeToolCall({ id: "f1", name: "bash" });
const { results } = await dispatchTools(
[tc],
failExecutor("command not found"),
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(1);
const content = results[0].content as string;
const parsed = JSON.parse(content);
expect(parsed.error).toBe("command not found");
});
// ============================================================================
// Error handling — executor throws
// ============================================================================
it("catches executor exceptions and returns error result", async () => {
const tc = makeToolCall({ id: "err1", name: "broken_tool" });
const throwingExecutor: ToolExecutor = async () => {
throw new Error("executor crashed");
};
const { results } = await dispatchTools(
[tc],
throwingExecutor,
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(1);
const content = results[0].content as string;
const parsed = JSON.parse(content);
expect(parsed.error).toContain("executor crashed");
});
it("catches non-Error thrown values", async () => {
const tc = makeToolCall({ id: "err2", name: "weird_tool" });
const throwingExecutor: ToolExecutor = async () => {
throw "string_error";
};
const { results } = await dispatchTools(
[tc],
throwingExecutor,
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(1);
const content = results[0].content as string;
const parsed = JSON.parse(content);
expect(parsed.error).toContain("string_error");
});
// ============================================================================
// Truncation
// ============================================================================
it("truncates results exceeding maxResultChars", async () => {
const tc = makeToolCall({ id: "big1", name: "large_output" });
const longOutput = "x".repeat(50_000);
const { results } = await dispatchTools(
[tc],
successExecutor(longOutput),
defaultOpts({ loopDetector: detector, maxResultChars: 1000 }),
);
const content = results[0].content as string;
expect(content.length).toBeLessThan(longOutput.length);
expect(content).toContain("truncated");
expect(content).toContain("50,000");
});
it("does not truncate results within maxResultChars", async () => {
const tc = makeToolCall({ id: "small1", name: "small_output" });
const { results } = await dispatchTools(
[tc],
successExecutor("short"),
defaultOpts({ loopDetector: detector, maxResultChars: 1000 }),
);
expect(results[0].content).toBe("short");
});
it("uses default maxResultChars of 20000 when not specified", async () => {
const tc = makeToolCall({ id: "def1", name: "output_tool" });
const output = "y".repeat(25_000);
const { results } = await dispatchTools(
[tc],
successExecutor(output),
defaultOpts({ loopDetector: detector }),
);
const content = results[0].content as string;
expect(content).toContain("truncated");
// First 20K chars should be preserved
expect(content.startsWith("y".repeat(20_000))).toBe(true);
});
// ============================================================================
// Image marker handling
// ============================================================================
it("converts __IMAGE__ marker to image content block", async () => {
const tc = makeToolCall({ id: "img1", name: "read_file" });
const imageOutput = "__IMAGE__image/png__iVBORw0KGgoAAAANS";
const { results } = await dispatchTools(
[tc],
successExecutor(imageOutput),
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(1);
const content = results[0].content;
expect(Array.isArray(content)).toBe(true);
const blocks = content as Array<Record<string, unknown>>;
expect(blocks[0].type).toBe("image");
const source = blocks[0].source as Record<string, unknown>;
expect(source.type).toBe("base64");
expect(source.media_type).toBe("image/png");
expect(source.data).toBe("iVBORw0KGgoAAAANS");
});
it("does not convert __IMAGE__ marker for failed tool results", async () => {
const tc = makeToolCall({ id: "img2", name: "read_file" });
const executor: ToolExecutor = async () => ({
success: false,
output: "__IMAGE__image/png__data",
});
const { results } = await dispatchTools(
[tc],
executor,
defaultOpts({ loopDetector: detector }),
);
// Failed tool result should NOT be treated as an image
const content = results[0].content as string;
expect(typeof content).toBe("string");
const parsed = JSON.parse(content);
expect(parsed.error).toContain("__IMAGE__");
});
// ============================================================================
// Parallel execution — batching
// ============================================================================
it("executes tools in parallel up to maxConcurrent", async () => {
const concurrencyTracker: number[] = [];
let concurrent = 0;
const calls = Array.from({ length: 10 }, (_, i) =>
makeToolCall({ id: `p${i}`, name: `tool_${i}`, input: { i } }),
);
const executor: ToolExecutor = async (name) => {
concurrent++;
concurrencyTracker.push(concurrent);
await new Promise((r) => setTimeout(r, 20));
concurrent--;
return { success: true, output: `done_${name}` };
};
const { results } = await dispatchTools(
calls,
executor,
defaultOpts({ loopDetector: detector, maxConcurrent: 3 }),
);
expect(results).toHaveLength(10);
// The maximum observed concurrency should not exceed 3
expect(Math.max(...concurrencyTracker)).toBeLessThanOrEqual(3);
});
it("executes sequentially when maxConcurrent is 1", async () => {
const executionOrder: string[] = [];
const calls = [
makeToolCall({ id: "s1", name: "tool_a", input: { order: 1 } }),
makeToolCall({ id: "s2", name: "tool_b", input: { order: 2 } }),
makeToolCall({ id: "s3", name: "tool_c", input: { order: 3 } }),
];
const executor: ToolExecutor = async (name) => {
executionOrder.push(name);
return { success: true, output: "ok" };
};
await dispatchTools(
calls,
executor,
defaultOpts({ loopDetector: detector, maxConcurrent: 1 }),
);
expect(executionOrder).toEqual(["tool_a", "tool_b", "tool_c"]);
});
it("defaults to maxConcurrent of 7", async () => {
let maxConcurrent = 0;
let concurrent = 0;
const calls = Array.from({ length: 14 }, (_, i) =>
makeToolCall({ id: `d${i}`, name: `tool_${i}`, input: { i } }),
);
const executor: ToolExecutor = async () => {
concurrent++;
if (concurrent > maxConcurrent) maxConcurrent = concurrent;
await new Promise((r) => setTimeout(r, 20));
concurrent--;
return { success: true, output: "ok" };
};
await dispatchTools(calls, executor, defaultOpts({ loopDetector: detector }));
// Default batch size is 7, so max concurrency should be 7
expect(maxConcurrent).toBeLessThanOrEqual(7);
expect(maxConcurrent).toBeGreaterThanOrEqual(2); // Proves parallelism happened
});
// ============================================================================
// Loop detection integration
// ============================================================================
it("blocks tool calls that the loop detector rejects", async () => {
// Make the same call enough times to trigger identical call limit
const calls: ToolUseBlock[] = [];
for (let i = 0; i < LoopDetector.IDENTICAL_CALL_LIMIT + 1; i++) {
calls.push(makeToolCall({ id: `loop${i}`, name: "read_file", input: { path: "/same" } }));
}
const executorSpy = vi.fn(async () => ({ success: true, output: "ok" }));
const { results } = await dispatchTools(
calls,
executorSpy,
defaultOpts({ loopDetector: detector }),
);
// The last call should be blocked
const lastResult = results[results.length - 1];
const content = lastResult.content as string;
const parsed = JSON.parse(content);
expect(parsed.error).toContain("identical call");
// The executor should NOT have been called for the blocked one
expect(executorSpy).toHaveBeenCalledTimes(LoopDetector.IDENTICAL_CALL_LIMIT);
});
it("records results back to the loop detector", async () => {
const tc = makeToolCall({ id: "lr1", name: "bash", input: { cmd: "fail" } });
await dispatchTools(
[tc],
failExecutor("error occurred"),
defaultOpts({ loopDetector: detector }),
);
const stats = detector.getSessionStats();
expect(stats.totalErrors).toBe(1);
});
it("blocks previously failed strategies in subsequent dispatches", async () => {
const input = { path: "/specific/file", content: "data" };
// First dispatch: tool fails
await dispatchTools(
[makeToolCall({ id: "fs1", name: "edit_file", input })],
failExecutor("permission denied"),
defaultOpts({ loopDetector: detector }),
);
// Reset turn to simulate new turn (but keep session state)
detector.resetTurn();
// Second dispatch: same exact call should be blocked
const executorSpy = vi.fn(async () => ({ success: true, output: "ok" }));
const { results } = await dispatchTools(
[makeToolCall({ id: "fs2", name: "edit_file", input })],
executorSpy,
defaultOpts({ loopDetector: detector }),
);
const content = results[0].content as string;
const parsed = JSON.parse(content);
expect(parsed.error).toContain("failed in a previous turn");
expect(executorSpy).not.toHaveBeenCalled();
});
// ============================================================================
// Bail-out detection
// ============================================================================
it("returns bailOut=true after consecutive failed turns", async () => {
for (let turn = 0; turn < LoopDetector.CONSECUTIVE_FAILED_TURN_LIMIT; turn++) {
const tc = makeToolCall({ id: `bail${turn}`, name: `tool_${turn}`, input: { turn } });
const { bailOut, bailMessage } = await dispatchTools(
[tc],
failExecutor("error"),
defaultOpts({ loopDetector: detector }),
);
if (turn < LoopDetector.CONSECUTIVE_FAILED_TURN_LIMIT - 1) {
expect(bailOut).toBe(false);
} else {
expect(bailOut).toBe(true);
expect(bailMessage).toContain("consecutive turns");
}
detector.resetTurn();
}
});
it("prepends SYSTEM WARNING to last result content on bail-out", async () => {
// Build up consecutive failed turns
for (let turn = 0; turn < LoopDetector.CONSECUTIVE_FAILED_TURN_LIMIT - 1; turn++) {
const tc = makeToolCall({ id: `pre${turn}`, name: `tool_${turn}`, input: { turn } });
await dispatchTools(
[tc],
failExecutor("error"),
defaultOpts({ loopDetector: detector }),
);
detector.resetTurn();
}
// This turn should trigger bail
const tc = makeToolCall({ id: "final_bail", name: "final_tool", input: { last: true } });
const { results, bailOut } = await dispatchTools(
[tc],
failExecutor("final error"),
defaultOpts({ loopDetector: detector }),
);
expect(bailOut).toBe(true);
const content = results[0].content as string;
expect(content).toContain("[SYSTEM WARNING]");
expect(content).toContain("consecutive turns");
});
it("does not bail after successful turns", async () => {
for (let turn = 0; turn < 5; turn++) {
const tc = makeToolCall({ id: `ok${turn}`, name: "good_tool", input: { turn } });
const { bailOut } = await dispatchTools(
[tc],
successExecutor("ok"),
defaultOpts({ loopDetector: detector }),
);
expect(bailOut).toBe(false);
detector.resetTurn();
}
});
it("does not prepend SYSTEM WARNING when last result has array content (image)", async () => {
// Build up consecutive failed turns
for (let turn = 0; turn < LoopDetector.CONSECUTIVE_FAILED_TURN_LIMIT - 1; turn++) {
const tc = makeToolCall({ id: `pre2${turn}`, name: `tool_${turn}`, input: { turn } });
await dispatchTools(
[tc],
failExecutor("error"),
defaultOpts({ loopDetector: detector }),
);
detector.resetTurn();
}
// This turn: one fails (triggers bail) and one returns image (last result)
const calls = [
makeToolCall({ id: "fail_img", name: "failing_tool", input: { fail: true } }),
makeToolCall({ id: "img_bail", name: "image_tool", input: { img: true } }),
];
const executor: ToolExecutor = async (name) => {
if (name === "failing_tool") {
return { success: false, output: "err" };
}
return { success: true, output: "__IMAGE__image/jpeg__/9j/4AAQ" };
};
const { results, bailOut } = await dispatchTools(
calls,
executor,
defaultOpts({ loopDetector: detector }),
);
expect(bailOut).toBe(true);
// Last result is image (array content) - WARNING should NOT be prepended
const lastContent = results[results.length - 1].content;
expect(Array.isArray(lastContent)).toBe(true);
});
// ============================================================================
// Callbacks
// ============================================================================
it("calls onStart for each executed tool", async () => {
const started: Array<{ name: string; input: Record<string, unknown> }> = [];
const calls = [
makeToolCall({ id: "cb1", name: "tool_a", input: { a: 1 } }),
makeToolCall({ id: "cb2", name: "tool_b", input: { b: 2 } }),
];
await dispatchTools(calls, successExecutor(), {
loopDetector: detector,
onStart: (name, input) => started.push({ name, input }),
});
expect(started).toHaveLength(2);
expect(started[0].name).toBe("tool_a");
expect(started[1].name).toBe("tool_b");
});
it("calls onResult for each executed tool with success and duration", async () => {
const completed: Array<{ name: string; success: boolean; result: string; durationMs: number }> = [];
const tc = makeToolCall({ id: "cbr1", name: "slow_tool" });
const executor: ToolExecutor = async () => {
await new Promise((r) => setTimeout(r, 50));
return { success: true, output: "done" };
};
await dispatchTools([tc], executor, {
loopDetector: detector,
onResult: (name, success, result, durationMs) =>
completed.push({ name, success, result, durationMs }),
});
expect(completed).toHaveLength(1);
expect(completed[0].name).toBe("slow_tool");
expect(completed[0].success).toBe(true);
expect(completed[0].result).toBe("done");
expect(completed[0].durationMs).toBeGreaterThanOrEqual(40);
});
it("calls onResult with blocked reason when loop detector blocks", async () => {
const completed: Array<{ name: string; success: boolean; result: string }> = [];
// Pre-fill loop detector to trigger block
for (let i = 0; i < LoopDetector.IDENTICAL_CALL_LIMIT; i++) {
detector.recordCall("read_file", { path: "/x" });
}
const tc = makeToolCall({ id: "blk1", name: "read_file", input: { path: "/x" } });
await dispatchTools([tc], successExecutor(), {
loopDetector: detector,
onResult: (name, success, result) => completed.push({ name, success, result }),
});
expect(completed).toHaveLength(1);
expect(completed[0].success).toBe(false);
expect(completed[0].result).toContain("identical call");
});
it("does not call onStart for blocked tools", async () => {
const started: string[] = [];
for (let i = 0; i < LoopDetector.IDENTICAL_CALL_LIMIT; i++) {
detector.recordCall("read_file", { path: "/x" });
}
const tc = makeToolCall({ id: "ns1", name: "read_file", input: { path: "/x" } });
await dispatchTools([tc], successExecutor(), {
loopDetector: detector,
onStart: (name) => started.push(name),
});
expect(started).toHaveLength(0);
});
// ============================================================================
// Abort signal
// ============================================================================
it("skips execution when signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
const calls = [
makeToolCall({ id: "ab1", name: "tool_a" }),
makeToolCall({ id: "ab2", name: "tool_b" }),
];
const executorSpy = vi.fn(async () => ({ success: true, output: "ok" }));
const { results } = await dispatchTools(calls, executorSpy, {
loopDetector: detector,
signal: controller.signal,
});
expect(executorSpy).not.toHaveBeenCalled();
expect(results).toHaveLength(0);
});
it("stops processing batches when signal is aborted mid-execution", async () => {
const controller = new AbortController();
const executionOrder: string[] = [];
// 4 tools with maxConcurrent=2 -> 2 batches
const calls = [
makeToolCall({ id: "m1", name: "tool_1", input: { n: 1 } }),
makeToolCall({ id: "m2", name: "tool_2", input: { n: 2 } }),
makeToolCall({ id: "m3", name: "tool_3", input: { n: 3 } }),
makeToolCall({ id: "m4", name: "tool_4", input: { n: 4 } }),
];
const executor: ToolExecutor = async (name) => {
executionOrder.push(name);
// Abort after first batch starts
if (name === "tool_2") {
controller.abort();
}
return { success: true, output: "ok" };
};
const { results } = await dispatchTools(calls, executor, {
loopDetector: detector,
maxConcurrent: 2,
signal: controller.signal,
});
// First batch (tool_1, tool_2) executed but second batch (tool_3, tool_4) should be skipped
expect(executionOrder).toContain("tool_1");
expect(executionOrder).toContain("tool_2");
expect(executionOrder).not.toContain("tool_3");
expect(executionOrder).not.toContain("tool_4");
// Only results from completed tools
expect(results).toHaveLength(2);
});
// ============================================================================
// Mixed success/failure in parallel batch
// ============================================================================
it("handles mixed success and failure in a single batch", async () => {
const calls = [
makeToolCall({ id: "mx1", name: "good_tool", input: { good: true } }),
makeToolCall({ id: "mx2", name: "bad_tool", input: { bad: true } }),
makeToolCall({ id: "mx3", name: "another_good", input: { fine: true } }),
];
const executor: ToolExecutor = async (name) => {
if (name === "bad_tool") {
return { success: false, output: "failed badly" };
}
return { success: true, output: `${name} ok` };
};
const { results } = await dispatchTools(
calls,
executor,
defaultOpts({ loopDetector: detector }),
);
expect(results).toHaveLength(3);
expect(results[0].content).toBe("good_tool ok");
const failContent = JSON.parse(results[1].content as string);
expect(failContent.error).toBe("failed badly");
expect(results[2].content).toBe("another_good ok");
});
// ============================================================================
// Result type always has correct structure
// ============================================================================
it("all results have type=tool_result and correct tool_use_id", async () => {
const calls = [
makeToolCall({ id: "st1", name: "a" }),
makeToolCall({ id: "st2", name: "b" }),
];
const { results } = await dispatchTools(
calls,
successExecutor(),
defaultOpts({ loopDetector: detector }),
);
for (const r of results) {
expect(r.type).toBe("tool_result");
}
expect(results[0].tool_use_id).toBe("st1");
expect(results[1].tool_use_id).toBe("st2");
});
// ============================================================================
// Edge: non-string output from executor
// ============================================================================
it("stringifies non-string output from successful executor", async () => {
const tc = makeToolCall({ id: "ns1", name: "json_tool" });
// Force a non-string through the type system to test the fallback
const executor: ToolExecutor = async () => ({
success: true,
output: { data: "hello" } as unknown as string,
});
const { results } = await dispatchTools(
[tc],
executor,
defaultOpts({ loopDetector: detector }),
);
const content = results[0].content as string;
expect(typeof content).toBe("string");
expect(content).toContain("data");
expect(content).toContain("hello");
});
});
// ============================================================================
// buildAssistantContent
// ============================================================================
describe("buildAssistantContent", () => {
it("builds content with just text", () => {
const result = buildAssistantContent({
text: "Hello world",
toolUseBlocks: [],
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ type: "text", text: "Hello world" });
});
it("builds content with tool_use blocks", () => {
const result = buildAssistantContent({
text: "Using tools",
toolUseBlocks: [
{ id: "t1", name: "read_file", input: { path: "/foo" } },
],
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "text", text: "Using tools" });
expect(result[1]).toEqual({
type: "tool_use",
id: "t1",
name: "read_file",
input: { path: "/foo" },
});
});
it("puts compaction block first", () => {
const result = buildAssistantContent({
text: "Some text",
toolUseBlocks: [],
compactionContent: "compacted summary",
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "compaction", content: "compacted summary" });
expect(result[1]).toEqual({ type: "text", text: "Some text" });
});
it("puts thinking blocks before text", () => {
const result = buildAssistantContent({
text: "Result",
toolUseBlocks: [],
thinkingBlocks: [{ type: "thinking", thinking: "Let me think...", signature: "sig123" }],
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "thinking", thinking: "Let me think...", signature: "sig123" });
expect(result[1]).toEqual({ type: "text", text: "Result" });
});
it("preserves full ordering: compaction, thinking, text, tool_use", () => {
const result = buildAssistantContent({
text: "The answer",
toolUseBlocks: [
{ id: "t1", name: "bash", input: { command: "ls" } },
{ id: "t2", name: "read_file", input: { path: "/x" } },
],
thinkingBlocks: [{ type: "thinking", thinking: "hmm", signature: "s1" }],
compactionContent: "summary of previous context",
});
expect(result).toHaveLength(5);
expect(result[0].type).toBe("compaction");
expect(result[1].type).toBe("thinking");
expect(result[2].type).toBe("text");
expect(result[3].type).toBe("tool_use");
expect(result[4].type).toBe("tool_use");
});
it("omits text block when text is empty", () => {
const result = buildAssistantContent({
text: "",
toolUseBlocks: [{ id: "t1", name: "bash", input: {} }],
});
expect(result).toHaveLength(1);
expect(result[0].type).toBe("tool_use");
});
it("omits compaction block when compactionContent is null", () => {
const result = buildAssistantContent({
text: "Hello",
toolUseBlocks: [],
compactionContent: null,
});
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
});
it("omits compaction block when compactionContent is undefined", () => {
const result = buildAssistantContent({
text: "Hello",
toolUseBlocks: [],
compactionContent: undefined,
});
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
});
it("includes compaction block with empty string content", () => {
const result = buildAssistantContent({
text: "Hello",
toolUseBlocks: [],
compactionContent: "",
});
// Empty string is truthy for the null/undefined check
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "compaction", content: "" });
});
it("handles multiple thinking blocks", () => {
const result = buildAssistantContent({
text: "Final answer",
toolUseBlocks: [],
thinkingBlocks: [
{ type: "thinking", thinking: "first thought", signature: "s1" },
{ type: "thinking", thinking: "second thought", signature: "s2" },
],
});
expect(result).toHaveLength(3);
expect(result[0].type).toBe("thinking");
expect(result[1].type).toBe("thinking");
expect(result[2].type).toBe("text");
});
it("handles no text, no thinking, no compaction — only tool_use", () => {
const result = buildAssistantContent({
text: "",
toolUseBlocks: [{ id: "t1", name: "bash", input: { command: "echo hi" } }],
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "tool_use",
id: "t1",
name: "bash",
input: { command: "echo hi" },
});
});
it("produces empty array when everything is empty/absent", () => {
const result = buildAssistantContent({
text: "",
toolUseBlocks: [],
});
expect(result).toHaveLength(0);
});
it("maps multiple tool_use blocks preserving id, name, input", () => {
const tools: ToolUseBlock[] = [
{ id: "t1", name: "bash", input: { command: "ls" } },
{ id: "t2", name: "read_file", input: { path: "/a" } },
{ id: "t3", name: "edit_file", input: { path: "/b", content: "new" } },
];
const result = buildAssistantContent({ text: "", toolUseBlocks: tools });
expect(result).toHaveLength(3);
for (let i = 0; i < tools.length; i++) {
expect(result[i]).toEqual({
type: "tool_use",
id: tools[i].id,
name: tools[i].name,
input: tools[i].input,
});
}
});
});