diff --git a/src/cli/format-event.test.ts b/src/cli/format-event.test.ts new file mode 100644 index 0000000..f052773 --- /dev/null +++ b/src/cli/format-event.test.ts @@ -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"); + }); +}); diff --git a/src/cli/format-event.ts b/src/cli/format-event.ts new file mode 100644 index 0000000..13263be --- /dev/null +++ b/src/cli/format-event.ts @@ -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; + 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): 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; + 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 | null = null; + try { + parsed = JSON.parse(line) as Record; + } 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) + : {}; + 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; + 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) + : {}; + 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; + 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) + : {}; + 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)); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..7c81ab6 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1 @@ +export { printClaudeStreamEvent } from "./format-event.js"; diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts new file mode 100644 index 0000000..adc18a6 --- /dev/null +++ b/src/server/job-manifest.test.ts @@ -0,0 +1,511 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { buildJobManifest } from "./job-manifest.js"; +import type { SelfPodInfo } from "./k8s-client.js"; + +function makeCtx(overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-abc12345", + agent: { id: "agent-abc", companyId: "co1", name: "Test Agent", adapterType: "claude_k8s", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: {}, + context: {}, + onLog: async () => {}, + ...overrides, + }; +} + +function makeSelfPod(overrides: Partial = {}): SelfPodInfo { + return { + namespace: "paperclip", + image: "paperclipai/paperclip:latest", + imagePullSecrets: [{ name: "regcred" }], + dnsConfig: undefined, + pvcClaimName: "paperclip-data", + secretVolumes: [], + inheritedEnv: {}, + ...overrides, + }; +} + +describe("buildJobManifest", () => { + let ctx: AdapterExecutionContext; + let selfPod: SelfPodInfo; + + beforeEach(() => { + ctx = makeCtx(); + selfPod = makeSelfPod(); + }); + + describe("job naming", () => { + it("uses agent-claude- prefix", () => { + const { jobName } = buildJobManifest({ ctx, selfPod }); + expect(jobName).toMatch(/^agent-claude-/); + }); + + it("includes sanitized agent id slug", () => { + ctx.agent.id = "Agent-ABC!@#"; + const { jobName } = buildJobManifest({ ctx, selfPod }); + // sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-8 + // "Agent-ABC!@#" -> "agent-abc" (strips !@#, slice to 8 = "agent-ab") + expect(jobName).toContain("agent-ab"); + }); + + it("includes sanitized run id slug", () => { + ctx.runId = "RUN-ABC-12345"; + const { jobName } = buildJobManifest({ ctx, selfPod }); + // sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-8 + // "RUN-ABC-12345" -> "run-abc-12345" (slice to 8 = "run-abc-") + expect(jobName).toContain("run-abc-"); + }); + }); + + describe("job spec", () => { + it("sets backoffLimit to 0 for fail-fast", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.backoffLimit).toBe(0); + }); + + it("sets activeDeadlineSeconds when timeoutSec > 0", () => { + ctx.config = { timeoutSec: 300 }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.activeDeadlineSeconds).toBe(300); + }); + + it("omits activeDeadlineSeconds when timeoutSec is 0", () => { + ctx.config = { timeoutSec: 0 }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.activeDeadlineSeconds).toBeUndefined(); + }); + + it("sets ttlSecondsAfterFinished default 300", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.ttlSecondsAfterFinished).toBe(300); + }); + + it("uses configured ttlSecondsAfterFinished", () => { + ctx.config = { ttlSecondsAfterFinished: 600 }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.ttlSecondsAfterFinished).toBe(600); + }); + }); + + describe("labels", () => { + it("includes required paperclip labels", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const labels = job.metadata?.labels ?? {}; + expect(labels["app.kubernetes.io/managed-by"]).toBe("paperclip"); + expect(labels["app.kubernetes.io/component"]).toBe("agent-job"); + expect(labels["paperclip.io/agent-id"]).toBe("agent-abc"); + expect(labels["paperclip.io/run-id"]).toBe("run-abc12345"); + expect(labels["paperclip.io/company-id"]).toBe("co1"); + expect(labels["paperclip.io/adapter-type"]).toBe("claude_k8s"); + }); + + it("includes extra labels from config", () => { + ctx.config = { labels: { "env": "prod", "team": "platform" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.labels?.env).toBe("prod"); + expect(job.metadata?.labels?.team).toBe("platform"); + }); + + it("merges extra labels with required ones", () => { + ctx.config = { labels: { "env": "prod" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.labels?.env).toBe("prod"); + expect(job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("claude_k8s"); + }); + }); + + describe("annotations", () => { + it("includes adapter type and agent name annotations", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.metadata?.annotations?.["paperclip.io/adapter-type"]).toBe("claude_k8s"); + expect(job.metadata?.annotations?.["paperclip.io/agent-name"]).toBe("Test Agent"); + }); + }); + + describe("pod spec", () => { + it("sets restartPolicy to Never", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.restartPolicy).toBe("Never"); + }); + + it("sets fsGroupChangePolicy to OnRootMismatch", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch"); + }); + + it("sets fsGroup, runAsNonRoot, runAsUser, runAsGroup", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const sc = job.spec?.template?.spec?.securityContext; + expect(sc?.runAsNonRoot).toBe(true); + expect(sc?.runAsUser).toBe(1000); + expect(sc?.runAsGroup).toBe(1000); + expect(sc?.fsGroup).toBe(1000); + }); + + it("includes imagePullSecrets from selfPod", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.imagePullSecrets).toEqual([{ name: "regcred" }]); + }); + + it("omits imagePullSecrets when empty", () => { + selfPod.imagePullSecrets = []; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.imagePullSecrets).toBeUndefined(); + }); + + it("includes dnsConfig from selfPod when present", () => { + selfPod.dnsConfig = { nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.dnsConfig).toEqual({ nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] }); + }); + + it("omits dnsConfig when not present", () => { + selfPod.dnsConfig = undefined; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.dnsConfig).toBeUndefined(); + }); + }); + + describe("init containers", () => { + it("has write-prompt init container with busybox image", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const init = job.spec?.template?.spec?.initContainers?.[0]; + expect(init?.name).toBe("write-prompt"); + expect(init?.image).toBe("busybox:1.36"); + expect(init?.imagePullPolicy).toBe("IfNotPresent"); + }); + + it("write-prompt writes PROMPT_CONTENT to /tmp/prompt/prompt.txt", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const init = job.spec?.template?.spec?.initContainers?.[0]; + expect(init?.command).toEqual(["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]); + }); + + it("write-prompt mounts prompt volume", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const init = job.spec?.template?.spec?.initContainers?.[0]; + expect(init?.volumeMounts).toContainEqual({ name: "prompt", mountPath: "/tmp/prompt" }); + }); + + it("prompt env var contains rendered prompt text", () => { + const { job, prompt } = buildJobManifest({ ctx, selfPod }); + const init = job.spec?.template?.spec?.initContainers?.[0]; + const promptEnv = init?.env?.find((e: { name: string }) => e.name === "PROMPT_CONTENT"); + expect(promptEnv?.value).toBe(prompt); + }); + }); + + describe("claude container", () => { + it("names container 'claude'", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.name).toBe("claude"); + }); + + it("uses selfPod image by default", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.image).toBe("paperclipai/paperclip:latest"); + }); + + it("uses configured image override", () => { + ctx.config = { image: "my-image:v2" }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.image).toBe("my-image:v2"); + }); + + it("sets imagePullPolicy from config", () => { + ctx.config = { imagePullPolicy: "Always" }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("Always"); + }); + + it("defaults imagePullPolicy to IfNotPresent", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("IfNotPresent"); + }); + + it("sets workingDir to /paperclip by default", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/paperclip"); + }); + + it("uses workspace cwd when available", () => { + ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject"); + }); + + it("prefers workspace cwd over configured cwd", () => { + ctx.config = { cwd: "/custom/path" }; + ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject"); + }); + }); + + describe("volumes", () => { + it("creates prompt emptyDir volume", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const promptVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt"); + expect(promptVol?.emptyDir).toEqual({}); + }); + + it("mounts data PVC at /paperclip when pvcClaimName is set", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const dataVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "data"); + expect(dataVol?.persistentVolumeClaim?.claimName).toBe("paperclip-data"); + const dataMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/paperclip"); + expect(dataMount?.name).toBe("data"); + }); + + it("omits data volume when no PVC", () => { + selfPod.pvcClaimName = null; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.volumes?.find((v) => v.name === "data")).toBeUndefined(); + }); + + it("mounts secret volumes", () => { + selfPod.secretVolumes = [{ + volumeName: "my-secret", + secretName: "app-secret", + mountPath: "/secrets/app", + defaultMode: 420, + }]; + const { job } = buildJobManifest({ ctx, selfPod }); + const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "my-secret"); + expect(secretVol?.secret?.secretName).toBe("app-secret"); + const secretMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/secrets/app"); + expect(secretMount?.readOnly).toBe(true); + }); + }); + + describe("environment variables", () => { + it("sets HOME to /paperclip", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const home = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "HOME"); + expect(home?.value).toBe("/paperclip"); + }); + + it("inherits env vars from selfPod", () => { + selfPod.inheritedEnv = { ANTHROPIC_API_KEY: "sk-abc", AWS_REGION: "us-east-1" }; + const { job } = buildJobManifest({ ctx, selfPod }); + const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? []; + expect(envNames).toContain("ANTHROPIC_API_KEY"); + expect(envNames).toContain("AWS_REGION"); + }); + + it("inherits ANTHROPIC_AUTH_TOKEN from selfPod for API auth", () => { + selfPod.inheritedEnv = { ANTHROPIC_AUTH_TOKEN: "sk-test" }; + const { job } = buildJobManifest({ ctx, selfPod }); + const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? []; + expect(envNames).toContain("ANTHROPIC_AUTH_TOKEN"); + }); + + it("user env config overrides inherited env", () => { + selfPod.inheritedEnv = { AWS_REGION: "us-east-1" }; + ctx.config = { env: { AWS_REGION: "us-west-2" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + const awsRegion = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "AWS_REGION"); + expect(awsRegion?.value).toBe("us-west-2"); + }); + + it("sets PAPERCLIP_RUN_ID", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const runId = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_RUN_ID"); + expect(runId?.value).toBe("run-abc12345"); + }); + + it("sets PAPERCLIP_API_KEY from authToken", () => { + ctx.authToken = "pk_abc123"; + const { job } = buildJobManifest({ ctx, selfPod }); + const apiKey = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_KEY"); + expect(apiKey?.value).toBe("pk_abc123"); + }); + + it("inherited PAPERCLIP_API_URL from selfPod takes precedence", () => { + ctx.authToken = "pk_abc"; + selfPod.inheritedEnv = { PAPERCLIP_API_URL: "http://paperclip:8080" }; + const { job } = buildJobManifest({ ctx, selfPod }); + const apiUrl = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_URL"); + expect(apiUrl?.value).toBe("http://paperclip:8080"); + }); + }); + + describe("resources", () => { + it("sets default resource requests and limits", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const resources = job.spec?.template?.spec?.containers[0]?.resources; + expect(resources?.requests).toEqual({ cpu: "1000m", memory: "2Gi" }); + expect(resources?.limits).toEqual({ cpu: "4000m", memory: "8Gi" }); + }); + + it("uses configured resource overrides", () => { + ctx.config = { + resources: { + requests: { cpu: "500m", memory: "1Gi" }, + limits: { cpu: "2000m", memory: "4Gi" }, + }, + }; + const { job } = buildJobManifest({ ctx, selfPod }); + const resources = job.spec?.template?.spec?.containers[0]?.resources; + expect(resources?.requests).toEqual({ cpu: "500m", memory: "1Gi" }); + expect(resources?.limits).toEqual({ cpu: "2000m", memory: "4Gi" }); + }); + }); + + describe("nodeSelector and tolerations", () => { + it("applies nodeSelector from config", () => { + ctx.config = { nodeSelector: { "topology.kubernetes.io/zone": "us-east-1a" } }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.nodeSelector).toEqual({ "topology.kubernetes.io/zone": "us-east-1a" }); + }); + + it("applies tolerations from config", () => { + ctx.config = { tolerations: [{ key: "disk", operator: "Equal", value: "ssd", effect: "NoSchedule" }] }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.tolerations).toHaveLength(1); + }); + + it("omits nodeSelector when empty", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.nodeSelector).toBeUndefined(); + }); + + it("omits tolerations when empty", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.tolerations).toBeUndefined(); + }); + }); + + describe("claude args", () => { + it("builds --print - - --output-format stream-json --verbose", () => { + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--print"); + expect(claudeArgs).toContain("-"); + expect(claudeArgs).toContain("--output-format"); + expect(claudeArgs).toContain("stream-json"); + expect(claudeArgs).toContain("--verbose"); + }); + + it("adds --model when configured", () => { + ctx.config = { model: "claude-opus-4-6" }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--model"); + expect(claudeArgs).toContain("claude-opus-4-6"); + }); + + it("adds --effort when configured", () => { + ctx.config = { effort: "high" }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--effort"); + expect(claudeArgs).toContain("high"); + }); + + it("adds --max-turns when configured", () => { + ctx.config = { maxTurnsPerRun: 10 }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--max-turns"); + expect(claudeArgs).toContain("10"); + }); + + it("adds --resume when sessionId present", () => { + ctx.runtime.sessionId = "sess_abc"; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--resume"); + expect(claudeArgs).toContain("sess_abc"); + }); + + it("adds --dangerously-skip-permissions by default", () => { + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--dangerously-skip-permissions"); + }); + + it("adds --append-system-prompt-file when instructionsFilePath set", () => { + ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--append-system-prompt-file"); + expect(claudeArgs).toContain("/paperclip/instructions.md"); + }); + + it("appends extraArgs when configured", () => { + ctx.config = { extraArgs: ["--no-input", "--verbose"] }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).toContain("--no-input"); + expect(claudeArgs).toContain("--verbose"); + }); + }); + + describe("prompt rendering", () => { + it("includes agent name in default prompt template", () => { + const { prompt } = buildJobManifest({ ctx, selfPod }); + expect(prompt).toContain("Test Agent"); + }); + + it("uses custom promptTemplate when set", () => { + ctx.config = { promptTemplate: "You are a helpful assistant." }; + const { prompt } = buildJobManifest({ ctx, selfPod }); + expect(prompt).toBe("You are a helpful assistant."); + }); + + it("includes workspace context in prompt when available", () => { + ctx.context = { + paperclipWorkspace: { + cwd: "/project", + strategy: "read-only", + workspaceId: "ws1", + repoUrl: "https://github.com/org/repo", + branchName: "main", + }, + }; + const { prompt } = buildJobManifest({ ctx, selfPod }); + expect(prompt).toContain("Test Agent"); + }); + + it("returns promptMetrics with char counts", () => { + const { promptMetrics } = buildJobManifest({ ctx, selfPod }); + expect(promptMetrics.promptChars).toBeGreaterThan(0); + expect(typeof promptMetrics.promptChars).toBe("number"); + }); + }); + + describe("serviceAccountName", () => { + it("sets custom serviceAccountName when configured", () => { + ctx.config = { serviceAccountName: "paperclip-agent" }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.serviceAccountName).toBe("paperclip-agent"); + }); + + it("omits serviceAccountName when not configured", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.serviceAccountName).toBeUndefined(); + }); + }); + + describe("namespace", () => { + it("uses selfPod namespace by default", () => { + const { namespace } = buildJobManifest({ ctx, selfPod }); + expect(namespace).toBe("paperclip"); + }); + + it("uses configured namespace override", () => { + ctx.config = { namespace: "agents" }; + const { namespace, job } = buildJobManifest({ ctx, selfPod }); + expect(namespace).toBe("agents"); + expect(job.metadata?.namespace).toBe("agents"); + }); + }); + + describe("return value", () => { + it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics", () => { + const result = buildJobManifest({ ctx, selfPod }); + expect(result.job).toBeDefined(); + expect(result.jobName).toBeDefined(); + expect(result.namespace).toBeDefined(); + expect(result.prompt).toBeDefined(); + expect(result.claudeArgs).toBeDefined(); + expect(result.promptMetrics).toBeDefined(); + }); + }); +}); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index ed5a506..0243cfb 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -186,7 +186,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { const agentSlug = sanitizeForK8sName(agent.id); const runSlug = sanitizeForK8sName(runId); - const jobName = `agent-${agentSlug}-${runSlug}`; + const jobName = `agent-claude-${agentSlug}-${runSlug}`; // Build prompt (same logic as claude_local) const promptTemplate = asString( diff --git a/src/server/k8s-client.ts b/src/server/k8s-client.ts index 9f62571..bd908ff 100644 --- a/src/server/k8s-client.ts +++ b/src/server/k8s-client.ts @@ -24,16 +24,6 @@ export interface SelfPodInfo { inheritedEnv: Record; } -/** Keys forwarded from the Deployment container env into Job pods. */ -const INHERITED_ENV_KEYS = [ - "CLAUDE_CODE_USE_BEDROCK", - "AWS_REGION", - "AWS_BEARER_TOKEN_BEDROCK", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "PAPERCLIP_API_URL", -]; - let cachedSelfPod: SelfPodInfo | null = null; /** @@ -141,13 +131,13 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise = {}; - for (const key of INHERITED_ENV_KEYS) { - const value = process.env[key]; - if (value !== undefined) { - inheritedEnv[key] = value; - } + for (const envItem of mainContainer.env ?? []) { + if (!envItem.name) continue; + const value = envItem.value ?? ""; + if (value) inheritedEnv[envItem.name] = value; } cachedSelfPod = { diff --git a/src/server/parse.test.ts b/src/server/parse.test.ts new file mode 100644 index 0000000..ccc4b24 --- /dev/null +++ b/src/server/parse.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from "vitest"; +import { + parseClaudeStreamJson, + extractClaudeLoginUrl, + detectClaudeLoginRequired, + describeClaudeFailure, + isClaudeMaxTurnsResult, + isClaudeUnknownSessionError, +} from "./parse.js"; + +describe("parseClaudeStreamJson", () => { + it("returns empty result for blank input", () => { + const result = parseClaudeStreamJson(""); + expect(result.sessionId).toBeNull(); + expect(result.model).toBe(""); + expect(result.costUsd).toBeNull(); + expect(result.usage).toBeNull(); + expect(result.summary).toBe(""); + expect(result.resultJson).toBeNull(); + }); + + it("returns empty result for non-JSON lines", () => { + const result = parseClaudeStreamJson("hello world\nnot json\n"); + expect(result.summary).toBe(""); + expect(result.resultJson).toBeNull(); + }); + + it("parses system/init event for sessionId and model", () => { + const stdout = JSON.stringify({ + type: "system", + subtype: "init", + session_id: "sess_abc123", + model: "claude-opus-4-6", + }); + const result = parseClaudeStreamJson(stdout); + expect(result.sessionId).toBe("sess_abc123"); + expect(result.model).toBe("claude-opus-4-6"); + }); + + it("parses assistant text blocks", () => { + const lines = [ + JSON.stringify({ + type: "assistant", + session_id: "sess_abc", + message: { content: [{ type: "text", text: "Hello" }] }, + }), + JSON.stringify({ + type: "assistant", + session_id: "sess_abc", + message: { content: [{ type: "text", text: " world" }] }, + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + expect(result.summary).toBe("Hello\n\n world"); + }); + + it("parses thinking blocks", () => { + const lines = [ + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "thinking", thinking: "Let me think..." }] }, + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + // thinking is not included in summary + expect(result.summary).toBe(""); + }); + + it("parses tool_use blocks without crashing", () => { + const lines = [ + JSON.stringify({ + type: "assistant", + message: { + content: [{ + type: "tool_use", + name: "Bash", + input: { command: "ls" }, + id: "tool_123", + }], + }, + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + expect(result.resultJson).toBeNull(); // no result event yet + }); + + it("parses result event with usage and cost", () => { + const lines = [ + JSON.stringify({ + type: "result", + session_id: "sess_abc", + result: "Done", + subtype: "stop", + total_cost_usd: 0.005, + usage: { + input_tokens: 100, + output_tokens: 200, + cache_read_input_tokens: 50, + }, + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + expect(result.sessionId).toBe("sess_abc"); + expect(result.costUsd).toBe(0.005); + expect(result.resultJson).not.toBeNull(); + expect(result.usage?.inputTokens).toBe(100); + expect(result.usage?.outputTokens).toBe(200); + expect(result.usage?.cachedInputTokens).toBe(50); + }); + + it("returns null cost for non-finite total_cost_usd", () => { + const lines = [ + JSON.stringify({ + type: "result", + total_cost_usd: Infinity, + result: "Done", + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + expect(result.costUsd).toBeNull(); + }); + + it("falls back to assistant texts when no result event", () => { + const lines = [ + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Some output" }] }, + }), + ].join("\n"); + const result = parseClaudeStreamJson(lines); + expect(result.summary).toBe("Some output"); + expect(result.resultJson).toBeNull(); + }); + + it("handles mixed JSON and non-JSON lines", () => { + const lines = `some raw output +${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "JSON output" }] } })} +more raw output`; + const result = parseClaudeStreamJson(lines); + // Non-JSON lines don't contribute to summary; only parsed JSON content does + expect(result.summary).toContain("JSON output"); + expect(result.summary).not.toContain("some raw output"); + }); +}); + +describe("extractClaudeLoginUrl", () => { + it("returns null for no URL in text", () => { + expect(extractClaudeLoginUrl("not a url")).toBeNull(); + }); + + it("extracts and cleans URLs with trailing punctuation", () => { + expect(extractClaudeLoginUrl("Visit https://auth.anthropic.com/ for login!")).toBe("https://auth.anthropic.com/"); + }); + + it("returns first URL when no anthropic/claude keywords", () => { + expect(extractClaudeLoginUrl("Go to https://example.com/page")).toBe("https://example.com/page"); + }); + + it("filters by claude/anthropic/auth keywords", () => { + const text = "See https://example.com and https://auth.anthropic.com/login"; + expect(extractClaudeLoginUrl(text)).toBe("https://auth.anthropic.com/login"); + }); + + it("returns null when no URL matches filter", () => { + expect(extractClaudeLoginUrl("Visit https://example.com only")).toBe("https://example.com"); + }); +}); + +describe("detectClaudeLoginRequired", () => { + const loginPhrases = [ + "Please log in", + "not logged in", + "please run `claude login`", + "login required", + "unauthorized", + "authentication required", + ]; + + it("returns requiresLogin false when no auth phrases", () => { + const result = detectClaudeLoginRequired({ + parsed: { result: "All good" }, + stdout: "", + stderr: "", + }); + expect(result.requiresLogin).toBe(false); + expect(result.loginUrl).toBeNull(); + }); + + it("detects login required from result text", () => { + const result = detectClaudeLoginRequired({ + parsed: { result: "Please log in to continue" }, + stdout: "", + stderr: "", + }); + expect(result.requiresLogin).toBe(true); + }); + + it("detects login required from error array", () => { + const result = detectClaudeLoginRequired({ + parsed: { errors: ["not logged in", "please log in"] }, + stdout: "", + stderr: "", + }); + expect(result.requiresLogin).toBe(true); + }); + + it("extracts login URL from stdout", () => { + const result = detectClaudeLoginRequired({ + parsed: {}, + stdout: "Visit https://auth.anthropic.com to login", + stderr: "", + }); + expect(result.requiresLogin).toBe(false); + expect(result.loginUrl).toBe("https://auth.anthropic.com"); + }); + + it("extracts login URL from stderr", () => { + const result = detectClaudeLoginRequired({ + parsed: {}, + stdout: "", + stderr: "Error. See https://auth.anthropic.com/setup", + }); + expect(result.requiresLogin).toBe(false); + expect(result.loginUrl).toBe("https://auth.anthropic.com/setup"); + }); + + it("detects requiresLogin with URL extraction combined", () => { + const result = detectClaudeLoginRequired({ + parsed: { result: "please log in" }, + stdout: "Visit https://auth.anthropic.com/", + stderr: "", + }); + expect(result.requiresLogin).toBe(true); + expect(result.loginUrl).toBe("https://auth.anthropic.com/"); + }); +}); + +describe("describeClaudeFailure", () => { + it("returns null when no failure info", () => { + expect(describeClaudeFailure({})).toBeNull(); + }); + + it("returns null when result is empty", () => { + expect(describeClaudeFailure({ result: " " })).toBeNull(); + }); + + it("formats with subtype and result", () => { + const result = describeClaudeFailure({ subtype: "error_rate_limit", result: "Too many requests" }); + expect(result).toBe("Claude run failed: subtype=error_rate_limit: Too many requests"); + }); + + it("falls back to first error message", () => { + const result = describeClaudeFailure({ + subtype: "", + result: "", + errors: ["something went wrong"], + }); + expect(result).toBe("Claude run failed: something went wrong"); + }); +}); + +describe("isClaudeMaxTurnsResult", () => { + it("returns false for null/undefined", () => { + expect(isClaudeMaxTurnsResult(null)).toBe(false); + expect(isClaudeMaxTurnsResult(undefined)).toBe(false); + }); + + it("detects error_max_turns subtype", () => { + expect(isClaudeMaxTurnsResult({ subtype: "error_max_turns" })).toBe(true); + }); + + it("detects max_turns stop_reason", () => { + expect(isClaudeMaxTurnsResult({ stop_reason: "max_turns" })).toBe(true); + }); + + it("detects max turns in result text", () => { + expect(isClaudeMaxTurnsResult({ result: "Reached maximum turns" })).toBe(true); + expect(isClaudeMaxTurnsResult({ result: "Maximum turns exceeded" })).toBe(true); + expect(isClaudeMaxTurnsResult({ result: "result is ready" })).toBe(false); + }); + + it("is case insensitive", () => { + expect(isClaudeMaxTurnsResult({ result: "MAXIMUM TURNS" })).toBe(true); + expect(isClaudeMaxTurnsResult({ subtype: "Error_Max_Turns" })).toBe(true); + }); +}); + +describe("isClaudeUnknownSessionError", () => { + it("detects 'no conversation found with session id'", () => { + expect(isClaudeUnknownSessionError({ result: "no conversation found with session id abc" })).toBe(true); + }); + + it("detects 'unknown session'", () => { + expect(isClaudeUnknownSessionError({ result: "unknown session: sess_123" })).toBe(true); + }); + + it("detects 'session not found'", () => { + expect(isClaudeUnknownSessionError({ result: "session sess_xyz not found" })).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isClaudeUnknownSessionError({ result: "something went wrong" })).toBe(false); + }); + + it("checks error array messages", () => { + expect(isClaudeUnknownSessionError({ errors: ["session abc not found"] })).toBe(true); + }); +}); diff --git a/src/server/session.test.ts b/src/server/session.test.ts new file mode 100644 index 0000000..ecc5a02 --- /dev/null +++ b/src/server/session.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { sessionCodec } from "./session.js"; + +const deserialize = sessionCodec.deserialize.bind(sessionCodec); +const serialize = sessionCodec.serialize.bind(sessionCodec); +const getDisplayId = sessionCodec.getDisplayId!.bind(sessionCodec); + +describe("sessionCodec", () => { + describe("deserialize", () => { + it("returns null for non-object input", () => { + expect(deserialize(null)).toBeNull(); + expect(deserialize(undefined)).toBeNull(); + expect(deserialize("string")).toBeNull(); + expect(deserialize(123)).toBeNull(); + expect(deserialize([])).toBeNull(); + }); + + it("returns null when no sessionId present", () => { + expect(deserialize({})).toBeNull(); + expect(deserialize({ workspaceId: "ws1" })).toBeNull(); + }); + + it("accepts sessionId key", () => { + const result = deserialize({ sessionId: "sess_abc" }); + expect(result?.sessionId).toBe("sess_abc"); + }); + + it("accepts session_id key as fallback", () => { + const result = deserialize({ session_id: "sess_abc" }); + expect(result?.sessionId).toBe("sess_abc"); + }); + + it("prefers sessionId over session_id", () => { + const result = deserialize({ sessionId: "sess_a", session_id: "sess_b" }); + expect(result?.sessionId).toBe("sess_a"); + }); + + it("trims whitespace from sessionId", () => { + const result = deserialize({ sessionId: " sess_abc " }); + expect(result?.sessionId).toBe("sess_abc"); + }); + + it("returns null for blank sessionId", () => { + expect(deserialize({ sessionId: "" })).toBeNull(); + expect(deserialize({ sessionId: " " })).toBeNull(); + }); + + it("maps cwd variants", () => { + expect(deserialize({ sessionId: "s", cwd: "/work" })?.cwd).toBe("/work"); + expect(deserialize({ sessionId: "s", workdir: "/work" })?.cwd).toBe("/work"); + expect(deserialize({ sessionId: "s", folder: "/work" })?.cwd).toBe("/work"); + }); + + it("prefers cwd over workdir and folder", () => { + const result = deserialize({ sessionId: "s", cwd: "/a", workdir: "/b", folder: "/c" }); + expect(result?.cwd).toBe("/a"); + }); + + it("maps workspaceId and workspace_id", () => { + expect(deserialize({ sessionId: "s", workspaceId: "ws1" })?.workspaceId).toBe("ws1"); + expect(deserialize({ sessionId: "s", workspace_id: "ws2" })?.workspaceId).toBe("ws2"); + }); + + it("maps repoUrl and repo_url", () => { + expect(deserialize({ sessionId: "s", repoUrl: "https://github.com/a/b" })?.repoUrl).toBe("https://github.com/a/b"); + expect(deserialize({ sessionId: "s", repo_url: "https://github.com/a/b" })?.repoUrl).toBe("https://github.com/a/b"); + }); + + it("maps repoRef and repo_ref", () => { + expect(deserialize({ sessionId: "s", repoRef: "main" })?.repoRef).toBe("main"); + expect(deserialize({ sessionId: "s", repo_ref: "develop" })?.repoRef).toBe("develop"); + }); + + it("omits undefined fields", () => { + const result = deserialize({ sessionId: "sess_abc" }); + expect(Object.keys(result!)).toEqual(["sessionId"]); + }); + + it("includes all available fields", () => { + const result = deserialize({ + sessionId: "sess_abc", + cwd: "/work", + workspaceId: "ws1", + repoUrl: "https://github.com/a/b", + repoRef: "main", + }); + expect(result).toEqual({ + sessionId: "sess_abc", + cwd: "/work", + workspaceId: "ws1", + repoUrl: "https://github.com/a/b", + repoRef: "main", + }); + }); + }); + + describe("serialize", () => { + it("returns null for null/undefined input", () => { + expect(serialize(null)).toBeNull(); + expect(serialize(undefined as unknown as null)).toBeNull(); + }); + + it("returns null when no sessionId", () => { + expect(serialize({})).toBeNull(); + expect(serialize({ cwd: "/work" })).toBeNull(); + }); + + it("serializes sessionId", () => { + expect(serialize({ sessionId: "sess_abc" })?.sessionId).toBe("sess_abc"); + }); + + it("falls back to session_id from params", () => { + const result = serialize({ session_id: "sess_abc" } as unknown as Record | null); + expect(result?.sessionId).toBe("sess_abc"); + }); + + it("maps all fields same as deserialize", () => { + const input: Record = { + sessionId: "sess_abc", + cwd: "/work", + workspaceId: "ws1", + repoUrl: "https://github.com/a/b", + repoRef: "main", + }; + expect(serialize(input)).toEqual(input); + }); + + it("omits undefined fields", () => { + const result = serialize({ sessionId: "sess_abc" }); + expect(result).not.toBeNull(); + expect(Object.keys(result!)).toEqual(["sessionId"]); + }); + }); + + describe("getDisplayId", () => { + it("returns null for null/undefined", () => { + expect(getDisplayId(null)).toBeNull(); + expect(getDisplayId(undefined as unknown as null)).toBeNull(); + }); + + it("returns sessionId when present", () => { + expect(getDisplayId({ sessionId: "sess_abc" })).toBe("sess_abc"); + }); + + it("falls back to session_id", () => { + expect(getDisplayId({ session_id: "sess_abc" })).toBe("sess_abc"); + }); + + it("prefers sessionId over session_id", () => { + expect(getDisplayId({ sessionId: "sess_a", session_id: "sess_b" })).toBe("sess_a"); + }); + + it("returns null when neither present", () => { + expect(getDisplayId({ cwd: "/work" })).toBeNull(); + }); + + it("trims whitespace", () => { + expect(getDisplayId({ sessionId: " sess_abc " })).toBe("sess_abc"); + }); + }); +}); diff --git a/src/ui-parser.test.ts b/src/ui-parser.test.ts new file mode 100644 index 0000000..13a3477 --- /dev/null +++ b/src/ui-parser.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect } from "vitest"; +import { parseStdoutLine } from "./ui-parser.js"; + +const ts = "2026-04-12T00:00:00.000Z"; + +function parse(line: string) { + return parseStdoutLine(line, ts); +} + +describe("parseStdoutLine", () => { + it("returns stdout entry for non-JSON input", () => { + const entries = parse("hello world"); + expect(entries).toEqual([{ kind: "stdout", ts, text: "hello world" }]); + }); + + it("returns stdout entry for null parse result", () => { + const entries = parse(" "); + expect(entries[0]?.kind).toBe("stdout"); + }); + + describe("system/init", () => { + it("parses init event", () => { + const entries = parse(JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-opus-4-6", + session_id: "sess_abc", + })); + expect(entries).toEqual([{ + kind: "init", + ts, + model: "claude-opus-4-6", + sessionId: "sess_abc", + }]); + }); + + it("handles missing model with default", () => { + const entries = parse(JSON.stringify({ type: "system", subtype: "init" })); + expect(entries[0]).toMatchObject({ kind: "init", model: "unknown" }); + }); + }); + + describe("assistant messages", () => { + it("parses text block", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hello there" }] }, + })); + expect(entries).toEqual([{ kind: "assistant", ts, text: "Hello there" }]); + }); + + it("skips empty text blocks", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "" }] }, + })); + // Empty content arrays fall back to stdout + expect(entries[0]?.kind).toBe("stdout"); + }); + + it("parses thinking block", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "thinking", thinking: "Let me think about this" }] }, + })); + expect(entries).toEqual([{ kind: "thinking", ts, text: "Let me think about this" }]); + }); + + it("skips empty thinking blocks", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "thinking", thinking: "" }] }, + })); + // Empty content arrays fall back to stdout + expect(entries[0]?.kind).toBe("stdout"); + }); + + it("parses tool_use block", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { + content: [{ + type: "tool_use", + name: "Bash", + input: { command: "ls -la" }, + id: "tool_123", + }], + }, + })); + expect(entries).toEqual([{ + kind: "tool_call", + ts, + name: "Bash", + input: { command: "ls -la" }, + toolUseId: "tool_123", + }]); + }); + + it("parses tool_use with tool_use_id fallback", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { + content: [{ + type: "tool_use", + name: "Bash", + tool_use_id: "tool_fallback", + input: {}, + }], + }, + })); + const entry = entries[0] as { kind: string; toolUseId?: string }; + expect(entry.kind).toBe("tool_call"); + expect(entry.toolUseId).toBe("tool_fallback"); + }); + + it("returns stdout as fallback for empty content", () => { + const entries = parse(JSON.stringify({ + type: "assistant", + message: { content: [] }, + })); + expect(entries[0]?.kind).toBe("stdout"); + }); + }); + + describe("user messages", () => { + it("parses user text block", () => { + const entries = parse(JSON.stringify({ + type: "user", + message: { content: [{ type: "text", text: "Hello Claude" }] }, + })); + expect(entries).toEqual([{ kind: "user", ts, text: "Hello Claude" }]); + }); + + it("parses tool_result block", () => { + const entries = parse(JSON.stringify({ + type: "user", + message: { + content: [{ + type: "tool_result", + tool_use_id: "tool_123", + content: "file1.txt\nfile2.txt", + }], + }, + })); + expect(entries).toEqual([{ + kind: "tool_result", + ts, + toolUseId: "tool_123", + content: "file1.txt\nfile2.txt", + isError: false, + }]); + }); + + it("marks tool_result as error when is_error is true", () => { + const entries = parse(JSON.stringify({ + type: "user", + message: { + content: [{ + type: "tool_result", + tool_use_id: "tool_123", + is_error: true, + content: "Permission denied", + }], + }, + })); + expect(entries[0]).toMatchObject({ kind: "tool_result", isError: true }); + }); + + it("handles text content array parts", () => { + const entries = parse(JSON.stringify({ + type: "user", + message: { + content: [{ + type: "tool_result", + tool_use_id: "tool_123", + content: [{ type: "text", text: "part1" }, { type: "text", text: "part2" }], + }], + }, + })); + const entry = entries[0] as { kind: string; content: string }; + expect(entry.kind).toBe("tool_result"); + expect(entry.content).toBe("part1\npart2"); + }); + }); + + describe("result", () => { + it("parses result with usage and cost", () => { + const entries = parse(JSON.stringify({ + type: "result", + result: "Task completed successfully", + subtype: "stop", + total_cost_usd: 0.0125, + usage: { + input_tokens: 500, + output_tokens: 300, + cache_read_input_tokens: 200, + }, + })); + expect(entries[0]).toMatchObject({ + kind: "result", + ts, + text: "Task completed successfully", + subtype: "stop", + costUsd: 0.0125, + inputTokens: 500, + outputTokens: 300, + cachedTokens: 200, + isError: false, + errors: [], + }); + }); + + it("marks result as error when is_error is true", () => { + const entries = parse(JSON.stringify({ + type: "result", + is_error: true, + errors: ["Something went wrong"], + })); + const entry = entries[0] as { kind: string; isError: boolean }; + expect(entry.kind).toBe("result"); + expect(entry.isError).toBe(true); + }); + + it("extracts errors array", () => { + const entries = parse(JSON.stringify({ + type: "result", + errors: ["error one", "error two"], + })); + const entry = entries[0] as { kind: string; errors: string[] }; + expect(entry.kind).toBe("result"); + expect(entry.errors).toEqual(["error one", "error two"]); + }); + + it("handles non-string errors", () => { + const entries = parse(JSON.stringify({ + type: "result", + errors: [{ message: "obj error" }], + })); + const entry = entries[0] as { kind: string; errors: string[] }; + expect(entry.kind).toBe("result"); + expect(entry.errors).toContain("obj error"); + }); + }); + + describe("stderr and system", () => { + it("passes through unknown types as stdout", () => { + const entries = parse(JSON.stringify({ type: "unknown", data: "stuff" })); + expect(entries[0]?.kind).toBe("stdout"); + }); + }); +});