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:
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
*.local
|
||||
.npmrc
|
||||
Generated
+1282
-5
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user