Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/adapter-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @paperclipai/adapter-utils

## Unreleased

### Patch Changes

- Added `parseJsonLenient()` utility to extract JSON from mixed text, markdown code blocks, and interleaved output
- Fixed EPIPE crash in `runChildProcess()`: stdin errors are now caught via event listener and callback-style write instead of try/catch

## 0.3.1

### Patch Changes
Expand Down
110 changes: 110 additions & 0 deletions packages/adapter-utils/src/server-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildInvocationEnvForLogs,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
materializePaperclipSkillCopy,
parseJsonLenient,
refreshPaperclipWorkspaceEnvForExecution,
renderPaperclipWakePrompt,
runningProcesses,
Expand Down Expand Up @@ -979,3 +980,112 @@ describe("appendWithByteCap", () => {
expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7);
});
});

describe("parseJsonLenient", () => {
it("returns null for empty string", () => {
expect(parseJsonLenient("")).toBeNull();
});

it("parses plain JSON directly", () => {
expect(parseJsonLenient('{"type":"result","ok":true}')).toEqual({
type: "result",
ok: true,
});
});

it("extracts JSON from markdown code blocks", () => {
const wrapped = "Some text\n```json\n{\"type\":\"result\",\"value\":42}\n```\nMore text";
expect(parseJsonLenient(wrapped)).toEqual({
type: "result",
value: 42,
});
});

it("extracts JSON from plain markdown blocks without language tag", () => {
const wrapped = "```\n{\"type\":\"result\",\"value\":42}\n```";
expect(parseJsonLenient(wrapped)).toEqual({
type: "result",
value: 42,
});
});

it("finds JSON object embedded in mixed text when JSON is at end of string", () => {
// The progressive parser tries substrings from '{' to end; trailing text
// after the closing brace causes parse failure. JSON must extend to end.
const mixed = 'Progress: 50%\n{"type":"result","status":"done"}';
expect(parseJsonLenient(mixed)).toEqual({
type: "result",
status: "done",
});
});

it("finds JSON array when it starts the string", () => {
// Arrays are supported, but only when '[' appears before any '{' in the string.
const arrayFirst = "[{\"id\":1},{\"id\":2}]";
expect(parseJsonLenient(arrayFirst)).toEqual([{ id: 1 }, { id: 2 }]);
});

it("returns null when no JSON is present", () => {
expect(parseJsonLenient("Just plain text without any braces")).toBeNull();
});

it("returns null for malformed JSON that cannot be recovered", () => {
expect(parseJsonLenient("{ broken json ")).toBeNull();
});

it("prefers code-block extraction when direct parse fails", () => {
// The full string is not valid JSON (trailing backticks), so direct parse fails.
// Code-block extraction should then find the block contents.
const input = 'not json\n```\n{"type":"block"}\n```';
expect(parseJsonLenient(input)).toEqual({ type: "block" });
});
});

describe("runChildProcess EPIPE handling", () => {
it("does not crash when child closes stdin early (EPIPE)", async () => {
// Use `sh -c 'exit 0'` which immediately exits, closing stdin before parent can write
const logs: { stream: "stdout" | "stderr"; chunk: string }[] = [];
const result = await runChildProcess(
`epipe-test-${randomUUID()}`,
"sh",
["-c", "exit 0"],
{
cwd: os.tmpdir(),
env: {},
timeoutSec: 5,
graceSec: 1,
stdin: "this should not crash",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
},
);

// Process should exit cleanly (exit code 0) despite EPIPE on stdin write
expect(result.exitCode).toBe(0);
expect(result.signal).toBeNull();
expect(result.timedOut).toBe(false);
});

it("delivers stdin successfully when child reads it", async () => {
const logs: { stream: "stdout" | "stderr"; chunk: string }[] = [];
const result = await runChildProcess(
`stdin-echo-test-${randomUUID()}`,
"cat",
[],
{
cwd: os.tmpdir(),
env: {},
timeoutSec: 5,
graceSec: 1,
stdin: "hello from parent",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
},
);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("hello from parent");
});
});
59 changes: 57 additions & 2 deletions packages/adapter-utils/src/server-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,44 @@ export function parseJson(value: string): Record<string, unknown> | null {
}
}

export function parseJsonLenient(value: string): Record<string, unknown> | null {
// Try direct parse first
const direct = parseJson(value);
if (direct) return direct;

// Try extracting the largest JSON object from mixed text
// This handles cases where Claude outputs JSON wrapped in markdown code blocks
// or mixed with progress indicators / warnings
const codeBlockMatch = value.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
const fromBlock = parseJson(codeBlockMatch[1] ?? "");
if (fromBlock) return fromBlock;
}

// Try to find the first { or [ and parse from there
const objectStart = value.indexOf("{");
const arrayStart = value.indexOf("[");
let start = -1;
if (objectStart >= 0 && arrayStart >= 0) {
start = Math.min(objectStart, arrayStart);
} else if (objectStart >= 0) {
start = objectStart;
} else if (arrayStart >= 0) {
start = arrayStart;
}

if (start >= 0) {
// Try parsing progressively from each start position
for (let i = start; i < value.length; i++) {
const candidate = value.slice(i);
const parsed = parseJson(candidate);
if (parsed) return parsed;
}
}

return null;
}

export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
const combined = prev + chunk;
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
Expand Down Expand Up @@ -2058,10 +2096,27 @@ export async function runChildProcess(

const stdin = child.stdin;
if (opts.stdin != null && stdin) {
// Swallow EPIPE so a child that closes stdin early does not crash the server
stdin.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE") return;
// Re-emit other stream errors so existing handlers can react
child.emit("error", err);
});

void spawnPersistPromise.finally(() => {
if (child.killed || stdin.destroyed) return;
stdin.write(opts.stdin as string);
stdin.end();
stdin.write(opts.stdin as string, (err) => {
if (err) {
const errnoErr = err as NodeJS.ErrnoException;
if (errnoErr.code === "EPIPE") {
// Child closed stdin before we could write; ignore
return;
}
// Log but do not throw — the error event handler above will also fire
console.error("stdin write error:", err);
}
stdin.end();
});
});
}

Expand Down
8 changes: 8 additions & 0 deletions packages/adapters/claude-local/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @paperclipai/adapter-claude-local

## Unreleased

### Patch Changes

- Added lenient JSON parsing fallback in `parseClaudeStreamJson()` for markdown-wrapped and mixed-text output
- Added `extractResultFromMixedOutput()` to locate `"type": "result"` JSON objects inside interleaved text
- Treats exit-code-0 + parse failure as inferred success with `inferredSuccess: true` flag instead of error

## 0.3.1

### Patch Changes
Expand Down
15 changes: 11 additions & 4 deletions packages/adapters/claude-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
asBoolean,
asStringArray,
parseObject,
parseJson,
parseJsonLenient,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
readPaperclipRuntimeSkillEntries,
Expand Down Expand Up @@ -710,7 +710,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
.find(Boolean) ?? "";

if ((proc.exitCode ?? 0) === 0) {
return "Failed to parse claude JSON output";
// When exit code is 0 but parsing failed, this is likely a success with
// non-standard output format. Return a generic message that doesn't
// imply failure, so upstream can infer success.
return "Claude completed but output could not be fully parsed";
}

return stderrLine
Expand Down Expand Up @@ -764,7 +767,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});

const parsedStream = parseClaudeStreamJson(proc.stdout);
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
const parsed = parsedStream.resultJson ?? parseJsonLenient(proc.stdout);
return { proc, parsedStream, parsed };
};

Expand Down Expand Up @@ -824,19 +827,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? "claude_auth_required"
: transientUpstream
? "claude_transient_upstream"
: (proc.exitCode ?? 0) === 0
? null // Exit 0 + no parse = inferred success, not an error
: null;
const isInferredSuccess = (proc.exitCode ?? 0) === 0 && !loginMeta.requiresLogin && !transientUpstream;
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
errorMessage: fallbackErrorMessage,
errorMessage: isInferredSuccess ? null : fallbackErrorMessage,
errorCode,
errorFamily: transientUpstream ? "transient_upstream" : null,
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
errorMeta,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
...(isInferredSuccess ? { inferredSuccess: true } : {}),
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
...(transientRetryNotBefore
? { retryNotBefore: transientRetryNotBefore.toISOString() }
Expand Down
89 changes: 89 additions & 0 deletions packages/adapters/claude-local/src/server/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
extractClaudeRetryNotBefore,
isClaudeTransientUpstreamError,
parseClaudeStreamJson,
} from "./parse.js";

describe("isClaudeTransientUpstreamError", () => {
Expand Down Expand Up @@ -121,3 +122,91 @@ describe("extractClaudeRetryNotBefore", () => {
).toBeNull();
});
});

describe("parseClaudeStreamJson", () => {
it("parses normal line-delimited stream JSON", () => {
const stdout = [
'{"type":"system","subtype":"init","session_id":"sess-1"}',
'{"type":"assistant","session_id":"sess-1","message":{"content":[{"type":"text","text":"Hello"}]}}',
'{"type":"result","session_id":"sess-1","result":"Done","usage":{"input_tokens":10,"output_tokens":5},"total_cost_usd":0.001}',
].join("\n");

const parsed = parseClaudeStreamJson(stdout);
expect(parsed.sessionId).toBe("sess-1");
expect(parsed.resultJson).toEqual({
type: "result",
session_id: "sess-1",
result: "Done",
usage: { input_tokens: 10, output_tokens: 5 },
total_cost_usd: 0.001,
});
expect(parsed.usage).toEqual({
inputTokens: 10,
cachedInputTokens: 0,
outputTokens: 5,
});
expect(parsed.costUsd).toBe(0.001);
expect(parsed.summary).toBe("Done");
});

it("extracts result from markdown-wrapped JSON", () => {
const stdout = 'Some intro\n```json\n{"type":"result","session_id":"sess-2","result":"Wrapped","usage":{"input_tokens":1,"output_tokens":2},"total_cost_usd":0.0001}\n```\n';
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.sessionId).toBe("sess-2");
expect(parsed.resultJson).not.toBeNull();
expect(parsed.summary).toBe("Wrapped");
});

it("extracts result from mixed text with progress indicators", () => {
const stdout = 'Progress: 50%\n{"type":"result","session_id":"sess-3","result":"Mixed","usage":{"input_tokens":5,"output_tokens":5}}\nDone.';
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.sessionId).toBe("sess-3");
expect(parsed.resultJson).not.toBeNull();
expect(parsed.summary).toBe("Mixed");
});

it("extracts result using extractResultFromMixedOutput for interleaved text", () => {
const stdout = 'Loading...\n{"type":"system","subtype":"init","session_id":"sess-4"}\nThinking...\n{"type":"assistant","session_id":"sess-4","message":{"content":[{"type":"text","text":"Working"}]}}\nProgress: 75%\n{"type":"result","session_id":"sess-4","result":"Interleaved","usage":{"input_tokens":3,"output_tokens":4}}\nCleanup...';
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.sessionId).toBe("sess-4");
expect(parsed.resultJson).not.toBeNull();
expect(parsed.summary).toBe("Interleaved");
});

it("returns null resultJson when no result is found", () => {
const stdout = '{"type":"system","subtype":"init","session_id":"sess-5"}\nNo result here.';
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.sessionId).toBe("sess-5");
expect(parsed.resultJson).toBeNull();
expect(parsed.costUsd).toBeNull();
});

it("collects assistant text messages", () => {
const stdout = [
'{"type":"assistant","message":{"content":[{"type":"text","text":"First"}]}}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"Second"}]}}',
'{"type":"result","result":"Final","usage":{}}',
].join("\n");
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.summary).toBe("Final");
});

it("uses assistant texts as summary when result field is missing", () => {
const stdout = [
'{"type":"assistant","message":{"content":[{"type":"text","text":"First paragraph"}]}}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"Second paragraph"}]}}',
'{"type":"result","usage":{},"total_cost_usd":0}'
].join("\n");
const parsed = parseClaudeStreamJson(stdout);
expect(parsed.summary).toBe("First paragraph\n\nSecond paragraph");
});

it("handles empty stdout gracefully", () => {
const parsed = parseClaudeStreamJson("");
expect(parsed.sessionId).toBeNull();
expect(parsed.resultJson).toBeNull();
expect(parsed.summary).toBe("");
expect(parsed.usage).toBeNull();
expect(parsed.costUsd).toBeNull();
});
});
Loading