Add CLI formatter, fix env forwarding, rename job prefix to agent-claude-
- Add src/cli/ with format-event.ts (printClaudeStreamEvent) exported from CLIAdapterModule - Fix env var forwarding: read from pod spec container env dynamically instead of static allowlist; agent config env overrides pod values - Rename K8s Job prefix from agent- to agent-claude- - Add fsGroupChangePolicy: "OnRootMismatch" to skip PVC chown on subsequent runs - Add comprehensive test coverage (159 tests across 5 test files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { printClaudeStreamEvent } from "./format-event.js";
|
||||
|
||||
// Mock console methods to capture output
|
||||
const consoleMock = {
|
||||
log: vi.fn(),
|
||||
};
|
||||
|
||||
vi.stubGlobal("console", {
|
||||
...console,
|
||||
log: consoleMock.log,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
consoleMock.log.mockClear();
|
||||
});
|
||||
|
||||
function output() {
|
||||
return consoleMock.log.mock.calls.map((c) => c[0]).join("\n");
|
||||
}
|
||||
|
||||
describe("printClaudeStreamEvent", () => {
|
||||
it("prints raw line if not JSON", () => {
|
||||
printClaudeStreamEvent("hello world", false);
|
||||
expect(output()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("skips empty lines", () => {
|
||||
printClaudeStreamEvent(" ", false);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it("prints init event with model and session", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-opus-4-6",
|
||||
session_id: "sess_abc123",
|
||||
}), false);
|
||||
expect(output()).toContain("Claude initialized");
|
||||
expect(output()).toContain("claude-opus-4-6");
|
||||
expect(output()).toContain("sess_abc123");
|
||||
});
|
||||
|
||||
it("prints assistant text block", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Hello world" }] },
|
||||
}), false);
|
||||
expect(output()).toContain("assistant: Hello world");
|
||||
});
|
||||
|
||||
it("prints thinking block", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "thinking", thinking: "Let me think..." }] },
|
||||
}), false);
|
||||
expect(output()).toContain("thinking: Let me think...");
|
||||
});
|
||||
|
||||
it("prints tool_use block with name and input", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_use",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
}],
|
||||
},
|
||||
}), false);
|
||||
expect(output()).toContain("tool_call: Bash");
|
||||
expect(output()).toContain("ls -la");
|
||||
});
|
||||
|
||||
it("prints tool_result for user message", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: "file1.txt\nfile2.txt",
|
||||
}],
|
||||
},
|
||||
}), false);
|
||||
expect(output()).toContain("tool_result");
|
||||
});
|
||||
|
||||
it("marks tool_result as error when is_error is true", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
is_error: true,
|
||||
content: "Permission denied",
|
||||
}],
|
||||
},
|
||||
}), false);
|
||||
expect(output()).toContain("tool_result (error)");
|
||||
});
|
||||
|
||||
it("prints result with tokens and cost", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "result",
|
||||
result: "Done",
|
||||
subtype: "stop",
|
||||
total_cost_usd: 0.005,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_read_input_tokens: 50,
|
||||
},
|
||||
}), false);
|
||||
expect(output()).toContain("tokens:");
|
||||
expect(output()).toContain("in=100");
|
||||
expect(output()).toContain("out=200");
|
||||
expect(output()).toContain("cached=50");
|
||||
expect(output()).toContain("cost=");
|
||||
});
|
||||
|
||||
it("prints error subtype in result", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "error_rate_limit",
|
||||
is_error: true,
|
||||
errors: ["rate limited"],
|
||||
}), false);
|
||||
expect(output()).toContain("claude_result");
|
||||
expect(output()).toContain("error_rate_limit");
|
||||
expect(output()).toContain("rate limited");
|
||||
});
|
||||
|
||||
it("prints non-JSON lines directly", () => {
|
||||
printClaudeStreamEvent("some output text", false);
|
||||
expect(output()).toBe("some output text");
|
||||
});
|
||||
|
||||
it("does not print unknown types in non-debug mode", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({ type: "unknown", data: "stuff" }), false);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it("prints unknown types in debug mode", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({ type: "unknown", data: "stuff" }), true);
|
||||
expect(output()).toContain("stuff");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||
const obj = value as Record<string, unknown>;
|
||||
const message =
|
||||
(typeof obj.message === "string" && obj.message) ||
|
||||
(typeof obj.error === "string" && obj.error) ||
|
||||
(typeof obj.code === "string" && obj.code) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printToolResult(block: Record<string, unknown>): void {
|
||||
const isError = block.is_error === true;
|
||||
let text = "";
|
||||
if (typeof block.content === "string") {
|
||||
text = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of block.content) {
|
||||
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
||||
const record = part as Record<string, unknown>;
|
||||
if (typeof record.text === "string") parts.push(record.text);
|
||||
}
|
||||
text = parts.join("\n");
|
||||
}
|
||||
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (text) {
|
||||
console.log((isError ? pc.red : pc.gray)(text));
|
||||
}
|
||||
}
|
||||
|
||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
} else if (blockType === "thinking") {
|
||||
const text = typeof block.thinking === "string" ? block.thinking : "";
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
} else if (blockType === "tool_use") {
|
||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (block.input !== undefined) {
|
||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
if (typeof block.type === "string" && block.type === "tool_result") {
|
||||
printToolResult(block);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||
if (resultText) {
|
||||
console.log(pc.green("result:"));
|
||||
console.log(resultText);
|
||||
}
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
||||
if (errors.length > 0) {
|
||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
pc.blue(
|
||||
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printClaudeStreamEvent } from "./format-event.js";
|
||||
Reference in New Issue
Block a user