Add tests, free-text model field, and K8s job improvements

- Add vitest with 26 passing tests for parse and job-manifest
- Set models to undefined for free-text model input
- Add fsGroupChangePolicy: "OnRootMismatch" to reduce volume chown delays
- Change job name prefix to agent-opencode- for adapter identification
- Add .npmrc to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 08:14:57 -04:00
parent 75f54b1edc
commit 6866da42bf
8 changed files with 1556 additions and 17 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
node_modules/
dist/
.DS_Store
*.local
*.local
.npmrc
+1282 -5
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@farhoodliquor/paperclip-adapter-opencode-k8s",
"version": "0.1.0",
"version": "0.1.5",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
@@ -18,7 +18,9 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@kubernetes/client-node": "^1.0.0"
@@ -29,6 +31,7 @@
"devDependencies": {
"@paperclipai/adapter-utils": "^0.3.0",
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"vitest": "^4.1.4"
}
}
+1 -7
View File
@@ -1,13 +1,7 @@
export const type = "opencode_k8s";
export const label = "OpenCode (Kubernetes)";
export const models = [
{ id: "openai/gpt-5.2-codex", label: "openai/gpt-5.2-codex" },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const models: undefined = undefined;
export const agentConfigurationDoc = `# opencode_k8s agent configuration
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { buildJobManifest, type JobBuildInput } from "./job-manifest.js";
const mockSelfPod: JobBuildInput["selfPod"] = {
namespace: "paperclip",
image: "paperclip/paperclip:latest",
imagePullSecrets: [],
inheritedEnv: {},
pvcClaimName: null,
dnsConfig: undefined,
secretVolumes: [],
};
const mockCtx: JobBuildInput["ctx"] = {
runId: "run123456",
agent: { id: "agent-abc", name: "Test Agent", companyId: "co123", adapterType: null, adapterConfig: null },
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
config: {},
context: {
taskId: null,
issueId: null,
paperclipWorkspace: null,
issueIds: null,
paperclipWorkspaces: null,
paperclipRuntimeServiceIntents: null,
paperclipRuntimeServices: null,
},
onLog: async () => {},
};
describe("buildJobManifest", () => {
it("creates job with agent-opencode- prefix in name", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
expect(result.jobName).toMatch(/^agent-opencode-/);
});
it("uses default image from selfPod", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const container = result.job.spec?.template?.spec?.containers?.[0];
expect(container?.image).toBe("paperclip/paperclip:latest");
});
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const securityContext = result.job.spec?.template?.spec?.securityContext;
expect(securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch");
});
it("sets runAsNonRoot and runAsUser 1000", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const securityContext = result.job.spec?.template?.spec?.securityContext;
expect(securityContext?.runAsNonRoot).toBe(true);
expect(securityContext?.runAsUser).toBe(1000);
expect(securityContext?.runAsGroup).toBe(1000);
expect(securityContext?.fsGroup).toBe(1000);
});
it("maps labels to job metadata", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
expect(result.job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip");
expect(result.job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("opencode_k8s");
expect(result.job.metadata?.labels?.["paperclip.io/agent-id"]).toBe("agent-abc");
expect(result.job.metadata?.labels?.["paperclip.io/run-id"]).toBe("run123456");
});
it("creates init container for prompt", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const initContainers = result.job.spec?.template?.spec?.initContainers;
expect(initContainers?.length).toBe(1);
expect(initContainers?.[0].name).toBe("write-prompt");
expect(initContainers?.[0].image).toBe("busybox:1.36");
});
it("sets HOME to /paperclip", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
const homeEnv = env.find((e) => e.name === "HOME");
expect(homeEnv?.value).toBe("/paperclip");
});
it("sets OPENCODE_DISABLE_PROJECT_CONFIG=true", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
const opencodeEnv = env.find((e) => e.name === "OPENCODE_DISABLE_PROJECT_CONFIG");
expect(opencodeEnv?.value).toBe("true");
});
it("applies default ttlSecondsAfterFinished of 300", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
expect(result.job.spec?.ttlSecondsAfterFinished).toBe(300);
});
it("sets backoffLimit to 0", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
expect(result.job.spec?.backoffLimit).toBe(0);
});
it("uses job template restartPolicy Never", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
expect(result.job.spec?.template?.spec?.restartPolicy).toBe("Never");
});
});
+2 -1
View File
@@ -187,7 +187,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
// Job naming
const agentSlug = sanitizeForK8sName(agent.id);
const runSlug = sanitizeForK8sName(runId);
const jobName = `agent-${agentSlug}-${runSlug}`;
const jobName = `agent-opencode-${agentSlug}-${runSlug}`;
// Build prompt
const promptTemplate = asString(
@@ -306,6 +306,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
fsGroupChangePolicy: "OnRootMismatch",
};
// Build the main container command
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
describe("parseOpenCodeJsonl", () => {
it("parses text messages", () => {
const stdout = [
JSON.stringify({ type: "text", part: { text: "Hello" }, sessionID: "ses_123" }),
JSON.stringify({ type: "text", part: { text: "World" }, sessionID: "ses_123" }),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.sessionId).toBe("ses_123");
expect(result.summary).toBe("Hello\n\nWorld");
expect(result.errorMessage).toBeNull();
});
it("accumulates usage from step_finish events", () => {
const stdout = [
JSON.stringify({
type: "step_finish",
part: { tokens: { input: 100, output: 50, reasoning: 20, cache: { read: 80 } }, cost: 0.001 },
}),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.usage.inputTokens).toBe(100);
expect(result.usage.cachedInputTokens).toBe(80);
expect(result.usage.outputTokens).toBe(70);
expect(result.costUsd).toBeCloseTo(0.001);
});
it("captures errors from error type events", () => {
const stdout = [
JSON.stringify({ type: "error", error: { message: "Something went wrong" } }),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toBe("Something went wrong");
});
it("captures tool_use errors with error state", () => {
const stdout = [
JSON.stringify({
type: "tool_use",
part: { state: { status: "error", error: "Tool failed" } },
}),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toBe("Tool failed");
});
it("extracts sessionId from any event", () => {
const stdout = [
JSON.stringify({ type: "text", part: { text: "Hi" }, sessionID: "ses_abc" }),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.sessionId).toBe("ses_abc");
});
it("handles empty stdout", () => {
const result = parseOpenCodeJsonl("");
expect(result.sessionId).toBeNull();
expect(result.summary).toBe("");
expect(result.errorMessage).toBeNull();
});
it("skips malformed JSON lines", () => {
const stdout = [
"not json at all",
JSON.stringify({ type: "text", part: { text: "Valid" }, sessionID: "ses_1" }),
"",
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.summary).toBe("Valid");
});
it("combines multiple errors", () => {
const stdout = [
JSON.stringify({ type: "error", error: { message: "Error 1" } }),
JSON.stringify({ type: "error", error: { message: "Error 2" } }),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toBe("Error 1\nError 2");
});
it("parses nested error message in data field", () => {
const stdout = [
JSON.stringify({ type: "error", error: { data: { message: "Nested error" } } }),
].join("\n");
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toBe("Nested error");
});
});
describe("isOpenCodeUnknownSessionError", () => {
it("detects 'unknown session' in stdout", () => {
const stdout = "Error: unknown session";
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
it("detects 'session not found' in stdout", () => {
const stdout = "session not found";
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
it("detects 'resource not found' with session path in stdout", () => {
const stdout = "resource not found: /session/abc.json";
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
it("detects 'no session' in combined output", () => {
const stdout = "";
const stderr = "no session available";
expect(isOpenCodeUnknownSessionError(stdout, stderr)).toBe(true);
});
it("returns false for normal errors", () => {
const stdout = "Something went wrong";
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(false);
});
it("handles case insensitivity", () => {
const stdout = "UNKNOWN SESSION";
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
coverage: {
reporter: ["text", "json", "html"],
},
},
});