Skip to content
Merged
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
49 changes: 9 additions & 40 deletions app/sidecar/__tests__/message-processor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { MessageProcessor } from "../message-processor.js";
import { extractResultMarkdown, tryParseJsonFromText } from "../lib/result-extraction.js";
import { extractResultMarkdown } from "../lib/result-extraction.js";
import type { DisplayItem, DisplayItemEnvelope } from "../display-types.js";
import type { AgentEventEnvelope } from "../agent-events.js";

Expand Down Expand Up @@ -651,42 +651,11 @@ describe("MessageProcessor", () => {
});

// =========================================================================
// tryParseJsonFromText helper
// resultMarkdown is derived only from structured output
// =========================================================================

describe("tryParseJsonFromText", () => {
it("parses plain JSON string", () => {
const result = tryParseJsonFromText('{"status":"ok","count":3}');
expect(result).toEqual({ status: "ok", count: 3 });
});

it("strips ```json code fence before parsing", () => {
const text = "```json\n{\"status\":\"validation_complete\",\"validation_log_markdown\":\"# Log\"}\n```";
const result = tryParseJsonFromText(text) as Record<string, unknown>;
expect(result.status).toBe("validation_complete");
expect(result.validation_log_markdown).toBe("# Log");
});

it("strips plain ``` code fence before parsing", () => {
const text = "```\n{\"key\":\"value\"}\n```";
expect(tryParseJsonFromText(text)).toEqual({ key: "value" });
});

it("returns undefined for non-JSON text", () => {
expect(tryParseJsonFromText("just some plain text")).toBeUndefined();
});

it("returns undefined for empty string", () => {
expect(tryParseJsonFromText("")).toBeUndefined();
});
});

// =========================================================================
// resultMarkdown fallback from output text block
// =========================================================================

describe("resultMarkdown fallback from output text", () => {
it("extracts resultMarkdown from last output text when structuredOutput is absent", () => {
describe("resultMarkdown without structured output", () => {
it("does not extract resultMarkdown from last output text when structuredOutput is absent", () => {
const jsonText = JSON.stringify({
status: "validation_complete",
validation_log_markdown: "# Validation Log\n\nAll good.",
Expand All @@ -707,12 +676,11 @@ describe("MessageProcessor", () => {
const items = extractDisplayItems(out);
const resultItem = items.find((i) => i.type === "result");

expect(resultItem?.resultMarkdown).toContain("# Validation Log");
expect(resultItem?.resultMarkdown).toContain("# Tests");
expect(resultItem?.structuredOutput).toMatchObject({ status: "validation_complete" });
expect(resultItem?.resultMarkdown).toBeUndefined();
expect(resultItem?.structuredOutput).toBeUndefined();
});

it("extracts resultMarkdown from output text with ```json code fence", () => {
it("does not extract resultMarkdown from fenced JSON output text", () => {
const jsonText = "```json\n" + JSON.stringify({
status: "validation_complete",
validation_log_markdown: "# Log",
Expand All @@ -730,7 +698,8 @@ describe("MessageProcessor", () => {
const items = extractDisplayItems(out);
const resultItem = items.find((i) => i.type === "result");

expect(resultItem?.resultMarkdown).toBe("# Log");
expect(resultItem?.resultMarkdown).toBeUndefined();
expect(resultItem?.structuredOutput).toBeUndefined();
});

it("does not override structuredOutput when already present", () => {
Expand Down
80 changes: 1 addition & 79 deletions app/sidecar/__tests__/result-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { extractResultMarkdown, tryParseJsonFromText } from "../lib/result-extraction";
import { extractResultMarkdown } from "../lib/result-extraction.js";

// ---------------------------------------------------------------------------
// extractResultMarkdown
Expand Down Expand Up @@ -60,81 +60,3 @@ describe("extractResultMarkdown", () => {
expect(result).toBe("included");
});
});

// ---------------------------------------------------------------------------
// tryParseJsonFromText
// ---------------------------------------------------------------------------

describe("tryParseJsonFromText", () => {
it("parses valid JSON directly", () => {
expect(tryParseJsonFromText('{"key": "value"}')).toEqual({ key: "value" });
});

it("parses JSON wrapped in code fences", () => {
const text = "```json\n{\"key\": \"value\"}\n```";
expect(tryParseJsonFromText(text)).toEqual({ key: "value" });
});

it("parses JSON wrapped in plain code fences (no language tag)", () => {
const text = "```\n{\"key\": \"value\"}\n```";
expect(tryParseJsonFromText(text)).toEqual({ key: "value" });
});

it("returns undefined for invalid JSON", () => {
expect(tryParseJsonFromText("not json at all")).toBeUndefined();
});

it("returns undefined for empty string", () => {
expect(tryParseJsonFromText("")).toBeUndefined();
});

it("parses JSON arrays", () => {
expect(tryParseJsonFromText("[1, 2, 3]")).toEqual([1, 2, 3]);
});

it("handles whitespace around fences", () => {
const text = "```json\n { \"a\": 1 } \n``` ";
expect(tryParseJsonFromText(text)).toEqual({ a: 1 });
});

it("parses primitive JSON values", () => {
expect(tryParseJsonFromText('"hello"')).toBe("hello");
expect(tryParseJsonFromText("42")).toBe(42);
expect(tryParseJsonFromText("true")).toBe(true);
expect(tryParseJsonFromText("null")).toBeNull();
});

it("extracts JSON from text with preamble before code fence", () => {
const text = 'Here is the result:\n\n```json\n{"status": "research_complete", "count": 3}\n```';
expect(tryParseJsonFromText(text)).toEqual({ status: "research_complete", count: 3 });
});

it("extracts JSON from text with preamble and postamble around code fence", () => {
const text = 'I completed the research.\n\n```json\n{"status": "done"}\n```\n\nLet me know if you need more.';
expect(tryParseJsonFromText(text)).toEqual({ status: "done" });
});

it("extracts JSON object from plain text without code fences", () => {
const text = 'Now I have all the outputs. Let me consolidate:\n\n{"status": "research_complete", "dimensions_selected": 3}';
expect(tryParseJsonFromText(text)).toEqual({ status: "research_complete", dimensions_selected: 3 });
});

it("handles nested braces in brace-matching extraction", () => {
const text = 'Result:\n{"outer": {"inner": "value"}, "count": 1}';
expect(tryParseJsonFromText(text)).toEqual({ outer: { inner: "value" }, count: 1 });
});

it("handles strings with braces in brace-matching extraction", () => {
const text = 'Output:\n{"message": "use {name} placeholder", "ok": true}';
expect(tryParseJsonFromText(text)).toEqual({ message: "use {name} placeholder", ok: true });
});

it("returns undefined when no valid JSON exists anywhere", () => {
expect(tryParseJsonFromText("Just some text with no JSON at all")).toBeUndefined();
});

it("returns undefined for malformed JSON in code fence", () => {
const text = '```json\n{invalid json}\n```';
expect(tryParseJsonFromText(text)).toBeUndefined();
});
});
125 changes: 125 additions & 0 deletions app/sidecar/__tests__/sdk-output-format.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Integration test: verifies whether the SDK populates structured_output
* when outputFormat is passed with a nested JSON schema.
*
* This test guards the previously observed upstream SDK bug:
* anthropics/claude-agent-sdk-typescript#277
*
* Run with ANTHROPIC_API_KEY set:
* ANTHROPIC_API_KEY=sk-... npx vitest run __tests__/sdk-output-format.integration.test.ts
*
* If the SDK regresses, the "nested schema" test will fail because the app
* requires structured_output for outputFormat runs.
*/
import { describe, it, expect } from "vitest";
import { query } from "@anthropic-ai/claude-agent-sdk";

const HAS_API_KEY = !!process.env.ANTHROPIC_API_KEY;

/** Flat schema — no nested objects. Used as the control. */
const FLAT_SCHEMA = {
type: "object" as const,
properties: {
status: { type: "string" as const },
count: { type: "number" as const },
},
required: ["status", "count"],
additionalProperties: false,
};

/** Nested schema — contains an object-typed property inside the top-level object.
* This shape previously triggered the SDK bug for non-trivial schemas. */
const NESTED_SCHEMA = {
type: "object" as const,
properties: {
status: { type: "string" as const },
metadata: {
type: "object" as const,
properties: {
count: { type: "number" as const },
tags: {
type: "array" as const,
items: { type: "string" as const },
},
},
required: ["count", "tags"],
additionalProperties: false,
},
items: {
type: "array" as const,
items: {
type: "object" as const,
properties: {
id: { type: "string" as const },
value: { type: "number" as const },
},
required: ["id", "value"],
additionalProperties: false,
},
},
},
required: ["status", "metadata", "items"],
additionalProperties: false,
};

async function runQuery(schema: Record<string, unknown>, prompt: string): Promise<{ subtype: string; structured_output: unknown; result: unknown }> {
let resultMsg: Record<string, unknown> | null = null;

for await (const msg of query({
prompt,
options: {
model: "claude-haiku-4-5-20251001",
outputFormat: { type: "json_schema", schema },
maxTurns: 2,
permissionMode: "bypassPermissions" as const,
},
})) {
const m = msg as Record<string, unknown>;
if (m.type === "result") resultMsg = m;
}

if (!resultMsg) throw new Error("No result message from SDK");

return {
subtype: String(resultMsg.subtype ?? ""),
structured_output: resultMsg.structured_output,
result: resultMsg.result,
};
}

describe.skipIf(!HAS_API_KEY)("SDK outputFormat — structured_output presence (VU-1015)", () => {
it("populates structured_output for a flat (non-nested) schema", async () => {
const { subtype, structured_output } = await runQuery(
FLAT_SCHEMA,
'Return JSON with status "ok" and count 1.',
);

expect(subtype).toBe("success");
// If this assertion fails, the SDK is broken even for flat schemas.
expect(structured_output).not.toBeNull();
expect(structured_output).not.toBeUndefined();
expect(structured_output).toMatchObject({ status: expect.any(String), count: expect.any(Number) });
}, 30_000);

it("populates structured_output for a nested schema", async () => {
const { subtype, structured_output } = await runQuery(
NESTED_SCHEMA,
'Return JSON: status "complete", metadata with count 2 and tags ["a","b"], items [{id:"x",value:1},{id:"y",value:2}].',
);

expect(subtype).toBe("success");
// This is the canary for the upstream SDK bug (anthropics/claude-agent-sdk-typescript#277).
// If structured_output is null/undefined here, the SDK has regressed and outputFormat
// runs must fail rather than parse text fallback.
expect(structured_output).not.toBeNull();
expect(structured_output).not.toBeUndefined();
expect(structured_output).toMatchObject({
status: expect.any(String),
metadata: {
count: expect.any(Number),
tags: expect.any(Array),
},
items: expect.any(Array),
});
}, 30_000);
});
Loading
Loading