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";
|
||||
@@ -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> = {}): 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> = {}): 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -24,16 +24,6 @@ export interface SelfPodInfo {
|
||||
inheritedEnv: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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<SelfPodIn
|
||||
}
|
||||
}
|
||||
|
||||
// Collect inherited env vars from process.env (these came from the Deployment spec)
|
||||
// Collect env vars from the pod spec's container definition.
|
||||
// Agent config env (set in buildEnvVars) will override these.
|
||||
const inheritedEnv: Record<string, string> = {};
|
||||
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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null);
|
||||
expect(result?.sessionId).toBe("sess_abc");
|
||||
});
|
||||
|
||||
it("maps all fields same as deserialize", () => {
|
||||
const input: Record<string, unknown> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user