feat: inherit valueFrom/envFrom env from Deployment; prefer paperclip container

- SelfPodInfo gains inheritedEnvValueFrom (V1EnvVar[]) and inheritedEnvFrom (V1EnvFromSource[])
- Container selection now prefers the container named "paperclip", falls back to first
- buildJobManifest appends valueFrom env vars (skipping names already overridden)
  and sets envFrom on the opencode container when present
- Tests updated: mock updated, 5 new cases covering secretKeyRef forwarding,
  dedup, envFrom passthrough, and empty-envFrom omission

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-24 22:12:31 +00:00
parent 84dc0f5930
commit 61d2a42a66
11 changed files with 1367 additions and 60 deletions
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from "vitest";
import { formatEvent } from "./format-event.js";
describe("formatEvent", () => {
describe("empty / non-JSON input", () => {
it("returns empty string for empty line", () => {
expect(formatEvent("", false)).toBe("");
});
it("returns empty string for whitespace-only line", () => {
expect(formatEvent(" ", false)).toBe("");
});
it("returns non-JSON line as-is (trimmed)", () => {
expect(formatEvent("plain text output", false)).toBe("plain text output");
});
it("trims whitespace from non-JSON lines", () => {
expect(formatEvent(" trimmed ", false)).toBe("trimmed");
});
});
describe("step_start", () => {
it("returns empty string in normal mode", () => {
const line = JSON.stringify({ type: "step_start", sessionID: "ses_1" });
expect(formatEvent(line, false)).toBe("");
});
it("returns [step_start] with session in debug mode", () => {
const line = JSON.stringify({ type: "step_start", sessionID: "ses_1" });
expect(formatEvent(line, true)).toBe("[step_start] session=ses_1");
});
it("returns [step_start] without session suffix when sessionID absent in debug mode", () => {
const line = JSON.stringify({ type: "step_start" });
expect(formatEvent(line, true)).toBe("[step_start]");
});
});
describe("text", () => {
it("returns text content", () => {
const line = JSON.stringify({ type: "text", part: { text: "Hello world" } });
expect(formatEvent(line, false)).toBe("Hello world");
});
it("returns trimmed text", () => {
const line = JSON.stringify({ type: "text", part: { text: " trimmed " } });
expect(formatEvent(line, false)).toBe("trimmed");
});
it("returns empty string for empty text field", () => {
const line = JSON.stringify({ type: "text", part: { text: "" } });
expect(formatEvent(line, false)).toBe("");
});
it("returns same output in debug mode", () => {
const line = JSON.stringify({ type: "text", part: { text: "Debug output" } });
expect(formatEvent(line, true)).toBe("Debug output");
});
});
describe("tool_use", () => {
it("returns empty for normal tool_use in non-debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "pending", description: "ls" } },
});
expect(formatEvent(line, false)).toBe("");
});
it("returns empty for completed tool_use in non-debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "completed", output: "result" } },
});
expect(formatEvent(line, false)).toBe("");
});
it("returns warning with ⚠ prefix for tool error in non-debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "error", error: "Command failed" } },
});
expect(formatEvent(line, false)).toBe("⚠ Command failed");
});
it("returns empty for tool error with empty error field in non-debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "error", error: "" } },
});
expect(formatEvent(line, false)).toBe("");
});
it("returns debug info including tool name and status in debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "grep", state: { status: "completed", description: "search files" } },
});
const result = formatEvent(line, true);
expect(result).toContain("[tool:grep]");
expect(result).toContain("completed");
expect(result).toContain("search files");
});
it("appends output snippet in debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "completed", output: "output result here" } },
});
const result = formatEvent(line, true);
expect(result).toContain("output result here");
});
it("appends error in debug mode", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "error", error: "exit code 1" } },
});
const result = formatEvent(line, true);
expect(result).toContain("✗ exit code 1");
});
});
describe("step_finish", () => {
it("returns message when provided", () => {
const line = JSON.stringify({
type: "step_finish",
part: { message: "Task complete", reason: "end_turn" },
});
expect(formatEvent(line, false)).toBe("Task complete");
});
it("returns fallback with reason when message is empty", () => {
const line = JSON.stringify({
type: "step_finish",
part: { reason: "end_turn", message: "" },
});
expect(formatEvent(line, false)).toBe("[step_finish] end_turn");
});
it("returns fallback with empty reason when both message and reason absent", () => {
const line = JSON.stringify({ type: "step_finish", part: {} });
expect(formatEvent(line, false)).toBe("[step_finish] ");
});
it("appends token count when non-zero", () => {
const line = JSON.stringify({
type: "step_finish",
part: { message: "Done", tokens: { total: 500 }, cost: 0 },
});
const result = formatEvent(line, false);
expect(result).toContain("tokens=500");
});
it("appends cost when non-zero", () => {
const line = JSON.stringify({
type: "step_finish",
part: { message: "Done", tokens: { total: 0 }, cost: 0.0025 },
});
const result = formatEvent(line, false);
expect(result).toContain("cost$0.0025");
});
it("appends both tokens and cost when both non-zero", () => {
const line = JSON.stringify({
type: "step_finish",
part: { message: "Done", tokens: { total: 300 }, cost: 0.001 },
});
const result = formatEvent(line, false);
expect(result).toContain("tokens=300");
expect(result).toContain("cost$0.0010");
});
it("omits metrics suffix when tokens and cost are zero", () => {
const line = JSON.stringify({
type: "step_finish",
part: { message: "Done", tokens: { total: 0 }, cost: 0 },
});
expect(formatEvent(line, false)).toBe("Done");
});
});
describe("error", () => {
it("returns error message with ✗ prefix", () => {
const line = JSON.stringify({ type: "error", error: { message: "Something failed" } });
expect(formatEvent(line, false)).toBe("✗ Something failed");
});
it("returns ✗ prefix with string error", () => {
const line = JSON.stringify({ type: "error", message: "Direct error" });
const result = formatEvent(line, false);
expect(result).toContain("✗");
});
it("returns empty string for error with no extractable text", () => {
const line = JSON.stringify({ type: "error" });
const result = formatEvent(line, false);
expect(typeof result).toBe("string");
});
});
describe("assistant", () => {
it("returns nested text content", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "text", text: "Assistant response" }] } },
});
expect(formatEvent(line, false)).toBe("Assistant response");
});
it("returns trimmed nested text", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "text", text: " Trimmed " }] } },
});
expect(formatEvent(line, false)).toBe("Trimmed");
});
it("returns empty for non-text content blocks", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "tool_use" }] } },
});
expect(formatEvent(line, false)).toBe("");
});
it("returns empty for assistant with no content", () => {
const line = JSON.stringify({ type: "assistant", part: {} });
expect(formatEvent(line, false)).toBe("");
});
});
describe("unknown types", () => {
it("returns empty string for unknown type in non-debug mode", () => {
const line = JSON.stringify({ type: "some_unknown_type", data: {} });
expect(formatEvent(line, false)).toBe("");
});
it("returns [type] for unknown type in debug mode", () => {
const line = JSON.stringify({ type: "some_unknown_type" });
expect(formatEvent(line, true)).toBe("[some_unknown_type]");
});
it("returns empty string for JSON with no type in non-debug mode", () => {
const line = JSON.stringify({ sessionID: "ses_123" });
expect(formatEvent(line, false)).toBe("");
});
});
});
+1 -1
View File
@@ -133,7 +133,7 @@ export function formatEvent(line: string, debug: boolean): string {
}
case "error": {
const text = errorText(event).trim();
const text = errorText(event.error ?? event.message ?? event).trim();
if (text) return `${text}`;
return "";
}
+2
View File
@@ -23,6 +23,8 @@ const MOCK_SELF_POD = {
pvcClaimName: null,
secretVolumes: [],
inheritedEnv: {},
inheritedEnvValueFrom: [],
inheritedEnvFrom: [],
};
const MOCK_JOB = {
+257 -49
View File
@@ -9,10 +9,19 @@ import {
} from "./parse.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
import { buildJobManifest } from "./job-manifest.js";
import { LogLineDedupFilter } from "./log-dedup.js";
import type * as k8s from "@kubernetes/client-node";
import { Writable } from "node:stream";
const POLL_INTERVAL_MS = 2000;
const KEEPALIVE_INTERVAL_MS = 15_000;
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
// Upper bound on how long streamPodLogsOnce will wait after stopSignal fires
// before force-returning, even if logApi.log has not yet resolved. Defensive
// against the K8s client library not propagating writable.destroy() into an
// abort of the underlying HTTP request.
const LOG_STREAM_BAIL_TIMEOUT_MS = 3_000;
const LOG_EXIT_COMPLETION_GRACE_MS = parseInt(process.env.LOG_EXIT_COMPLETION_GRACE_MS ?? "30000", 10);
export function isK8s404(err: unknown): boolean {
@@ -132,55 +141,157 @@ async function waitForPod(
throw new Error(`Timed out waiting for pod to be scheduled (${Math.round(timeoutMs / 1000)}s)`);
}
/**
* Stream pod logs once via follow. Returns accumulated stdout when the
* stream ends (container exit, API disconnect, or abort signal).
*/
async function streamPodLogsOnce(
namespace: string,
podName: string,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
sinceSeconds?: number,
dedup?: LogLineDedupFilter,
stopSignal?: { stopped: boolean },
): Promise<string> {
const logApi = getLogApi(kubeconfigPath);
const chunks: string[] = [];
const writable = new Writable({
write(chunk: Buffer, _encoding, callback) {
const text = redactHomePathUserSegments(chunk.toString("utf-8"));
chunks.push(text);
const emitted = dedup ? dedup.filter(text) : text;
if (!emitted) {
callback();
return;
}
void onLog("stdout", emitted).then(() => callback(), callback);
},
});
// When the job completion signal fires, destroy the writable to abort the
// in-flight follow stream. Without this, logApi.log can hang indefinitely
// when the pod terminates without closing the HTTP connection cleanly.
let stopPoller: ReturnType<typeof setInterval> | null = null;
let bailTimer: ReturnType<typeof setTimeout> | null = null;
let bailResolve: (() => void) | null = null;
const bailPromise = new Promise<void>((resolve) => {
bailResolve = resolve;
});
if (stopSignal) {
stopPoller = setInterval(() => {
if (stopSignal.stopped) {
if (!writable.destroyed) writable.destroy();
if (!bailTimer && bailResolve) {
bailTimer = setTimeout(() => {
onLog("stderr", "[paperclip] Log stream bail timer fired — forcing return\n").catch(() => {});
bailResolve!();
}, LOG_STREAM_BAIL_TIMEOUT_MS);
}
}
}, 200);
}
const logPromise = logApi.log(namespace, podName, "opencode", writable, {
follow: true,
pretty: false,
...(sinceSeconds ? { sinceSeconds } : {}),
}).catch(() => {
// follow may fail if the container already exited, the API connection
// dropped, or we aborted via writable.destroy() — not fatal.
});
try {
if (stopSignal) {
await Promise.race([logPromise, bailPromise]);
} else {
await logPromise;
}
} finally {
if (stopPoller) clearInterval(stopPoller);
if (bailTimer) clearTimeout(bailTimer);
}
return chunks.join("");
}
/**
* Stream pod logs with automatic reconnection. Keeps retrying the log
* stream until the stop signal fires (job completed) or the container
* exits normally. This handles silent K8s API connection drops that
* would otherwise cause the UI to stop receiving real output.
*
* Capped at MAX_LOG_RECONNECT_ATTEMPTS to prevent infinite reconnect
* loops during sustained API partitions.
*
* onFirstStreamExit is called the first time streamPodLogsOnce returns.
* Used by execute() to start the LOG_EXIT_COMPLETION_GRACE_MS grace timer
* without waiting for all reconnects to exhaust.
*/
async function streamPodLogs(
namespace: string,
podName: string,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
stopSignal?: { stopped: boolean },
dedup?: LogLineDedupFilter,
onFirstStreamExit?: () => void,
): Promise<string> {
const logApi = getLogApi(kubeconfigPath);
const parts: string[] = [];
let lineBuffer = "";
const allChunks: string[] = [];
let attempt = 0;
// Track the timestamp of the last successfully received log line so
// reconnects use a tight window instead of an ever-growing one anchored
// at stream start. This is the primary fix for duplicative logs on reconnect.
let lastLogReceivedAt = Math.floor(Date.now() / 1000);
if (!dedup) dedup = new LogLineDedupFilter();
const writable = new Writable({
write(chunk: Buffer, _encoding, callback) {
const incoming = lineBuffer + chunk.toString("utf-8");
const nlIdx = incoming.lastIndexOf("\n");
if (nlIdx === -1) {
// No complete line yet — buffer until newline arrives
lineBuffer = incoming;
callback();
return;
}
lineBuffer = incoming.slice(nlIdx + 1);
// Redact each complete line individually to avoid path splits across chunk boundaries
const redacted = incoming
.slice(0, nlIdx + 1)
.split("\n")
.map((line) => redactHomePathUserSegments(line))
.join("\n");
parts.push(redacted);
void onLog("stdout", redacted).then(() => callback(), callback);
},
});
while (!stopSignal?.stopped) {
if (attempt >= MAX_LOG_RECONNECT_ATTEMPTS) {
await onLog("stderr", `[paperclip] Log stream: max reconnect attempts (${MAX_LOG_RECONNECT_ATTEMPTS}) reached — giving up.\n`);
break;
}
try {
await logApi.log(namespace, podName, "opencode", writable, {
follow: true,
pretty: false,
});
} catch {
// follow may fail if the container already exited
// On reconnect, ask for logs since the last received line (+5s buffer)
// instead of since stream start. This keeps the window tight and
// avoids ever-growing duplicate output.
const sinceSeconds = attempt > 0
? Math.max(1, Math.floor(Date.now() / 1000) - lastLogReceivedAt + 5)
: undefined;
if (attempt > 0) {
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt}/${MAX_LOG_RECONNECT_ATTEMPTS})...\n`);
}
const preStreamTs = Math.floor(Date.now() / 1000);
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup, stopSignal);
// Signal first stream exit immediately so the grace-period timer in
// execute() can start without waiting for all reconnects to complete.
if (attempt === 0) onFirstStreamExit?.();
if (result) {
allChunks.push(result);
// Update last-received timestamp to now (the stream just ended,
// so any log lines in `result` were received up to this moment).
lastLogReceivedAt = Math.floor(Date.now() / 1000);
} else if (attempt === 0) {
// First attempt returned nothing — update timestamp so reconnect
// window stays reasonable.
lastLogReceivedAt = preStreamTs;
}
attempt++;
if (stopSignal?.stopped) break;
// Brief pause before reconnecting to avoid tight loops.
await new Promise((resolve) => setTimeout(resolve, LOG_STREAM_RECONNECT_DELAY_MS));
}
// Flush any partial line that never received a trailing newline
if (lineBuffer) {
const redacted = redactHomePathUserSegments(lineBuffer);
parts.push(redacted);
await onLog("stdout", redacted);
}
// Flush any buffered partial line so the final assistant/result chunk
// isn't dropped when the stream ends mid-line.
const tail = dedup.flush();
if (tail) await onLog("stdout", tail);
return parts.join("");
return allChunks.join("");
}
async function readPodLogs(
@@ -201,7 +312,7 @@ async function readPodLogs(
}
}
type JobCompletionResult = { succeeded: boolean; timedOut: boolean; jobGone: boolean };
export type JobCompletionResult = { succeeded: boolean; timedOut: boolean; jobGone: boolean };
async function waitForJobCompletion(
namespace: string,
@@ -406,6 +517,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
let exitCode: number | null = null;
let jobTimedOut = false;
let podTerminatedReason: string | null = null;
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
try {
const scheduleTimeoutMs = 120_000;
@@ -427,10 +539,101 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const completionTimeoutMs = timeoutSec > 0 ? (timeoutSec + graceSec) * 1000 : 0;
// Start completion poller in parallel with log streaming
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath);
// Shared stop signal: set to true when job completion is detected so
// the log stream stops reconnecting promptly.
const logStopSignal = { stopped: false };
// Shared dedup filter across reconnects so replayed lines inside the
// sinceSeconds overlap window are dropped before reaching the UI.
const logDedup = new LogLineDedupFilter();
stdout = await streamPodLogs(namespace, podName, onLog, kubeconfigPath);
// Keepalive: periodically emit a status line so the Paperclip server
// knows the adapter is still alive during long silent phases.
let lastLogAt = Date.now();
let keepaliveJobTerminal = false;
let consecutiveTerminalReadings = 0;
keepaliveTimer = setInterval(() => {
void (async () => {
if (keepaliveJobTerminal) return;
// Require two consecutive terminal readings before latching to
// guard against a stale K8s API cache returning a false terminal
// status on a single read.
try {
const j = await getBatchApi(kubeconfigPath).readNamespacedJob({ name: jobName, namespace });
const terminal = j.status?.conditions?.some(
(c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True",
);
if (terminal) {
consecutiveTerminalReadings++;
if (consecutiveTerminalReadings >= 2) keepaliveJobTerminal = true;
return;
}
consecutiveTerminalReadings = 0;
} catch {
return;
}
const silenceSec = Math.round((Date.now() - lastLogAt) / 1000);
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`).catch(() => {});
})();
}, KEEPALIVE_INTERVAL_MS);
// wrappedOnLog updates lastLogAt so the keepalive timer can measure silence.
const wrappedOnLog: typeof onLog = async (stream, chunk) => {
lastLogAt = Date.now();
return onLog(stream, chunk);
};
// Track when the log stream first exits so the grace-period can fire
// if the K8s Job condition lags behind container exit.
let logExitTime: number | null = null;
const trackedLogStream = streamPodLogs(
namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal, logDedup,
() => { logExitTime = Date.now(); },
);
// completionGraced races waitForJobCompletion against a grace timer that
// fires LOG_EXIT_COMPLETION_GRACE_MS after the log stream exits. This bounds
// the stale-UI window when K8s Job conditions lag container exit.
let gracePoller: ReturnType<typeof setInterval> | null = null;
const completionGraced = new Promise<JobCompletionResult>((resolve, reject) => {
let settled = false;
const settleOk = (r: JobCompletionResult) => {
if (settled) return;
settled = true;
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
logStopSignal.stopped = true;
resolve(r);
};
const settleErr = (err: unknown) => {
if (settled) return;
settled = true;
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
logStopSignal.stopped = true;
reject(err);
};
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then(settleOk).catch(settleErr);
gracePoller = setInterval(() => {
if (logExitTime !== null && Date.now() - logExitTime >= LOG_EXIT_COMPLETION_GRACE_MS) {
void onLog("stdout", `[paperclip] Log stream exited ${LOG_EXIT_COMPLETION_GRACE_MS / 1000}s ago without K8s Job condition update — proceeding with captured output\n`).catch(() => {});
settleOk({ succeeded: false, timedOut: false, jobGone: true });
}
}, 1_000);
});
const [logResult, completionResult] = await Promise.allSettled([
trackedLogStream,
completionGraced,
]);
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
}
if (logResult.status === "fulfilled") {
stdout = logResult.value;
}
if (!stdout.trim()) {
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
@@ -448,19 +651,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
// After log stream exits, wait at most LOG_EXIT_COMPLETION_GRACE_MS for the job
// condition to settle — avoids racing TTL cleanup vs condition update lag
const completion = await completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS);
jobTimedOut = completion.timedOut;
if (completion.jobGone) {
await onLog("stdout", `[paperclip] Job ${jobName} not found (likely TTL-cleaned after completion).\n`);
if (completionResult.status === "fulfilled") {
const completion = completionResult.value;
jobTimedOut = completion.timedOut;
if (completion.jobGone) {
await onLog("stdout", `[paperclip] Job ${jobName} not found (likely TTL-cleaned after completion).\n`);
}
} else {
jobTimedOut = true;
}
const terminatedInfo = await getPodTerminatedInfo(namespace, jobName, kubeconfigPath);
exitCode = terminatedInfo.exitCode;
podTerminatedReason = terminatedInfo.reason;
} finally {
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
}
if (!retainJobs) {
await cleanupJob(namespace, jobName, onLog, kubeconfigPath);
} else {
+51
View File
@@ -6,6 +6,8 @@ const mockSelfPod: JobBuildInput["selfPod"] = {
image: "paperclip/paperclip:latest",
imagePullSecrets: [],
inheritedEnv: {},
inheritedEnvValueFrom: [],
inheritedEnvFrom: [],
pvcClaimName: null,
dnsConfig: undefined,
secretVolumes: [],
@@ -144,4 +146,53 @@ describe("buildJobManifest", () => {
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "kubernetes.io/arch": "amd64" });
});
it("forwards inheritedEnvValueFrom entries onto the opencode container env", () => {
const selfPod = {
...mockSelfPod,
inheritedEnvValueFrom: [
{ name: "MY_SECRET", valueFrom: { secretKeyRef: { name: "my-secret", key: "token" } } },
],
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
const secretEnv = env.find((e) => e.name === "MY_SECRET");
expect(secretEnv?.valueFrom?.secretKeyRef?.name).toBe("my-secret");
expect(secretEnv?.valueFrom?.secretKeyRef?.key).toBe("token");
});
it("does not duplicate an inheritedEnvValueFrom entry if the name is already set as a literal", () => {
const selfPod = {
...mockSelfPod,
inheritedEnv: { HOME: "/custom" },
inheritedEnvValueFrom: [
{ name: "HOME", valueFrom: { secretKeyRef: { name: "s", key: "k" } } },
],
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
const homeEntries = env.filter((e) => e.name === "HOME");
// HOME is overridden by merged (HOME=/paperclip hardcoded last), so valueFrom must not appear
expect(homeEntries.every((e) => e.value !== undefined)).toBe(true);
});
it("forwards inheritedEnvFrom onto the opencode container envFrom", () => {
const selfPod = {
...mockSelfPod,
inheritedEnvFrom: [{ secretRef: { name: "my-config-secret" } }],
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const container = result.job.spec?.template?.spec?.containers?.[0];
expect(container?.envFrom).toEqual([{ secretRef: { name: "my-config-secret" } }]);
});
it("omits envFrom when inheritedEnvFrom is empty", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
const container = result.job.spec?.template?.spec?.containers?.[0];
expect(container?.envFrom).toBeUndefined();
});
});
+34 -4
View File
@@ -14,6 +14,8 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import type { SelfPodInfo } from "./k8s-client.js";
export const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024;
export interface JobBuildInput {
ctx: AdapterExecutionContext;
selfPod: SelfPodInfo;
@@ -21,6 +23,12 @@ export interface JobBuildInput {
instructionsContent?: string;
/** Concatenated content of desired skill markdown files, prepended after instructions. */
skillsBundleContent?: string;
/**
* When set, the prompt is stored in this K8s Secret (already created by the caller)
* and the init container mounts and copies it instead of using an env var.
* Required when the prompt exceeds LARGE_PROMPT_THRESHOLD_BYTES.
*/
promptSecretName?: string;
}
export interface JobBuildResult {
@@ -157,12 +165,19 @@ function buildEnvVars(
merged.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
merged.HOME = "/paperclip";
// Convert to V1EnvVar array
// Convert literal-value vars to V1EnvVar array
const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({
name,
value,
}));
// Append valueFrom vars (Secret/ConfigMap-backed) only for names not already overridden
for (const envVar of selfPod.inheritedEnvValueFrom) {
if (!Object.prototype.hasOwnProperty.call(merged, envVar.name)) {
envVars.push({ name: envVar.name, valueFrom: envVar.valueFrom });
}
}
return envVars;
}
@@ -293,6 +308,10 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const volumes: k8s.V1Volume[] = [{ name: "prompt", emptyDir: {} }];
const volumeMounts: k8s.V1VolumeMount[] = [{ name: "prompt", mountPath: "/tmp/prompt" }];
if (input.promptSecretName) {
volumes.push({ name: "prompt-secret", secret: { secretName: input.promptSecretName } });
}
if (selfPod.pvcClaimName) {
volumes.push({
name: "data",
@@ -370,9 +389,19 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
...(input.promptSecretName
? {
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt"],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
],
}
: {
command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
}),
securityContext,
resources: {
requests: { cpu: "10m", memory: "16Mi" },
@@ -388,6 +417,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
workingDir,
command: ["sh", "-c", mainCommand],
env: envVars,
...(selfPod.inheritedEnvFrom.length > 0 ? { envFrom: selfPod.inheritedEnvFrom } : {}),
volumeMounts,
securityContext,
resources: containerResources,
+21 -6
View File
@@ -20,8 +20,12 @@ export interface SelfPodInfo {
dnsConfig: k8s.V1PodDNSConfig | undefined;
pvcClaimName: string | null;
secretVolumes: SelfPodSecretVolume[];
/** Env vars read directly from the pod spec's container definition. */
/** Env vars with literal values from the container spec. */
inheritedEnv: Record<string, string>;
/** Env vars backed by secretKeyRef/configMapKeyRef/fieldRef (valueFrom). */
inheritedEnvValueFrom: k8s.V1EnvVar[];
/** Whole-Secret/ConfigMap env sources (envFrom) from the container spec. */
inheritedEnvFrom: k8s.V1EnvFromSource[];
}
let cachedSelfPod: SelfPodInfo | null = null;
@@ -102,7 +106,8 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
throw new Error(`claude_k8s: pod ${hostname} has no spec`);
}
const mainContainer = spec.containers[0];
const mainContainer =
spec.containers.find((c) => c.name === "paperclip") ?? spec.containers[0];
if (!mainContainer?.image) {
throw new Error(`claude_k8s: pod ${hostname} has no container image`);
}
@@ -131,13 +136,21 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
}
}
// Collect all env vars directly from the pod spec container definition.
// This gives the authoritative env the container was configured with in K8s —
// no static allowlist needed; any env var from the Deployment is forwarded.
// Collect env vars from the pod spec container definition.
// Literal-value vars go into inheritedEnv (forwarded as plain strings).
// valueFrom vars (secretKeyRef, configMapKeyRef, fieldRef) are kept as
// V1EnvVar objects so the Job pod can resolve them at runtime.
// envFrom entries (whole-Secret/ConfigMap mounts) are forwarded as-is.
const inheritedEnv: Record<string, string> = {};
const inheritedEnvValueFrom: k8s.V1EnvVar[] = [];
for (const envVar of mainContainer.env ?? []) {
if (envVar.value) inheritedEnv[envVar.name] = envVar.value;
if (envVar.value !== undefined) {
inheritedEnv[envVar.name] = envVar.value;
} else if (envVar.valueFrom) {
inheritedEnvValueFrom.push({ name: envVar.name, valueFrom: envVar.valueFrom });
}
}
const inheritedEnvFrom: k8s.V1EnvFromSource[] = [...(mainContainer.envFrom ?? [])];
cachedSelfPod = {
namespace,
@@ -149,6 +162,8 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
pvcClaimName,
secretVolumes,
inheritedEnv,
inheritedEnvValueFrom,
inheritedEnvFrom,
};
return cachedSelfPod;
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach } from "vitest";
import { eventDedupKey, LogLineDedupFilter } from "./log-dedup.js";
describe("eventDedupKey", () => {
it("returns null for object with no type field", () => {
expect(eventDedupKey({ sessionID: "ses_1" })).toBeNull();
});
it("returns null for object with empty type", () => {
expect(eventDedupKey({ type: "" })).toBeNull();
});
it("returns null for unknown event type", () => {
expect(eventDedupKey({ type: "unknown_type", sessionID: "ses_1" })).toBeNull();
});
it("returns type:sessionId:partId when all three present", () => {
const event = { type: "text", sessionID: "ses_1", part: { id: "part_abc" } };
expect(eventDedupKey(event)).toBe("text:ses_1:part_abc");
});
it("returns type:sessionId when partId absent", () => {
const event = { type: "text", sessionID: "ses_1", part: {} };
expect(eventDedupKey(event)).toBe("text:ses_1");
});
it("returns null when both sessionId and partId absent", () => {
const event = { type: "text", part: {} };
expect(eventDedupKey(event)).toBeNull();
});
it("returns null when part has no id and sessionID missing", () => {
const event = { type: "tool_use" };
expect(eventDedupKey(event)).toBeNull();
});
it("handles tool_use type", () => {
const event = { type: "tool_use", sessionID: "ses_1", part: { id: "tool_1" } };
expect(eventDedupKey(event)).toBe("tool_use:ses_1:tool_1");
});
it("handles step_finish type", () => {
const event = { type: "step_finish", sessionID: "ses_2", part: { id: "step_1" } };
expect(eventDedupKey(event)).toBe("step_finish:ses_2:step_1");
});
it("handles step_start type", () => {
const event = { type: "step_start", sessionID: "ses_3" };
expect(eventDedupKey(event)).toBe("step_start:ses_3");
});
it("handles thinking type", () => {
const event = { type: "thinking", sessionID: "ses_4", part: { id: "think_1" } };
expect(eventDedupKey(event)).toBe("thinking:ses_4:think_1");
});
it("handles assistant type", () => {
const event = { type: "assistant", sessionID: "ses_5" };
expect(eventDedupKey(event)).toBe("assistant:ses_5");
});
it("handles user type", () => {
const event = { type: "user", sessionID: "ses_6" };
expect(eventDedupKey(event)).toBe("user:ses_6");
});
it("returns null for error type (not in dedup switch)", () => {
const event = { type: "error", sessionID: "ses_7" };
expect(eventDedupKey(event)).toBeNull();
});
it("uses part.id string even when nested in non-object context", () => {
const event = { type: "text", sessionID: "ses_1", part: { id: "part_x" } };
expect(eventDedupKey(event)).toBe("text:ses_1:part_x");
});
});
describe("LogLineDedupFilter", () => {
let dedup: LogLineDedupFilter;
beforeEach(() => {
dedup = new LogLineDedupFilter();
});
describe("filter()", () => {
it("returns empty string for empty chunk", () => {
expect(dedup.filter("")).toBe("");
});
it("passes through non-JSON lines", () => {
const chunk = "[paperclip] Pod running: pod-abc\n";
expect(dedup.filter(chunk)).toBe(chunk);
});
it("passes a JSON event on first occurrence", () => {
const event = { type: "text", sessionID: "ses_1" };
const line = JSON.stringify(event) + "\n";
expect(dedup.filter(line)).toBe(line);
});
it("drops a duplicate JSON event on second occurrence", () => {
const event = { type: "text", sessionID: "ses_1" };
const line = JSON.stringify(event) + "\n";
dedup.filter(line); // first — passes
expect(dedup.filter(line)).toBe(""); // second — dropped
});
it("passes a JSON event without a dedup key on every occurrence", () => {
// Events with unknown type have no structural key — fall back to raw content hash
const event = { type: "error", sessionID: "ses_1", error: "unique1" };
const line = JSON.stringify(event) + "\n";
dedup.filter(line);
// Same raw content would be deduped (raw: key), but different error content passes
const event2 = { type: "error", sessionID: "ses_1", error: "unique2" };
const line2 = JSON.stringify(event2) + "\n";
expect(dedup.filter(line2)).toBe(line2);
});
it("deduplicates same raw non-dedup-keyed line twice", () => {
const event = { type: "error", message: "same" };
const line = JSON.stringify(event) + "\n";
dedup.filter(line);
expect(dedup.filter(line)).toBe(""); // same raw content deduplicated via raw: key
});
it("buffers incomplete trailing content without emitting", () => {
// No trailing newline → chunk is buffered
const partial = '{"type":"text","sessionID":"ses_1"}';
expect(dedup.filter(partial)).toBe("");
});
it("emits buffered content when completed by next chunk", () => {
const partial = '{"type":"text","sessionID":"ses_1"}';
dedup.filter(partial); // buffered
const completion = "\n"; // completes the line
const result = dedup.filter(completion);
expect(result).toBe('{"type":"text","sessionID":"ses_1"}\n');
});
it("handles multiple lines in a single chunk", () => {
const line1 = '{"type":"text","sessionID":"ses_1"}\n';
const line2 = '[paperclip] some status\n';
const chunk = line1 + line2;
const result = dedup.filter(chunk);
expect(result).toBe(chunk);
});
it("deduplicates within a multi-line chunk", () => {
const line = '{"type":"text","sessionID":"ses_1"}\n';
const chunk = line + line; // same line twice in one chunk
const result = dedup.filter(chunk);
expect(result).toBe(line); // only once
});
it("passes blank lines through unchanged", () => {
expect(dedup.filter("\n")).toBe("\n");
});
it("passes whitespace-only lines through unchanged", () => {
expect(dedup.filter(" \n")).toBe(" \n");
});
it("deduplicates events keyed by type:sessionId across chunks", () => {
const event = { type: "step_start", sessionID: "ses_1" };
const line = JSON.stringify(event) + "\n";
dedup.filter(line);
// second occurrence in a later chunk
expect(dedup.filter(line)).toBe("");
});
it("allows distinct events with different sessionIds to pass", () => {
const line1 = JSON.stringify({ type: "text", sessionID: "ses_1" }) + "\n";
const line2 = JSON.stringify({ type: "text", sessionID: "ses_2" }) + "\n";
dedup.filter(line1);
expect(dedup.filter(line2)).toBe(line2);
});
it("allows distinct events with different partIds to pass", () => {
const line1 = JSON.stringify({ type: "tool_use", sessionID: "ses_1", part: { id: "t1" } }) + "\n";
const line2 = JSON.stringify({ type: "tool_use", sessionID: "ses_1", part: { id: "t2" } }) + "\n";
dedup.filter(line1);
expect(dedup.filter(line2)).toBe(line2);
});
});
describe("flush()", () => {
it("returns empty string when buffer is empty", () => {
expect(dedup.flush()).toBe("");
});
it("returns and clears buffered incomplete line", () => {
const partial = '{"type":"text","sessionID":"ses_1"}';
dedup.filter(partial);
expect(dedup.flush()).toBe(partial);
});
it("returns empty string on subsequent flush after buffer cleared", () => {
const partial = '{"type":"text","sessionID":"ses_1"}';
dedup.filter(partial);
dedup.flush();
expect(dedup.flush()).toBe(""); // buffer already cleared
});
it("does not emit duplicate content on flush", () => {
const line = '{"type":"text","sessionID":"ses_1"}\n';
dedup.filter(line); // first emission
const partial = '{"type":"text","sessionID":"ses_1"}'; // no trailing newline
dedup.filter(partial);
expect(dedup.flush()).toBe(""); // same key already seen — suppressed
});
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Line-level dedup filter for the K8s log stream.
*
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
* window (integer-second granularity + a safety buffer), which replays a few
* seconds of recent output on every reconnect. Without dedup those replayed
* lines appear as duplicate events in the streaming UI.
*
* The filter operates at the chunk → line level: chunks are split on `\n`,
* incomplete trailing content is buffered until the next chunk, and each
* complete line is emitted at most once. JSON-shaped OpenCode JSONL events
* are keyed by (type + sessionID + part.id); non-JSON lines pass through
* unchanged so genuinely-repeated status lines are not swallowed.
*/
type Parsed = Record<string, unknown>;
function asStr(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asRec(value: unknown): Parsed | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Parsed;
}
/**
* Build a stable dedup key for an OpenCode JSONL event. Returns `null` when
* the event is not a recognized OpenCode event — those lines fall back to
* raw-content hashing so non-JSON output (paperclip status lines, shell
* output) is never deduped by identity.
*/
export function eventDedupKey(event: Parsed): string | null {
const type = asStr(event.type);
if (!type) return null;
const sessionId = asStr(event.sessionID);
const part = asRec(event.part);
const partId = part ? asStr(part.id) : "";
switch (type) {
case "text":
case "tool_use":
case "step_finish":
case "step_start":
case "thinking":
case "assistant":
case "user":
if (partId) return `${type}:${sessionId}:${partId}`;
if (sessionId) return `${type}:${sessionId}`;
return null;
default:
return null;
}
}
/**
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
* the caller — preserves original chunk formatting (including trailing
* newlines) for lines that pass the dedup check.
*/
export class LogLineDedupFilter {
private buffer = "";
private readonly seenKeys = new Set<string>();
/**
* Process a chunk and return the subset that should be forwarded.
* Incomplete trailing content (no terminating newline) is buffered and
* emitted on the next chunk that completes the line (or on flush()).
*/
filter(chunk: string): string {
if (!chunk) return "";
const combined = this.buffer + chunk;
const endsWithNewline = combined.endsWith("\n");
const parts = combined.split("\n");
if (endsWithNewline) {
parts.pop();
this.buffer = "";
} else {
this.buffer = parts.pop() ?? "";
}
const out: string[] = [];
for (const line of parts) {
if (this.shouldEmit(line)) out.push(line);
}
if (out.length === 0) return "";
return out.join("\n") + "\n";
}
/**
* Flush any incomplete trailing content. Called when the stream ends
* without a terminating newline so the final partial line isn't lost.
*/
flush(): string {
const pending = this.buffer;
this.buffer = "";
if (!pending) return "";
return this.shouldEmit(pending) ? pending : "";
}
private shouldEmit(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return true;
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return true;
}
const event = asRec(parsed);
if (!event) return true;
const structuralKey = eventDedupKey(event);
const key = structuralKey ?? `raw:${trimmed}`;
if (this.seenKeys.has(key)) return false;
this.seenKeys.add(key);
return true;
}
}
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect } from "vitest";
import { sessionCodec } from "./session.js";
describe("sessionCodec.deserialize", () => {
it("returns null for null input", () => {
expect(sessionCodec.deserialize(null)).toBeNull();
});
it("returns null for string input", () => {
expect(sessionCodec.deserialize("string")).toBeNull();
});
it("returns null for number input", () => {
expect(sessionCodec.deserialize(42)).toBeNull();
});
it("returns null for array input", () => {
expect(sessionCodec.deserialize([])).toBeNull();
});
it("returns null when sessionId is absent", () => {
expect(sessionCodec.deserialize({ cwd: "/foo" })).toBeNull();
});
it("returns null when sessionId is empty string", () => {
expect(sessionCodec.deserialize({ sessionId: "" })).toBeNull();
});
it("returns null when sessionId is whitespace only", () => {
expect(sessionCodec.deserialize({ sessionId: " " })).toBeNull();
});
it("reads canonical sessionId", () => {
const result = sessionCodec.deserialize({ sessionId: "ses_abc" });
expect(result?.sessionId).toBe("ses_abc");
});
it("reads legacy session_id field", () => {
const result = sessionCodec.deserialize({ session_id: "ses_legacy" });
expect(result?.sessionId).toBe("ses_legacy");
});
it("reads legacy sessionID field", () => {
const result = sessionCodec.deserialize({ sessionID: "ses_ID" });
expect(result?.sessionId).toBe("ses_ID");
});
it("prefers sessionId over session_id", () => {
const result = sessionCodec.deserialize({ sessionId: "canonical", session_id: "legacy" });
expect(result?.sessionId).toBe("canonical");
});
it("prefers session_id over sessionID", () => {
const result = sessionCodec.deserialize({ session_id: "mid", sessionID: "last" });
expect(result?.sessionId).toBe("mid");
});
it("trims whitespace from sessionId", () => {
const result = sessionCodec.deserialize({ sessionId: " ses_123 " });
expect(result?.sessionId).toBe("ses_123");
});
it("reads cwd field", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", cwd: "/work/dir" });
expect(result?.cwd).toBe("/work/dir");
});
it("reads workdir as cwd fallback", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", workdir: "/workdir" });
expect(result?.cwd).toBe("/workdir");
});
it("reads folder as cwd fallback", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", folder: "/folder" });
expect(result?.cwd).toBe("/folder");
});
it("prefers cwd over workdir", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", cwd: "/cwd", workdir: "/workdir" });
expect(result?.cwd).toBe("/cwd");
});
it("prefers workdir over folder", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", workdir: "/workdir", folder: "/folder" });
expect(result?.cwd).toBe("/workdir");
});
it("reads workspaceId field", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", workspaceId: "ws-1" });
expect(result?.workspaceId).toBe("ws-1");
});
it("reads workspace_id as workspaceId fallback", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", workspace_id: "ws-legacy" });
expect(result?.workspaceId).toBe("ws-legacy");
});
it("reads repoUrl field", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", repoUrl: "https://github.com/org/repo" });
expect(result?.repoUrl).toBe("https://github.com/org/repo");
});
it("reads repo_url as repoUrl fallback", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", repo_url: "https://github.com/org/repo" });
expect(result?.repoUrl).toBe("https://github.com/org/repo");
});
it("reads repoRef field", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", repoRef: "main" });
expect(result?.repoRef).toBe("main");
});
it("reads repo_ref as repoRef fallback", () => {
const result = sessionCodec.deserialize({ sessionId: "s1", repo_ref: "feature/branch" });
expect(result?.repoRef).toBe("feature/branch");
});
it("omits absent optional fields from result", () => {
const result = sessionCodec.deserialize({ sessionId: "s1" });
expect(result).toEqual({ sessionId: "s1" });
expect(result && "cwd" in result).toBe(false);
expect(result && "workspaceId" in result).toBe(false);
expect(result && "repoUrl" in result).toBe(false);
expect(result && "repoRef" in result).toBe(false);
});
it("includes all fields when all are present", () => {
const result = sessionCodec.deserialize({
sessionId: "ses_full",
cwd: "/work",
workspaceId: "ws-1",
repoUrl: "https://github.com/org/repo",
repoRef: "main",
});
expect(result).toEqual({
sessionId: "ses_full",
cwd: "/work",
workspaceId: "ws-1",
repoUrl: "https://github.com/org/repo",
repoRef: "main",
});
});
});
describe("sessionCodec.serialize", () => {
it("returns null for null input", () => {
expect(sessionCodec.serialize(null)).toBeNull();
});
it("returns null when sessionId is missing", () => {
expect(sessionCodec.serialize({ cwd: "/foo" })).toBeNull();
});
it("returns null when sessionId is empty string", () => {
expect(sessionCodec.serialize({ sessionId: "" })).toBeNull();
});
it("serializes canonical fields", () => {
const result = sessionCodec.serialize({
sessionId: "ses_abc",
cwd: "/work",
workspaceId: "ws-1",
repoUrl: "https://github.com/org/repo",
repoRef: "main",
});
expect(result).toEqual({
sessionId: "ses_abc",
cwd: "/work",
workspaceId: "ws-1",
repoUrl: "https://github.com/org/repo",
repoRef: "main",
});
});
it("reads legacy session_id field", () => {
const result = sessionCodec.serialize({ session_id: "ses_legacy" });
expect(result?.sessionId).toBe("ses_legacy");
});
it("reads legacy workdir as cwd", () => {
const result = sessionCodec.serialize({ sessionId: "s1", workdir: "/workdir" });
expect(result?.cwd).toBe("/workdir");
});
it("reads legacy workspace_id", () => {
const result = sessionCodec.serialize({ sessionId: "s1", workspace_id: "ws-2" });
expect(result?.workspaceId).toBe("ws-2");
});
it("reads legacy repo_url", () => {
const result = sessionCodec.serialize({ sessionId: "s1", repo_url: "https://github.com/org/repo" });
expect(result?.repoUrl).toBe("https://github.com/org/repo");
});
it("reads legacy repo_ref", () => {
const result = sessionCodec.serialize({ sessionId: "s1", repo_ref: "develop" });
expect(result?.repoRef).toBe("develop");
});
it("omits absent optional fields", () => {
const result = sessionCodec.serialize({ sessionId: "s1" });
expect(result).toEqual({ sessionId: "s1" });
});
});
describe("sessionCodec.getDisplayId", () => {
// getDisplayId is optional in the AdapterSessionCodec interface; use non-null assertion since we know it's implemented
const getDisplayId = sessionCodec.getDisplayId!.bind(sessionCodec);
it("returns null for null input", () => {
expect(getDisplayId(null)).toBeNull();
});
it("returns sessionId", () => {
expect(getDisplayId({ sessionId: "ses_abc" })).toBe("ses_abc");
});
it("returns session_id as fallback", () => {
expect(getDisplayId({ session_id: "ses_legacy" })).toBe("ses_legacy");
});
it("returns sessionID as fallback", () => {
expect(getDisplayId({ sessionID: "ses_ID" })).toBe("ses_ID");
});
it("prefers sessionId over session_id", () => {
expect(getDisplayId({ sessionId: "canonical", session_id: "legacy" })).toBe("canonical");
});
it("returns null when no valid id field present", () => {
expect(getDisplayId({ other: "value" })).toBeNull();
});
it("returns null when sessionId is empty string", () => {
expect(getDisplayId({ sessionId: "" })).toBeNull();
});
});
+176
View File
@@ -146,4 +146,180 @@ describe("parseStdoutLine", () => {
const line = JSON.stringify({ type: "thinking", part: { thinking: " " } });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("maps step_start to system kind", () => {
const line = JSON.stringify({ type: "step_start" });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "Starting step…" }]);
});
it("maps tool_use pending status to tool_call kind", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", id: "call_1", state: { status: "pending", description: "ls -la" } },
});
const entries = parseStdoutLine(line, TS);
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe("tool_call");
const entry = entries[0] as { name: string; toolUseId: string; input: unknown };
expect(entry.name).toBe("bash");
expect(entry.toolUseId).toBe("call_1");
expect(entry.input).toBe("ls -la");
});
it("maps tool_use error status to tool_result with isError=true", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", id: "call_2", state: { status: "error", error: "Command not found" } },
});
const entries = parseStdoutLine(line, TS);
expect(entries).toHaveLength(1);
const entry = entries[0] as { kind: string; isError: boolean; content: string; toolName: string };
expect(entry.kind).toBe("tool_result");
expect(entry.isError).toBe(true);
expect(entry.content).toBe("Command not found");
expect(entry.toolName).toBe("bash");
});
it("uses 'Tool error' fallback when tool_use error field is empty", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "error", error: "" } },
});
const entry = parseStdoutLine(line, TS)[0] as { content: string };
expect(entry.content).toBe("Tool error");
});
it("maps tool_use done status to tool_result", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "grep", id: "call_3", state: { status: "done", output: "3 matches" } },
});
const entries = parseStdoutLine(line, TS);
const entry = entries[0] as { kind: string; isError: boolean; content: string };
expect(entry.kind).toBe("tool_result");
expect(entry.isError).toBe(false);
expect(entry.content).toBe("3 matches");
});
it("uses description as content fallback when tool_use output is empty", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "ls", state: { status: "completed", output: "", description: "Listed 5 files" } },
});
const entry = parseStdoutLine(line, TS)[0] as { content: string };
expect(entry.content).toBe("Listed 5 files");
});
it("uses 'Done' when tool_use output and description are both empty", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "ls", state: { status: "completed", output: "", description: "" } },
});
const entry = parseStdoutLine(line, TS)[0] as { content: string };
expect(entry.content).toBe("Done");
});
it("uses tool name as toolUseId when id field is absent", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "pending" } },
});
const entry = parseStdoutLine(line, TS)[0] as { toolUseId: string };
expect(entry.toolUseId).toBe("bash");
});
it("sets tool_call input to undefined when description is empty", () => {
const line = JSON.stringify({
type: "tool_use",
part: { tool: "bash", state: { status: "pending", description: "" } },
});
const entry = parseStdoutLine(line, TS)[0] as { input: unknown };
expect(entry.input).toBeUndefined();
});
it("accumulates reasoning tokens into step_finish outputTokens", () => {
const line = JSON.stringify({
type: "step_finish",
part: { tokens: { input: 100, output: 50, reasoning: 20, cache: { read: 80 } }, cost: 0.005 },
});
const entry = parseStdoutLine(line, TS)[0] as {
inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number;
};
expect(entry.inputTokens).toBe(100);
expect(entry.outputTokens).toBe(70); // output(50) + reasoning(20)
expect(entry.cachedTokens).toBe(80);
expect(entry.costUsd).toBeCloseTo(0.005);
});
it("step_finish uses reason as fallback text when message is empty", () => {
const line = JSON.stringify({
type: "step_finish",
part: { reason: "end_turn", tokens: {} },
});
const entry = parseStdoutLine(line, TS)[0] as { text: string; subtype: string };
expect(entry.text).toBe("Step finished: end_turn");
expect(entry.subtype).toBe("end_turn");
});
it("step_finish uses 'done' subtype when reason is absent", () => {
const line = JSON.stringify({ type: "step_finish", part: { tokens: {} } });
const entry = parseStdoutLine(line, TS)[0] as { text: string; subtype: string };
expect(entry.text).toBe("Step finished: done");
expect(entry.subtype).toBe("step_finish");
});
it("step_finish defaults all numeric fields to 0 when tokens absent", () => {
const line = JSON.stringify({ type: "step_finish", part: {} });
const entry = parseStdoutLine(line, TS)[0] as {
inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number;
};
expect(entry.inputTokens).toBe(0);
expect(entry.outputTokens).toBe(0);
expect(entry.cachedTokens).toBe(0);
expect(entry.costUsd).toBe(0);
});
it("returns empty for assistant event with non-text content blocks", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "tool_use", input: {} }] } },
});
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("returns empty for assistant event with empty text block", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "text", text: " " }] } },
});
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("extracts error message from nested error.data.message", () => {
const line = JSON.stringify({ type: "error", error: { data: { message: "Nested message" } } });
const entry = parseStdoutLine(line, TS)[0] as { text: string };
expect(entry.text).toBe("Nested message");
});
it("falls back to error.name when message absent", () => {
const line = JSON.stringify({ type: "error", error: { name: "NotFoundError" } });
const entry = parseStdoutLine(line, TS)[0] as { text: string };
expect(entry.text).toBe("NotFoundError");
});
it("falls back to error.code when name absent", () => {
const line = JSON.stringify({ type: "error", error: { code: "ERR_CONN" } });
const entry = parseStdoutLine(line, TS)[0] as { text: string };
expect(entry.text).toBe("ERR_CONN");
});
it("returns empty array for unrecognized event types", () => {
const line = JSON.stringify({ type: "some_unknown_type", data: {} });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("returns empty array for JSON with no type field", () => {
const line = JSON.stringify({ sessionID: "ses_123", data: "something" });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
});