diff --git a/package.json b/package.json index f66b5a5..c1733f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paperclip-adapter-opencode-k8s", - "version": "0.1.28", + "version": "0.1.29", "description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs", "license": "MIT", "type": "module", diff --git a/src/cli/format-event.test.ts b/src/cli/format-event.test.ts index 40847ea..7423b61 100644 --- a/src/cli/format-event.test.ts +++ b/src/cli/format-event.test.ts @@ -248,3 +248,181 @@ describe("formatEvent", () => { }); }); }); + +import { parseStdoutLine } from "./format-event.js"; + +describe("parseStdoutLine (cli)", () => { + const TS = "2026-04-25T22:00:00.000Z"; + + it("returns empty for empty input", () => { + expect(parseStdoutLine("", TS)).toEqual([]); + expect(parseStdoutLine(" ", TS)).toEqual([]); + }); + + it("returns stdout entry for non-JSON input", () => { + expect(parseStdoutLine("plain log", TS)).toEqual([{ kind: "stdout", ts: TS, text: "plain log" }]); + }); + + it("returns stdout entry when JSON parses to a non-object primitive", () => { + expect(parseStdoutLine("42", TS)).toEqual([{ kind: "stdout", ts: TS, text: "42" }]); + }); + + it("renders a text event as an assistant delta", () => { + const line = JSON.stringify({ type: "text", part: { text: "Hello" } }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "Hello", delta: true }]); + }); + + it("returns empty for text event with empty text", () => { + const line = JSON.stringify({ type: "text", part: { text: "" } }); + expect(parseStdoutLine(line, TS)).toEqual([]); + }); + + it("renders tool_use status=error as tool_result with isError", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error", error: "boom" } } }); + expect(parseStdoutLine(line, TS)).toEqual([ + { kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "boom", isError: true }, + ]); + }); + + it("uses 'Tool error' fallback when error event has no error string", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error" } } }); + const result = parseStdoutLine(line, TS); + expect((result[0] as { content: string }).content).toBe("Tool error"); + }); + + it("renders tool_use status=completed as tool_result with output", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "completed", output: "ok" } } }); + expect(parseStdoutLine(line, TS)).toEqual([ + { kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "ok", isError: false }, + ]); + }); + + it("renders tool_use status=done — falls back to description when no output", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done", description: "did it" } } }); + expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("did it"); + }); + + it("renders tool_use status=done — falls back to 'Done' when no output or description", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done" } } }); + expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("Done"); + }); + + it("renders tool_use pending status as tool_call", () => { + const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "running", description: "go" } } }); + expect(parseStdoutLine(line, TS)).toEqual([ + { kind: "tool_call", ts: TS, name: "bash", input: "go", toolUseId: "t1" }, + ]); + }); + + it("falls back to part.type then 'tool' when no part.tool name", () => { + const line = JSON.stringify({ type: "tool_use", part: { type: "edit", state: { status: "running" } } }); + expect((parseStdoutLine(line, TS)[0] as { name: string }).name).toBe("edit"); + const line2 = JSON.stringify({ type: "tool_use", part: { state: { status: "running" } } }); + expect((parseStdoutLine(line2, TS)[0] as { name: string }).name).toBe("tool"); + }); + + it("renders step_finish with token/cost metrics", () => { + const line = JSON.stringify({ + type: "step_finish", + part: { + message: "did the thing", + reason: "stop", + tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 30 } }, + cost: 0.0123, + }, + }); + const result = parseStdoutLine(line, TS); + expect(result).toEqual([{ + kind: "result", + ts: TS, + text: "did the thing", + inputTokens: 100, + outputTokens: 60, + cachedTokens: 30, + costUsd: 0.0123, + subtype: "stop", + isError: false, + errors: [], + }]); + }); + + it("renders step_finish with default text when no message", () => { + const line = JSON.stringify({ type: "step_finish", part: { reason: "stop" } }); + expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toBe("Step finished: stop"); + const line2 = JSON.stringify({ type: "step_finish", part: {} }); + expect((parseStdoutLine(line2, TS)[0] as { text: string }).text).toBe("Step finished: done"); + }); + + it("renders step_start as a system entry", () => { + const line = JSON.stringify({ type: "step_start" }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "Starting step…" }]); + }); + + it("renders assistant event with nested text content", () => { + const line = JSON.stringify({ + type: "assistant", + part: { message: { content: [{ type: "text", text: "hi there" }] } }, + }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "hi there" }]); + }); + + it("handles assistant content as a single non-array object", () => { + const line = JSON.stringify({ + type: "assistant", + part: { message: { content: { type: "text", text: "single" } } }, + }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "single" }]); + }); + + it("returns empty for assistant event with no extractable text", () => { + const line = JSON.stringify({ type: "assistant", part: { message: { content: [{ type: "image" }] } } }); + expect(parseStdoutLine(line, TS)).toEqual([]); + const line2 = JSON.stringify({ type: "assistant", part: {} }); + expect(parseStdoutLine(line2, TS)).toEqual([]); + }); + + it("renders error event with errorText", () => { + const line = JSON.stringify({ type: "error", error: { message: "broken" } }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "broken" }]); + }); + + it("returns empty for error event with empty error string", () => { + const line = JSON.stringify({ type: "error", error: "" }); + expect(parseStdoutLine(line, TS)).toEqual([]); + }); + + it("uses error.code fallback in errorText", () => { + const line = JSON.stringify({ type: "error", error: { code: "E_X" } }); + expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "E_X" }]); + }); + + it("uses nested data.message and name fallbacks in errorText", () => { + const l1 = JSON.stringify({ type: "error", error: { data: { message: "nested" } } }); + expect((parseStdoutLine(l1, TS)[0] as { text: string }).text).toBe("nested"); + const l2 = JSON.stringify({ type: "error", error: { name: "ProviderErr" } }); + expect((parseStdoutLine(l2, TS)[0] as { text: string }).text).toBe("ProviderErr"); + }); + + it("falls back to JSON.stringify of the error object when nothing else matches", () => { + const line = JSON.stringify({ type: "error", error: { weirdKey: "x" } }); + expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toContain("weirdKey"); + }); + + it("returns empty array for unknown event types", () => { + const line = JSON.stringify({ type: "totally_unknown" }); + expect(parseStdoutLine(line, TS)).toEqual([]); + }); +}); + +describe("formatEvent — additional coverage", () => { + it("returns empty for safeJsonParse of a non-object primitive", () => { + // formatEvent treats a non-object as non-JSON and returns the trimmed line as-is + const result = formatEvent("42", false); + expect(result).toBe("42"); + }); + + it("returns empty for error event with empty error string", () => { + const line = JSON.stringify({ type: "error", error: "" }); + expect(formatEvent(line, false)).toBe(""); + }); +}); diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index 8d912c9..66621d1 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -1212,3 +1212,379 @@ describe("isK8s404", () => { expect(isK8s404(null)).toBe(false); }); }); + +describe("parseModelProvider", () => { + it("returns null for null input", async () => { + const { parseModelProvider } = await import("./execute.js"); + expect(parseModelProvider(null)).toBeNull(); + }); + + it("returns null when model has no slash separator", async () => { + const { parseModelProvider } = await import("./execute.js"); + expect(parseModelProvider("gpt-4")).toBeNull(); + expect(parseModelProvider(" ")).toBeNull(); + }); + + it("returns the provider segment from a slash-separated model id", async () => { + const { parseModelProvider } = await import("./execute.js"); + expect(parseModelProvider("anthropic/claude-opus-4")).toBe("anthropic"); + expect(parseModelProvider("openai/gpt-4o")).toBe("openai"); + }); + + it("trims whitespace inside the provider segment", async () => { + const { parseModelProvider } = await import("./execute.js"); + expect(parseModelProvider(" bedrock /claude")).toBe("bedrock"); + }); + + it("returns null when provider segment is whitespace only", async () => { + const { parseModelProvider } = await import("./execute.js"); + expect(parseModelProvider(" /model")).toBeNull(); + }); +}); + +describe("completionWithGrace", () => { + it("returns the completion result when it resolves before grace expires", async () => { + const { completionWithGrace } = await import("./execute.js"); + const result = await completionWithGrace( + Promise.resolve({ succeeded: true, timedOut: false, jobGone: false }), + 1000, + ); + expect(result).toEqual({ succeeded: true, timedOut: false, jobGone: false }); + }); + + it("returns timedOut result when grace expires first", async () => { + const { completionWithGrace } = await import("./execute.js"); + vi.useFakeTimers(); + try { + const slowCompletion = new Promise<{ succeeded: boolean; timedOut: boolean; jobGone: boolean }>(() => {}); + const racePromise = completionWithGrace(slowCompletion, 50); + await vi.advanceTimersByTimeAsync(60); + const result = await racePromise; + expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false }); + } finally { + vi.useRealTimers(); + } + }); + + it("returns timedOut result when completion promise rejects", async () => { + const { completionWithGrace } = await import("./execute.js"); + const result = await completionWithGrace(Promise.reject(new Error("boom")), 1000); + expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false }); + }); +}); + +describe("execute — config edge paths", () => { + it("logs a warning but continues when instructionsFilePath cannot be read", async () => { + const ctx = makeCtx({ instructionsFilePath: "/does/not/exist/AGENTS.md" }); + const result = await execute(ctx); + expect(result.errorCode).toBeUndefined(); + const logCalls = vi.mocked(ctx.onLog).mock.calls; + const warning = logCalls.find(([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("instructionsFilePath not readable")); + expect(warning).toBeDefined(); + }); + + it("returns k8s_job_create_failed when ensureAgentDbPvc throws (PVC create rejected)", async () => { + vi.mocked(getPvc).mockResolvedValueOnce(null); + vi.mocked(createPvc).mockRejectedValueOnce(new Error("storage class missing")); + const ctx = makeCtx({ + agentDbMode: "dedicated_pvc", + agentDbStorageClass: "fast", + }); + const result = await execute(ctx); + expect(result.errorCode).toBe("k8s_job_create_failed"); + expect(result.errorMessage).toContain("storage class missing"); + }); + + it("returns k8s_job_create_failed when ensureAgentDbPvc throws because storage class is missing", async () => { + vi.mocked(getPvc).mockResolvedValueOnce(null); + const ctx = makeCtx({ agentDbMode: "dedicated_pvc" }); + const result = await execute(ctx); + expect(result.errorCode).toBe("k8s_job_create_failed"); + expect(result.errorMessage).toContain("agentDbStorageClass is required"); + }); +}); + +describe("execute — large-prompt Secret create failure", () => { + const LARGE_PROMPT = "y".repeat(300 * 1024); + + it("returns k8s_job_create_failed when createNamespacedSecret throws", async () => { + vi.mocked(buildJobManifest).mockReturnValue({ + job: MOCK_JOB as ReturnType["job"], + jobName: JOB_NAME, + namespace: NAMESPACE, + prompt: LARGE_PROMPT, + opencodeArgs: [], + promptMetrics: null, + } as unknown as ReturnType); + + const coreApi = makeCoreApi(); + coreApi.createNamespacedSecret.mockRejectedValue(new Error("etcd full")); + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + + const ctx = makeCtx(); + const result = await execute(ctx); + + expect(result.errorCode).toBe("k8s_job_create_failed"); + expect(result.errorMessage).toContain("Failed to create prompt Secret"); + expect(result.errorMessage).toContain("etcd full"); + }); +}); + +describe("ensureAgentDbPvc — verification failure (FAR-85 belt-and-suspenders)", () => { + it("throws when getPvc returns null after createPvc resolved (verification failed)", async () => { + vi.mocked(getPvc) + .mockResolvedValueOnce(null) // first existence check: not found + .mockResolvedValueOnce(null); // post-create verification: still not found + vi.mocked(createPvc).mockResolvedValueOnce({} as never); + await expect( + ensureAgentDbPvc("agent-x", "ns-x", { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }), + ).rejects.toThrow(/PVC opencode-db-agent-x was not created/); + }); +}); + +describe("execute — step limit detection", () => { + it("logs that the step limit was reached when a step_finish event has reason=max_steps", async () => { + const STEP_LIMIT_JSONL = [ + JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }), + JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }), + ].join("\n"); + + const coreApi = makeCoreApi(STEP_LIMIT_JSONL, 0); + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + + const ctx = makeCtx(); + await execute(ctx); + + const logCalls = vi.mocked(ctx.onLog).mock.calls; + const limitLog = logCalls.find( + ([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("step limit reached"), + ); + expect(limitLog).toBeDefined(); + }); +}); + +describe("execute — waitForPod 'no pod yet' messaging", () => { + it("emits a 'Waiting for Job controller to create pod' log when pod is not yet present", async () => { + const coreApi = makeCoreApi(); + // First listNamespacedPod call returns empty (no pod yet), second returns Running + coreApi.listNamespacedPod = vi.fn() + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ + items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }], + }) + .mockResolvedValue({ + items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }], + }); + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + + const ctx = makeCtx(); + await execute(ctx); + + const logCalls = vi.mocked(ctx.onLog).mock.calls; + const waitLog = logCalls.find( + ([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("Waiting for Job controller to create pod"), + ); + expect(waitLog).toBeDefined(); + }); +}); + +describe("execute — pod scheduling failure (extra paths)", () => { + it("returns k8s_pod_schedule_failed when init container is in ImagePullBackOff", async () => { + const coreApi = { + listNamespacedPod: vi.fn().mockResolvedValue({ + items: [ + { + metadata: { name: POD_NAME }, + status: { + phase: "Pending", + initContainerStatuses: [ + { name: "write-prompt", state: { waiting: { reason: "ImagePullBackOff", message: "back-off" } } }, + ], + }, + }, + ], + }), + readNamespacedPodLog: vi.fn().mockResolvedValue(""), + }; + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + const result = await execute(makeCtx()); + expect(result.errorCode).toBe("k8s_pod_schedule_failed"); + expect(result.errorMessage).toMatch(/Init container.*image pull failed/); + }); + + it("returns k8s_pod_schedule_failed when init container is in CrashLoopBackOff", async () => { + const coreApi = { + listNamespacedPod: vi.fn().mockResolvedValue({ + items: [ + { + metadata: { name: POD_NAME }, + status: { + phase: "Pending", + initContainerStatuses: [ + { name: "write-prompt", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } }, + ], + }, + }, + ], + }), + readNamespacedPodLog: vi.fn().mockResolvedValue(""), + }; + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + const result = await execute(makeCtx()); + expect(result.errorCode).toBe("k8s_pod_schedule_failed"); + expect(result.errorMessage).toMatch(/Init container.*crash loop/); + }); + + it("returns k8s_pod_schedule_failed when main container is in CrashLoopBackOff", async () => { + const coreApi = { + listNamespacedPod: vi.fn().mockResolvedValue({ + items: [ + { + metadata: { name: POD_NAME }, + status: { + phase: "Pending", + containerStatuses: [ + { name: "opencode", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } }, + ], + }, + }, + ], + }), + readNamespacedPodLog: vi.fn().mockResolvedValue(""), + }; + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + const result = await execute(makeCtx()); + expect(result.errorCode).toBe("k8s_pod_schedule_failed"); + expect(result.errorMessage).toMatch(/crash loop/); + }); + + it("proceeds when all init containers terminated successfully and main is running", async () => { + const coreApi = { + listNamespacedPod: vi.fn() + .mockResolvedValueOnce({ + items: [ + { + metadata: { name: POD_NAME }, + status: { + phase: "Pending", + initContainerStatuses: [ + { name: "write-prompt", state: { terminated: { exitCode: 0 } } }, + ], + containerStatuses: [{ name: "opencode", state: { running: {} } }], + }, + }, + ], + }) + .mockResolvedValue({ + items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }], + }), + readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL), + }; + vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + const result = await execute(makeCtx()); + expect(result.errorCode).toBeUndefined(); + expect(result.exitCode).toBe(0); + }); +}); + +describe("execute — skill bundle source loading", () => { + it("reads SKILL.md from entry.source dir and bundles content into the prompt", async () => { + const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-test-")); + const skillDir = path.join(tmpDir, "skill-a"); + mkdirSync(skillDir); + writeFileSync(path.join(skillDir, "SKILL.md"), "skill A content"); + + const utils = await import("@paperclipai/adapter-utils/server-utils"); + vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([ + { key: "paperclip/skill-a", runtimeName: "skill-a", source: skillDir, required: true } as never, + ]); + + const ctx = makeCtx(); + await execute(ctx); + + // buildJobManifest should have received the skills bundle content + const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0]; + expect(buildArgs.skillsBundleContent).toContain("skill A content"); + }); + + it("falls back to reading entry.source as a file when SKILL.md path read throws", async () => { + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-flat-")); + const skillFile = path.join(tmpDir, "skill-b.md"); + writeFileSync(skillFile, "skill B flat content"); + + const utils = await import("@paperclipai/adapter-utils/server-utils"); + vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([ + { key: "paperclip/skill-b", runtimeName: "skill-b", source: skillFile, required: true } as never, + ]); + + const ctx = makeCtx(); + await execute(ctx); + + const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0]; + expect(buildArgs.skillsBundleContent).toContain("skill B flat content"); + }); +}); + +describe("execute — SIGTERM handler body (FAR-86 coverage)", () => { + it("invoking the captured SIGTERM handler deletes tracked Jobs and Secrets", async () => { + // Force a fresh module so sigtermHandlerInstalled starts false again. + vi.resetModules(); + vi.doMock("./k8s-client.js", () => ({ + getSelfPodInfo: vi.fn().mockResolvedValue(MOCK_SELF_POD), + getBatchApi: vi.fn(), + getCoreApi: vi.fn(), + getLogApi: vi.fn(), + getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-x" } }), + createPvc: vi.fn().mockResolvedValue({}), + })); + vi.doMock("./job-manifest.js", () => ({ + buildJobManifest: vi.fn().mockReturnValue({ + job: MOCK_JOB, + jobName: "fresh-job", + namespace: NAMESPACE, + prompt: "p", + opencodeArgs: [], + promptMetrics: null, + }), + LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, + })); + + const fresh = await import("./execute.js"); + const k8s = await import("./k8s-client.js"); + const batchApi = makeBatchApi(); + const coreApi = makeCoreApi(); + const logApi = makeLogApi(); + vi.mocked(k8s.getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); + vi.mocked(k8s.getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); + vi.mocked(k8s.getLogApi).mockReturnValue(logApi as unknown as ReturnType); + + let capturedHandler: (() => void) | null = null; + const onceSpy = vi.spyOn(process, "once").mockImplementation( + (event: string | symbol, handler: (...args: unknown[]) => void) => { + if (event === "SIGTERM") capturedHandler = handler as () => void; + return process; + }, + ); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + + await fresh.execute(makeCtx()); + onceSpy.mockRestore(); + + expect(capturedHandler).not.toBeNull(); + (capturedHandler as unknown as () => void)(); + // Wait long enough for the async handler body to settle + await new Promise((r) => setTimeout(r, 50)); + expect(batchApi.deleteNamespacedJob).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalled(); + + exitSpy.mockRestore(); + vi.doUnmock("./k8s-client.js"); + vi.doUnmock("./job-manifest.js"); + }); +}); diff --git a/src/server/execute.ts b/src/server/execute.ts index c428d47..1c7c684 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -37,7 +37,7 @@ export function isK8s404(err: unknown): boolean { return false; } -function parseModelProvider(model: string | null): string | null { +export function parseModelProvider(model: string | null): string | null { if (!model) return null; const trimmed = model.trim(); if (!trimmed.includes("/")) return null; diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index 9fb1373..db3a889 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -406,3 +406,110 @@ describe("sanitizeLabelValue", () => { expect(warned.length).toBe(1); }); }); + +describe("buildJobManifest — env wiring branches", () => { + it("sets PAPERCLIP_WAKE_PAYLOAD_JSON when paperclipWake is provided", () => { + const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWake: { reason: "issue_assigned", issue: { id: "x" } } } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_WAKE_PAYLOAD_JSON")?.value).toBeTruthy(); + }); + + it("forwards workspace context and AGENT_HOME from paperclipWorkspace", () => { + const ctx = { + ...mockCtx, + context: { + ...mockCtx.context, + paperclipWorkspace: { + cwd: "/work", + source: "main", + strategy: "shared", + workspaceId: "ws_1", + repoUrl: "https://example.com/r.git", + repoRef: "main", + branchName: "feature/x", + worktreePath: "/wt/x", + agentHome: "/home/agent", + }, + }, + }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_CWD")?.value).toBe("/work"); + expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_BRANCH")?.value).toBe("feature/x"); + expect(env.find((e) => e.name === "AGENT_HOME")?.value).toBe("/home/agent"); + }); + + it("sets PAPERCLIP_LINKED_ISSUE_IDS from non-empty issueIds array (skipping blanks)", () => { + const ctx = { ...mockCtx, context: { ...mockCtx.context, issueIds: ["a", " ", "b", null as unknown as string, "c"] } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_LINKED_ISSUE_IDS")?.value).toBe("a,b,c"); + }); + + it("encodes paperclipWorkspaces / paperclipRuntimeServiceIntents / paperclipRuntimeServices as JSON env", () => { + const ctx = { + ...mockCtx, + context: { + ...mockCtx.context, + paperclipWorkspaces: [{ id: "w1" }], + paperclipRuntimeServiceIntents: [{ name: "redis" }], + paperclipRuntimeServices: [{ name: "redis", url: "redis://r" }], + paperclipRuntimePrimaryUrl: "https://primary", + }, + }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACES_JSON")?.value).toContain("w1"); + expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON")?.value).toContain("redis"); + expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICES_JSON")?.value).toContain("redis://r"); + expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_PRIMARY_URL")?.value).toBe("https://primary"); + }); + + it("sets PAPERCLIP_API_KEY from ctx.authToken when provided", () => { + const ctx = { ...mockCtx, authToken: "tok_abc" }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc"); + }); + + it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => { + const selfPod = { + ...mockSelfPod, + inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" }, + }; + const result = buildJobManifest({ ctx: mockCtx, selfPod }); + const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; + expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api"); + expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key"); + }); +}); + +describe("buildJobManifest — volume wiring branches", () => { + it("mounts the prompt secret volume when promptSecretName is provided", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, promptSecretName: "prompt-x" }); + const volumes = result.job.spec?.template.spec?.volumes ?? []; + expect(volumes.find((v) => v.name === "prompt-secret")?.secret?.secretName).toBe("prompt-x"); + }); + + it("mounts the data PVC at /paperclip when selfPod has a pvcClaimName", () => { + const selfPod = { ...mockSelfPod, pvcClaimName: "paperclip-data" }; + const result = buildJobManifest({ ctx: mockCtx, selfPod }); + const volumes = result.job.spec?.template.spec?.volumes ?? []; + expect(volumes.find((v) => v.name === "data")?.persistentVolumeClaim?.claimName).toBe("paperclip-data"); + const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? []; + expect(mounts.find((m) => m.name === "data")?.mountPath).toBe("/paperclip"); + }); + + it("mounts inherited secret volumes from selfPod.secretVolumes", () => { + const selfPod = { + ...mockSelfPod, + secretVolumes: [{ volumeName: "tls", secretName: "tls-secret", mountPath: "/etc/tls", defaultMode: 0o400 }], + }; + const result = buildJobManifest({ ctx: mockCtx, selfPod }); + const volumes = result.job.spec?.template.spec?.volumes ?? []; + expect(volumes.find((v) => v.name === "tls")?.secret?.secretName).toBe("tls-secret"); + const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? []; + expect(mounts.find((m) => m.name === "tls")).toEqual({ name: "tls", mountPath: "/etc/tls", readOnly: true }); + }); +}); diff --git a/src/server/k8s-client.test.ts b/src/server/k8s-client.test.ts index 5ec0042..bfb33f2 100644 --- a/src/server/k8s-client.test.ts +++ b/src/server/k8s-client.test.ts @@ -28,13 +28,14 @@ vi.mock("@kubernetes/client-node", () => { } } class KubeConfig { - loadFromCluster() {} - loadFromFile() {} + loadFromCluster = mockLoadFromCluster; + loadFromFile = mockLoadFromFile; makeApiClient() { return { readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC, deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC, createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC, + readNamespacedPod: mockReadNamespacedPod, }; } } @@ -51,9 +52,17 @@ vi.mock("@kubernetes/client-node", () => { const mockReadNamespacedPVC = vi.fn(); const mockDeleteNamespacedPVC = vi.fn(); const mockCreateNamespacedPVC = vi.fn(); +const mockReadNamespacedPod = vi.fn(); +const mockLoadFromCluster = vi.fn(); +const mockLoadFromFile = vi.fn(); +const mockReadFileSync = vi.fn(); + +vi.mock("node:fs", () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); import * as k8s from "@kubernetes/client-node"; -import { getPvc, createPvc, deletePvc, resetCache } from "./k8s-client.js"; +import { getPvc, createPvc, deletePvc, getSelfPodInfo, resetCache } from "./k8s-client.js"; const ApiException = (k8s as unknown as { ApiException: new (code: number, message: string, body: T, headers?: Record) => Error & { code: number; body: T } }).ApiException; @@ -143,3 +152,145 @@ describe("createPvc — passes through to SDK", () => { expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec }); }); }); + +describe("getSelfPodInfo", () => { + const HOSTNAME = "paperclip-test-pod"; + const NAMESPACE = "paperclip-test"; + + beforeEach(() => { + process.env.HOSTNAME = HOSTNAME; + delete process.env.PAPERCLIP_NAMESPACE; + delete process.env.POD_NAMESPACE; + mockReadFileSync.mockReturnValue(NAMESPACE); + }); + + function basePod(overrides: Record = {}) { + return { + spec: { + containers: [ + { + name: "paperclip", + image: "paperclip:1.0", + env: [ + { name: "FOO", value: "bar" }, + { name: "SECRET_REF", valueFrom: { secretKeyRef: { name: "s", key: "k" } } }, + ], + envFrom: [{ configMapRef: { name: "cm" } }], + volumeMounts: [ + { name: "data", mountPath: "/paperclip" }, + { name: "tls-secret", mountPath: "/etc/tls" }, + ], + }, + ], + volumes: [ + { name: "data", persistentVolumeClaim: { claimName: "paperclip-pvc" } }, + { name: "tls-secret", secret: { secretName: "tls", defaultMode: 0o400 } }, + ], + imagePullSecrets: [{ name: "registry-creds" }, { name: "" }, {}], + dnsConfig: { nameservers: ["10.0.0.10"] }, + ...overrides, + }, + }; + } + + it("introspects the pod and extracts image, env, PVC, secrets, dnsConfig", async () => { + mockReadNamespacedPod.mockResolvedValue(basePod()); + const info = await getSelfPodInfo(); + expect(info.namespace).toBe(NAMESPACE); + expect(info.image).toBe("paperclip:1.0"); + expect(info.pvcClaimName).toBe("paperclip-pvc"); + expect(info.inheritedEnv).toEqual({ FOO: "bar" }); + expect(info.inheritedEnvValueFrom).toHaveLength(1); + expect(info.inheritedEnvValueFrom[0].name).toBe("SECRET_REF"); + expect(info.inheritedEnvFrom).toHaveLength(1); + expect(info.secretVolumes).toEqual([ + { volumeName: "tls-secret", secretName: "tls", mountPath: "/etc/tls", defaultMode: 0o400 }, + ]); + // imagePullSecrets with empty name are filtered out + expect(info.imagePullSecrets).toEqual([{ name: "registry-creds" }]); + expect(info.dnsConfig).toEqual({ nameservers: ["10.0.0.10"] }); + expect(mockReadNamespacedPod).toHaveBeenCalledWith({ name: HOSTNAME, namespace: NAMESPACE }); + }); + + it("caches the result — second call does not re-query the API", async () => { + mockReadNamespacedPod.mockResolvedValue(basePod()); + await getSelfPodInfo(); + await getSelfPodInfo(); + expect(mockReadNamespacedPod).toHaveBeenCalledTimes(1); + }); + + it("prefers PAPERCLIP_NAMESPACE env over service-account file", async () => { + process.env.PAPERCLIP_NAMESPACE = "from-env"; + mockReadNamespacedPod.mockResolvedValue(basePod()); + const info = await getSelfPodInfo(); + expect(info.namespace).toBe("from-env"); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it("falls back to POD_NAMESPACE when PAPERCLIP_NAMESPACE not set", async () => { + process.env.POD_NAMESPACE = "downward-api"; + mockReadNamespacedPod.mockResolvedValue(basePod()); + const info = await getSelfPodInfo(); + expect(info.namespace).toBe("downward-api"); + }); + + it("falls back to 'default' when service-account file read throws", async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + mockReadNamespacedPod.mockResolvedValue(basePod()); + const info = await getSelfPodInfo(); + expect(info.namespace).toBe("default"); + }); + + it("throws when HOSTNAME is not set", async () => { + delete process.env.HOSTNAME; + await expect(getSelfPodInfo()).rejects.toThrow("HOSTNAME env var not set"); + }); + + it("throws when pod has no spec", async () => { + mockReadNamespacedPod.mockResolvedValue({ spec: null }); + await expect(getSelfPodInfo()).rejects.toThrow("has no spec"); + }); + + it("throws when main container has no image", async () => { + mockReadNamespacedPod.mockResolvedValue({ + spec: { containers: [{ name: "paperclip", image: "" }] }, + }); + await expect(getSelfPodInfo()).rejects.toThrow("has no container image"); + }); + + it("falls back to first container when no container is named 'paperclip'", async () => { + mockReadNamespacedPod.mockResolvedValue({ + spec: { containers: [{ name: "other", image: "other:1.0" }] }, + }); + const info = await getSelfPodInfo(); + expect(info.image).toBe("other:1.0"); + }); + + it("returns null pvcClaimName when no /paperclip mount exists", async () => { + mockReadNamespacedPod.mockResolvedValue({ + spec: { containers: [{ name: "paperclip", image: "p:1", volumeMounts: [] }] }, + }); + const info = await getSelfPodInfo(); + expect(info.pvcClaimName).toBeNull(); + }); + + it("returns null pvcClaimName when /paperclip mount is not backed by a PVC", async () => { + mockReadNamespacedPod.mockResolvedValue({ + spec: { + containers: [{ name: "paperclip", image: "p:1", volumeMounts: [{ name: "data", mountPath: "/paperclip" }] }], + volumes: [{ name: "data", emptyDir: {} }], + }, + }); + const info = await getSelfPodInfo(); + expect(info.pvcClaimName).toBeNull(); + }); + + it("uses kubeconfig file path when provided (not in-cluster)", async () => { + mockReadNamespacedPod.mockResolvedValue(basePod()); + await getSelfPodInfo("/tmp/kubeconfig.yaml"); + expect(mockLoadFromFile).toHaveBeenCalledWith("/tmp/kubeconfig.yaml"); + expect(mockLoadFromCluster).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/parse.test.ts b/src/server/parse.test.ts index d934dc8..3d4a85f 100644 --- a/src/server/parse.test.ts +++ b/src/server/parse.test.ts @@ -182,3 +182,45 @@ describe("isOpenCodeUnknownSessionError", () => { expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true); }); }); + +describe("parseOpenCodeJsonl — errorText fallback paths", () => { + it("uses nested data.message when top-level message is missing", () => { + const stdout = JSON.stringify({ + type: "error", + error: { data: { message: "nested issue" } }, + sessionID: "ses_x", + }); + const result = parseOpenCodeJsonl(stdout); + expect(result.errorMessage).toContain("nested issue"); + }); + + it("uses error.name when no message or nested message", () => { + const stdout = JSON.stringify({ + type: "error", + error: { name: "ProviderAuthError" }, + sessionID: "ses_x", + }); + const result = parseOpenCodeJsonl(stdout); + expect(result.errorMessage).toContain("ProviderAuthError"); + }); + + it("uses error.code when no message/name", () => { + const stdout = JSON.stringify({ + type: "error", + error: { code: "E_TIMEOUT" }, + sessionID: "ses_x", + }); + const result = parseOpenCodeJsonl(stdout); + expect(result.errorMessage).toContain("E_TIMEOUT"); + }); + + it("falls back to JSON.stringify of the error object when nothing matches", () => { + const stdout = JSON.stringify({ + type: "error", + error: { unexpectedShape: { foo: "bar" } }, + sessionID: "ses_x", + }); + const result = parseOpenCodeJsonl(stdout); + expect(result.errorMessage).toContain("unexpectedShape"); + }); +}); diff --git a/src/ui-parser.test.ts b/src/ui-parser.test.ts index 7b12968..ac34d75 100644 --- a/src/ui-parser.test.ts +++ b/src/ui-parser.test.ts @@ -323,3 +323,41 @@ describe("parseStdoutLine", () => { expect(parseStdoutLine(line, TS)).toEqual([]); }); }); + +describe("parseStdoutLine — error edge cases", () => { + const TS_ERR = "2026-04-25T22:00:00.000Z"; + + it("returns stdout entry when JSON parses to a primitive (not an object)", () => { + const result = parseStdoutLine("42", TS_ERR); + // safeJsonParse returns null for non-object → falls through to stdout entry + expect(result).toEqual([{ kind: "stdout", ts: TS_ERR, text: "42" }]); + }); + + it("returns empty for text event with empty text", () => { + const line = JSON.stringify({ type: "text", part: { text: "" } }); + expect(parseStdoutLine(line, TS_ERR)).toEqual([]); + }); + + it("returns empty for assistant event with no content blocks", () => { + const line = JSON.stringify({ type: "assistant", part: { message: { content: null } } }); + expect(parseStdoutLine(line, TS_ERR)).toEqual([]); + }); + + it("returns empty for error event whose error field is an empty string", () => { + const line = JSON.stringify({ type: "error", error: "" }); + expect(parseStdoutLine(line, TS_ERR)).toEqual([]); + }); + + it("uses error.code fallback when error has no message/data/name", () => { + const line = JSON.stringify({ type: "error", error: { code: "E_FOO" } }); + const result = parseStdoutLine(line, TS_ERR); + expect(result).toEqual([{ kind: "stderr", ts: TS_ERR, text: "E_FOO" }]); + }); + + it("falls back to JSON.stringify of error object when no known field", () => { + const line = JSON.stringify({ type: "error", error: { somethingElse: "x" } }); + const result = parseStdoutLine(line, TS_ERR); + expect(result[0].kind).toBe("stderr"); + expect((result[0] as { text: string }).text).toContain("somethingElse"); + }); +});