Files
fusion/packages/pi-claude-cli/tests/event-bridge.test.ts
gsxdsm f4e0850f9d feat: add "Anthropic — via Claude CLI" as a first-class provider
Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.

Backend:
 - Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
   (MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
   pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
   vs ours ^0.62.0) and fix bugs without waiting on upstream.
 - Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
   so users don't have to `npm install -g pi-claude-cli` manually.
 - serve/daemon/dashboard conditionally load the extension via
   discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
   no side-effects on user ~/.fusion/agent/settings.json.
 - New GET /api/providers/claude-cli/status: claude --version probe
   + toggle state + cached extension resolution.
 - New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
   claude binary is missing, fires the existing skill-backfill hook.
 - /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
   provider entry so onboarding + settings see a consistent list.

Frontend:
 - New ClaudeCliProviderCard component shared between ModelOnboardingModal
   and SettingsModal's Authentication section.
 - New AuthProvider.type = "cli" variant.
 - Removed the old "Route AI calls through the Claude CLI" checkbox from
   Global Models settings and the opt-in step from the onboarding wizard.
 - ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
   the claude-cli provider id.

Tests:
 - 8 unit tests for extension resolution (@fusion/pi-claude-cli is
   workspace-linked so these run in-tree).
 - 2 unit tests for the binary probe.
 - Existing /auth/status tests filter out the new synthetic entry so
   they keep asserting structural OAuth/API-key behavior in isolation.
 - The vendored package's own 296 tests still pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:34 -07:00

1245 lines
38 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock @mariozechner/pi-ai before importing event-bridge
vi.mock("@mariozechner/pi-ai", () => ({
calculateCost: vi.fn(),
}));
import { createEventBridge } from "../src/event-bridge";
import { calculateCost } from "@mariozechner/pi-ai";
// Helper: create a mock stream that captures pushed events
function createMockStream() {
const events: unknown[] = [];
return {
push: vi.fn((event: unknown) => events.push(event)),
end: vi.fn(),
events,
};
}
// Helper: create a minimal mock model
function createMockModel() {
return {
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
api: "pi-claude-cli",
provider: "anthropic",
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
};
}
describe("createEventBridge", () => {
let stream: ReturnType<typeof createMockStream>;
let model: ReturnType<typeof createMockModel>;
beforeEach(() => {
stream = createMockStream();
model = createMockModel();
vi.clearAllMocks();
});
// Helper: create bridge and trigger the initial "start" event so tests
// don't need to account for it in their push call counts.
function createBridgeWithStart() {
const bridge = createEventBridge(stream as any, model as any);
// Trigger start event via message_start
bridge.handleEvent({ type: "message_start", message: { usage: {} } });
stream.push.mockClear();
stream.events.length = 0;
return bridge;
}
it("pushes start event on first handleEvent call", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({ type: "message_start", message: { usage: {} } });
expect(stream.push).toHaveBeenCalledWith(
expect.objectContaining({ type: "start" }),
);
});
describe("text content block streaming", () => {
it("pushes text_start on content_block_start with text type", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("text_start");
expect(event.contentIndex).toBe(0);
});
it("pushes text_delta on content_block_delta with text_delta type", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
});
expect(stream.push).toHaveBeenCalledTimes(2);
const event = stream.events[1] as any;
expect(event.type).toBe("text_delta");
expect(event.contentIndex).toBe(0);
expect(event.delta).toBe("Hello");
});
it("pushes text_end on content_block_stop with accumulated text", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: " world" },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
expect(stream.push).toHaveBeenCalledTimes(4);
const event = stream.events[3] as any;
expect(event.type).toBe("text_end");
expect(event.contentIndex).toBe(0);
expect(event.content).toBe("Hello world");
});
});
describe("multiple text blocks", () => {
it("tracks multiple text blocks independently", () => {
const bridge = createBridgeWithStart();
// Block 0
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "First" },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// Block 1
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "Second" },
});
bridge.handleEvent({
type: "content_block_stop",
index: 1,
});
// Verify block 0
const textEnd0 = stream.events[2] as any;
expect(textEnd0.type).toBe("text_end");
expect(textEnd0.contentIndex).toBe(0);
expect(textEnd0.content).toBe("First");
// Verify block 1
const textStart1 = stream.events[3] as any;
expect(textStart1.type).toBe("text_start");
expect(textStart1.contentIndex).toBe(1);
const textEnd1 = stream.events[5] as any;
expect(textEnd1.type).toBe("text_end");
expect(textEnd1.contentIndex).toBe(1);
expect(textEnd1.content).toBe("Second");
});
});
describe("message_start usage tracking", () => {
it("captures initial usage from message_start", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_start",
message: {
usage: {
input_tokens: 100,
output_tokens: 0,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 25,
},
},
});
const output = bridge.getOutput();
expect(output.usage.input).toBe(100);
expect(output.usage.output).toBe(0);
expect(output.usage.cacheRead).toBe(50);
expect(output.usage.cacheWrite).toBe(25);
expect(output.usage.totalTokens).toBe(175);
expect(calculateCost).toHaveBeenCalled();
});
it("defaults missing usage fields to 0", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_start",
message: {
usage: {
input_tokens: 50,
// output_tokens, cache_read_input_tokens, cache_creation_input_tokens all missing
},
},
});
const output = bridge.getOutput();
expect(output.usage.input).toBe(50);
expect(output.usage.output).toBe(0);
expect(output.usage.cacheRead).toBe(0);
expect(output.usage.cacheWrite).toBe(0);
expect(output.usage.totalTokens).toBe(50);
});
});
describe("message_delta handling", () => {
it("captures stop_reason from message_delta", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 42 },
});
const output = bridge.getOutput();
expect(output.stopReason).toBe("stop");
});
it("updates usage from message_delta", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_start",
message: {
usage: {
input_tokens: 100,
output_tokens: 0,
},
},
});
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 42 },
});
const output = bridge.getOutput();
expect(output.usage.output).toBe(42);
expect(output.usage.totalTokens).toBe(142);
});
});
describe("message_stop (no-op, done pushed by provider)", () => {
it("does not push done (provider pushes it after readline closes)", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_start",
message: { usage: { input_tokens: 100, output_tokens: 0 } },
});
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello world" },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 5 },
});
bridge.handleEvent({
type: "message_stop",
});
// No done event from event bridge (provider handles it after readline closes)
const doneEvent = stream.events.find((e: any) => e.type === "done");
expect(doneEvent).toBeUndefined();
expect(stream.end).not.toHaveBeenCalled();
// Output state is correct for provider to use
const output = bridge.getOutput();
expect(output.content).toHaveLength(1);
expect((output.content[0] as any).text).toBe("Hello world");
expect(output.stopReason).toBe("stop");
});
});
describe("stop reason mapping", () => {
it("maps end_turn to stop", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
});
expect(bridge.getOutput().stopReason).toBe("stop");
});
it("maps max_tokens to length", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "max_tokens" },
});
expect(bridge.getOutput().stopReason).toBe("length");
});
it("maps tool_use to toolUse", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "tool_use" },
});
expect(bridge.getOutput().stopReason).toBe("toolUse");
});
it("maps unknown stop reasons to stop", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "something_else" },
});
expect(bridge.getOutput().stopReason).toBe("stop");
});
});
describe("tool_use content block streaming", () => {
it("pushes toolcall_start with mapped pi name on content_block_start", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01ABC", name: "Read" },
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_start");
expect(event.contentIndex).toBe(0);
// Tool name should be mapped from Claude "Read" to pi "read"
expect(event.partial.content[0].name).toBe("read");
});
it("pushes toolcall_delta with raw JSON fragment on input_json_delta", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01ABC", name: "Read" },
});
stream.push.mockClear();
stream.events.length = 0;
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"file_' },
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_delta");
expect(event.delta).toBe('{"file_');
expect(event.contentIndex).toBe(0);
});
it("accumulates partial JSON across multiple deltas", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01ABC", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"file_' },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: 'path": "/foo.ts"}' },
});
// After full JSON is accumulated, the output content should have parsed args
const output = bridge.getOutput();
const toolCall = output.content[0] as any;
expect(toolCall.type).toBe("toolCall");
// Arguments should be updated as JSON becomes parseable
expect(toolCall.arguments).toEqual({ file_path: "/foo.ts" });
});
it("pushes toolcall_end with fully parsed and argument-mapped ToolCall on block_stop", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01ABC", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/foo.ts"}',
},
});
stream.push.mockClear();
stream.events.length = 0;
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_end");
expect(event.contentIndex).toBe(0);
expect(event.toolCall.type).toBe("toolCall");
expect(event.toolCall.id).toBe("toolu_01ABC");
expect(event.toolCall.name).toBe("read");
// Claude's "file_path" should be mapped to pi's "path"
expect(event.toolCall.arguments).toEqual({ path: "/foo.ts" });
});
it("tracks multiple tool_use blocks independently by Claude event.index", () => {
const bridge = createBridgeWithStart();
// Tool block at index 0
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/a.ts"}',
},
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// Tool block at index 1
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_02", name: "Write" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/b.ts", "content": "hello"}',
},
});
bridge.handleEvent({
type: "content_block_stop",
index: 1,
});
// Find toolcall_end events
const endEvents = stream.events.filter(
(e: any) => e.type === "toolcall_end",
) as any[];
expect(endEvents).toHaveLength(2);
expect(endEvents[0].toolCall.id).toBe("toolu_01");
expect(endEvents[0].toolCall.name).toBe("read");
expect(endEvents[0].toolCall.arguments).toEqual({ path: "/a.ts" });
expect(endEvents[1].toolCall.id).toBe("toolu_02");
expect(endEvents[1].toolCall.name).toBe("write");
expect(endEvents[1].toolCall.arguments).toEqual({
path: "/b.ts",
content: "hello",
});
});
it("tracks tool_use block interleaved with text block correctly", () => {
const bridge = createBridgeWithStart();
// Text block at index 0
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Let me read that file." },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// Tool block at index 1
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_01", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/foo.ts"}',
},
});
bridge.handleEvent({
type: "content_block_stop",
index: 1,
});
const output = bridge.getOutput();
expect(output.content).toHaveLength(2);
expect((output.content[0] as any).type).toBe("text");
expect((output.content[0] as any).text).toBe("Let me read that file.");
expect((output.content[1] as any).type).toBe("toolCall");
expect((output.content[1] as any).name).toBe("read");
// Verify contentIndex values
const textStart = stream.events.find(
(e: any) => e.type === "text_start",
) as any;
expect(textStart.contentIndex).toBe(0);
const toolStart = stream.events.find(
(e: any) => e.type === "toolcall_start",
) as any;
expect(toolStart.contentIndex).toBe(1);
});
it("handles partial JSON parse failure during delta gracefully (no crash)", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01", name: "Bash" },
});
// Partial JSON that cannot be parsed yet
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"command": "ls' },
});
// Should not crash -- arguments should still be empty object (previous value)
const output = bridge.getOutput();
const toolCall = output.content[0] as any;
expect(toolCall.arguments).toEqual({});
// Complete the JSON
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: ' -la"}' },
});
// Now should be parsed
expect(toolCall.arguments).toEqual({ command: "ls -la" });
});
it("emits toolcall_end with raw string arguments when final JSON parse fails", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01", name: "Read" },
});
// Send invalid JSON that will never parse
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: "not valid json at all",
},
});
stream.push.mockClear();
stream.events.length = 0;
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_end");
// Arguments should be the raw string since JSON parse failed
expect(event.toolCall.arguments).toBe("not valid json at all");
});
it("getOutput().stopReason is toolUse when stop_reason is tool_use", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_01", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/foo"}',
},
});
bridge.handleEvent({ type: "content_block_stop", index: 0 });
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "tool_use" },
usage: { output_tokens: 10 },
});
bridge.handleEvent({ type: "message_stop" });
// Done is now pushed by provider, not event bridge. Check output state.
expect(bridge.getOutput().stopReason).toBe("toolUse");
});
it("output.content includes ToolCall objects alongside TextContent", () => {
const bridge = createBridgeWithStart();
// Text block
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Reading file..." },
});
bridge.handleEvent({ type: "content_block_stop", index: 0 });
// Tool block
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_01", name: "Read" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: {
type: "input_json_delta",
partial_json: '{"file_path": "/test.ts"}',
},
});
bridge.handleEvent({ type: "content_block_stop", index: 1 });
const output = bridge.getOutput();
expect(output.content).toHaveLength(2);
expect(output.content[0]).toEqual({
type: "text",
text: "Reading file...",
});
expect(output.content[1]).toEqual({
type: "toolCall",
id: "toolu_01",
name: "read",
arguments: { path: "/test.ts" },
});
});
});
describe("thinking and other content block types", () => {
it("emits thinking_start for thinking blocks", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
expect(stream.push).toHaveBeenCalledWith(
expect.objectContaining({
type: "thinking_start",
contentIndex: 0,
}),
);
});
it("emits thinking_delta for thinking content", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
stream.push.mockClear();
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "Let me think..." },
});
expect(stream.push).toHaveBeenCalledWith(
expect.objectContaining({
type: "thinking_delta",
contentIndex: 0,
delta: "Let me think...",
}),
);
});
it("accumulates thinking text across multiple deltas", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "First thought. " },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "Second thought." },
});
const output = bridge.getOutput();
const thinkingBlock = output.content[0] as any;
expect(thinkingBlock.thinking).toBe("First thought. Second thought.");
});
it("emits thinking_end for thinking block stop", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "reasoning here" },
});
stream.push.mockClear();
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
expect(stream.push).toHaveBeenCalledWith(
expect.objectContaining({
type: "thinking_end",
contentIndex: 0,
content: "reasoning here",
}),
);
});
it("accumulates signature_delta on thinking block thinkingSignature", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "some reasoning" },
});
// Signature arrives in multiple chunks
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "sig_part1" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "sig_part2" },
});
const output = bridge.getOutput();
const thinkingBlock = output.content[0] as any;
expect(thinkingBlock.thinkingSignature).toBe("sig_part1sig_part2");
});
it("tracks thinking block interleaved with text block correctly", () => {
const bridge = createBridgeWithStart();
// Thinking block at index 0
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "deep thought" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "sig123" },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// Text block at index 1
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "The answer is 42." },
});
bridge.handleEvent({
type: "content_block_stop",
index: 1,
});
const output = bridge.getOutput();
expect(output.content).toHaveLength(2);
const thinking = output.content[0] as any;
expect(thinking.type).toBe("thinking");
expect(thinking.thinking).toBe("deep thought");
expect(thinking.thinkingSignature).toBe("sig123");
const text = output.content[1] as any;
expect(text.type).toBe("text");
expect(text.text).toBe("The answer is 42.");
// Verify correct contentIndex values
const thinkingStart = stream.events.find(
(e: any) => e.type === "thinking_start",
) as any;
expect(thinkingStart.contentIndex).toBe(0);
const textStart = stream.events.find(
(e: any) => e.type === "text_start",
) as any;
expect(textStart.contentIndex).toBe(1);
});
});
describe("MCP prefix stripping via mapClaudeToolNameToPi", () => {
it("strips mcp__custom-tools__ prefix from tool_use name in toolcall_start", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_mcp01",
name: "mcp__custom-tools__foo",
},
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_start");
// Tool name should be stripped from "mcp__custom-tools__foo" to "foo"
expect(event.partial.content[0].name).toBe("foo");
});
it("strips mcp__custom-tools__ prefix in toolcall_end", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_mcp01",
name: "mcp__custom-tools__foo",
},
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"target": "prod"}' },
});
stream.push.mockClear();
stream.events.length = 0;
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_end");
expect(event.toolCall.name).toBe("foo");
// Custom tool args pass through unchanged (no argument renames for MCP tools)
expect(event.toolCall.arguments).toEqual({ target: "prod" });
});
});
describe("internal Claude Code tools are filtered", () => {
it("skips ToolSearch tool_use blocks (not a pi-known tool)", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_ts01",
name: "ToolSearch",
},
});
// No toolcall_start should be emitted
expect(stream.push).not.toHaveBeenCalled();
// No content added to output
expect(bridge.getOutput().content).toHaveLength(0);
});
it("skips Task, Agent, and other internal tools", () => {
const bridge = createBridgeWithStart();
for (const internalTool of [
"Task",
"TaskOutput",
"Agent",
"WebSearch",
"WebFetch",
"NotebookEdit",
]) {
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: `toolu_${internalTool}`,
name: internalTool,
},
});
}
expect(stream.push).not.toHaveBeenCalled();
});
it("allows built-in tools (Read, Write, etc.)", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_read01", name: "Read" },
});
expect(stream.push).toHaveBeenCalledTimes(1);
expect((stream.events[0] as any).type).toBe("toolcall_start");
});
it("silently drops deltas and stop events for filtered tools", () => {
const bridge = createBridgeWithStart();
// ToolSearch start (filtered)
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_ts01",
name: "ToolSearch",
},
});
// Delta for filtered tool (should be ignored)
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"test"}' },
});
// Stop for filtered tool (should be ignored)
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// Nothing emitted
expect(stream.push).not.toHaveBeenCalled();
});
});
describe("unknown event types", () => {
it("silently ignores unknown event types (after start)", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "some_unknown_event" as any,
});
expect(stream.push).not.toHaveBeenCalled();
});
});
describe("complete text streaming sequence", () => {
it("produces correct pi event sequence for a full conversation turn", () => {
const bridge = createEventBridge(stream as any, model as any);
// 1. message_start
bridge.handleEvent({
type: "message_start",
message: {
usage: {
input_tokens: 150,
output_tokens: 0,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 10,
},
},
});
// 2. content_block_start
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
// 3. content_block_delta x2
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: " world" },
});
// 4. content_block_stop
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
// 5. message_delta
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 5 },
});
// 6. message_stop
bridge.handleEvent({
type: "message_stop",
});
// Verify event sequence: start, text_start, text_delta x2, text_end (no done — provider pushes it)
expect(stream.events).toHaveLength(5);
expect((stream.events[0] as any).type).toBe("start");
expect((stream.events[1] as any).type).toBe("text_start");
expect((stream.events[2] as any).type).toBe("text_delta");
expect((stream.events[2] as any).delta).toBe("Hello");
expect((stream.events[3] as any).type).toBe("text_delta");
expect((stream.events[3] as any).delta).toBe(" world");
expect((stream.events[4] as any).content).toBe("Hello world");
// Output fully populated for provider to read via getOutput()
const output = bridge.getOutput();
expect(output.role).toBe("assistant");
expect(output.content).toEqual([{ type: "text", text: "Hello world" }]);
expect(output.usage.input).toBe(150);
expect(output.usage.output).toBe(5);
expect(output.stopReason).toBe("stop");
expect(stream.end).not.toHaveBeenCalled();
expect(calculateCost).toHaveBeenCalled();
});
});
describe("output initialization", () => {
it("initializes output with correct defaults", () => {
const bridge = createEventBridge(stream as any, model as any);
const output = bridge.getOutput();
expect(output.role).toBe("assistant");
expect(output.content).toEqual([]);
expect(output.api).toBe("pi-claude-cli");
expect(output.provider).toBe("anthropic");
expect(output.model).toBe("claude-sonnet-4-5-20250929");
expect(output.stopReason).toBe("stop");
expect(output.usage.input).toBe(0);
expect(output.usage.output).toBe(0);
expect(output.usage.cacheRead).toBe(0);
expect(output.usage.cacheWrite).toBe(0);
expect(output.usage.totalTokens).toBe(0);
expect(output.usage.cost.total).toBe(0);
expect(typeof output.timestamp).toBe("number");
});
});
describe("stopReason mapping (provider reads via getOutput)", () => {
it("stopReason is toolUse for tool_use", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "tool_use" },
});
bridge.handleEvent({ type: "message_stop" });
expect(bridge.getOutput().stopReason).toBe("toolUse");
});
it("stopReason is length for max_tokens", () => {
const bridge = createEventBridge(stream as any, model as any);
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "max_tokens" },
});
bridge.handleEvent({ type: "message_stop" });
expect(bridge.getOutput().stopReason).toBe("length");
});
});
describe("delta without matching block", () => {
it("handles text_delta arriving with no matching block (index mismatch)", () => {
const bridge = createBridgeWithStart();
// Send a delta for index 5 without any block_start
expect(() => {
bridge.handleEvent({
type: "content_block_delta",
index: 5,
delta: { type: "text_delta", text: "orphan text" },
});
}).not.toThrow();
// No event should be pushed for the orphan delta
expect(stream.push).not.toHaveBeenCalled();
});
it("handles input_json_delta arriving with no matching block", () => {
const bridge = createBridgeWithStart();
expect(() => {
bridge.handleEvent({
type: "content_block_delta",
index: 99,
delta: { type: "input_json_delta", partial_json: '{"key":"val"}' },
});
}).not.toThrow();
expect(stream.push).not.toHaveBeenCalled();
});
it("handles thinking_delta arriving with no matching block", () => {
const bridge = createBridgeWithStart();
expect(() => {
bridge.handleEvent({
type: "content_block_delta",
index: 42,
delta: { type: "thinking_delta", thinking: "orphan thought" },
});
}).not.toThrow();
expect(stream.push).not.toHaveBeenCalled();
});
it("handles signature_delta arriving with no matching block", () => {
const bridge = createBridgeWithStart();
expect(() => {
bridge.handleEvent({
type: "content_block_delta",
index: 7,
delta: { type: "signature_delta", signature: "sig_orphan" },
});
}).not.toThrow();
expect(stream.push).not.toHaveBeenCalled();
});
it("handles content_block_stop arriving with no matching block", () => {
const bridge = createBridgeWithStart();
expect(() => {
bridge.handleEvent({
type: "content_block_stop",
index: 10,
});
}).not.toThrow();
expect(stream.push).not.toHaveBeenCalled();
});
});
describe("unknown content block type in content_block_start", () => {
it("silently ignores unknown content block types", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "some_new_type" as any },
});
// No event pushed and no crash
expect(stream.push).not.toHaveBeenCalled();
expect(bridge.getOutput().content).toHaveLength(0);
});
});
describe("message_delta without usage", () => {
it("handles message_delta with stop_reason but no usage", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "message_delta",
delta: { stop_reason: "end_turn" },
});
expect(bridge.getOutput().stopReason).toBe("stop");
// Usage should remain at defaults
expect(bridge.getOutput().usage.output).toBe(0);
});
});
});