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
28 changes: 28 additions & 0 deletions CHANGELOG/pages/redesign-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
Append-only lane for the public redesign chat, reproducible answer receipts, and their
transition into an authenticated live conversation. Newest entries first.

## 2026-07-17 - Honor JSON and table response shapes end to end

The last declared shape boundary falls: "as JSON" / "in a markdown table" are now
detected, instructed, and deterministically enforced. The policy reads the RAW model
output (parseMemo is lossy for structured blocks), extracts the first balanced JSON
block (string-aware brace scan, fence-stripped, 20k-char bound) or the longest
`| ... |` line run, validates, and fails closed with an honest retry message when the
model did not comply. Honesty on unsupported runs surfaces as a risks row - a
limitation cannot ride inside JSON without corrupting it. Both renderers now show
structured bodies in a bounded monospace block (`.rd-answer-structured`) with its own
horizontal scroll, via a shared `isStructuredAnswer` so they cannot disagree.

**PR / canonical main commit**: `PENDING #NNN MAIN SHA / FINAL QA`.

**Evidence state**:
- Source: `pending`
- Checks: `npx tsc --noEmit` -> 0 errors. `npx vitest run chatRuns.responseShape +
ChatResponseShape.guard + ChatRunLifecycle.guard` -> 30 passed (4 new: detector
incl. incidental-mention negatives, fenced-JSON passthrough with brace-in-string,
invalid-JSON fail-closed + risks-row honesty, table extraction + missing-table
fail-closed).
- Visual proof: `not recorded` at entry time - mono block verified in the design pass.
- Preview: Tier B one-flow-regression runs on the PR.
- Production live: `not recorded` at entry time.

**Author**: Homen Shum + Claude Fable 5.
**Touches**: chat runtime + both answer renderers + agent-workspace.css.

## 2026-07-17 - Resolve the four hardening residuals from the production audit

The 2026-07-16 audit cycle left four filed residuals (#567-#570); all four land here.
Expand Down
72 changes: 72 additions & 0 deletions convex/domains/redesign/chatRuns.responseShape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,78 @@ describe("redesign chat runtime response policy", () => {
expect(unsupported.shortAnswer.split(/\s+/).length).toBeLessThanOrEqual(8);
});

it("detects explicit JSON and table format requests, not incidental mentions", () => {
expect(detectRequestedResponseShape("Return the funding rounds as JSON.")).toEqual({ kind: "json" });
expect(detectRequestedResponseShape("Respond with valid JSON only.")).toEqual({ kind: "json" });
expect(detectRequestedResponseShape("Output a JSON array of competitors.")).toEqual({ kind: "json" });
expect(detectRequestedResponseShape("Compare the vendors in a markdown table.")).toEqual({ kind: "table" });
expect(detectRequestedResponseShape("Format the pricing tiers as a table.")).toEqual({ kind: "table" });
// Incidental prose stays a memo.
expect(detectRequestedResponseShape("Why did the JSON parser fail last run?")).toEqual({ kind: "memo" });
expect(detectRequestedResponseShape("Check the table below for the raw numbers.")).toEqual({ kind: "memo" });
});

it("passes through valid JSON from raw output, normalized and fence-stripped", () => {
const raw = 'Here is the data:\n```json\n{"company":"Acme","round":"Series B","note":"brace } inside string"}\n```\nHope that helps!';
const shaped = applyDeterministicResponsePolicy(
"Return the round as JSON.",
memo,
[{ idx: 1, source: "https://example.com/acme", quote: "Acme raised." }],
raw,
);
expect(JSON.parse(shaped.shortAnswer)).toEqual({
company: "Acme",
round: "Series B",
note: "brace } inside string",
});
expect(shaped.whyItMatters).toBe("");
expect(shaped.risks).toEqual([]);
});

it("fails closed with an honest message when JSON is invalid, and surfaces the source-needed limitation as a risks row on unsupported runs", () => {
const invalid = applyDeterministicResponsePolicy(
"Return the round as JSON.",
memo,
[{ idx: 1, source: "https://example.com/acme" }],
"The model rambled with {broken json",
);
expect(invalid.shortAnswer).toContain("did not produce valid JSON");

const unsupported = applyDeterministicResponsePolicy(
"Return the round as JSON.",
memo,
[{ source: "Setup" }],
'{"company":"Acme"}',
);
// A limitation cannot ride inside JSON without corrupting it — it must
// surface as a risks row instead, which un-compacts the render.
expect(JSON.parse(unsupported.shortAnswer)).toEqual({ company: "Acme" });
expect(unsupported.risks).toContain(
"Source needed: no supported URL is available, so source-strength and claim-strength comparisons are unverified.",
);
});

it("extracts the markdown table block and drops surrounding prose", () => {
const raw = "Sure!\n| Vendor | Price |\n| --- | --- |\n| Acme | $10 |\n| Bolt | $12 |\nLet me know if you need more.";
const shaped = applyDeterministicResponsePolicy(
"Compare vendors in a markdown table.",
memo,
[{ idx: 1, source: "https://example.com/acme" }],
raw,
);
expect(shaped.shortAnswer.split("\n")).toHaveLength(4);
expect(shaped.shortAnswer.startsWith("| Vendor |")).toBe(true);
expect(shaped.shortAnswer).not.toContain("Sure!");

const missing = applyDeterministicResponsePolicy(
"Compare vendors in a markdown table.",
memo,
[{ idx: 1, source: "https://example.com/acme" }],
"No table here, just prose.",
);
expect(missing.shortAnswer).toContain("did not produce a Markdown table");
});

it("does not mistake incidental prose about titles for a shape request", () => {
expect(
detectRequestedResponseShape("Summarize the report and explain why its title is misleading."),
Expand Down
146 changes: 144 additions & 2 deletions convex/domains/redesign/chatRuns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,9 @@ export type RequestedResponseShape =
| { kind: "bullets"; count: number }
| { kind: "sentence" }
| { kind: "paragraph" }
| { kind: "word_limit"; limit: number };
| { kind: "word_limit"; limit: number }
| { kind: "json" }
| { kind: "table" };

const RESPONSE_COUNT_WORDS: Record<string, number> = {
one: 1,
Expand Down Expand Up @@ -898,6 +900,19 @@ function detectWordLimit(normalized: string): number | null {
return null;
}

// Explicit machine-format asks. Anchored on format keywords next to a request
// verb or preposition so incidental prose ("the JSON parser failed", "the
// table below") stays a memo.
const JSON_SHAPE_PATTERNS: RegExp[] = [
/\b(?:as|in|return|output|respond (?:in|with))\s+(?:valid\s+|raw\s+|pure\s+)?json\b/,
/\bjson\s+(?:only|format|object|array)\b/,
/\bformat(?:ted)?\s+as\s+json\b/,
];
const TABLE_SHAPE_PATTERNS: RegExp[] = [
/\b(?:as|in|return|output|format(?:ted)? as)\s+a?\s*(?:markdown\s+)?table\b/,
/\b(?:markdown\s+)?table\s+(?:only|format)\b/,
];

export function detectRequestedResponseShape(prompt: string): RequestedResponseShape {
const normalized = prompt.replace(/\s+/g, " ").trim().toLowerCase();
if (TITLE_ONLY_PATTERNS.some((pattern) => pattern.test(normalized))) {
Expand All @@ -910,6 +925,15 @@ export function detectRequestedResponseShape(prompt: string): RequestedResponseS
const count = bulletMatch ? parseRequestedCount(bulletMatch[1]) : null;
if (count) return { kind: "bullets", count };

// Machine formats outrank prose shapes: "one row per company, as JSON"
// is a JSON ask, not a sentence ask.
if (JSON_SHAPE_PATTERNS.some((pattern) => pattern.test(normalized))) {
return { kind: "json" };
}
if (TABLE_SHAPE_PATTERNS.some((pattern) => pattern.test(normalized))) {
return { kind: "table" };
}

if (SINGLE_SENTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) {
return { kind: "sentence" };
}
Expand Down Expand Up @@ -972,6 +996,22 @@ export function responseShapeSystemInstructions(shape: RequestedResponseShape):
citationInstruction: inlineCitations,
calculationInstruction: compactCalculation,
};
case "json":
return {
shapeInstruction:
"The user requested JSON. Output ONLY one valid JSON object or array — no code fences, no prose before or after, no comments.",
citationInstruction:
"Do not embed [N] citation markers inside JSON values; supporting sources appear in the evidence panel.",
calculationInstruction: compactCalculation,
};
case "table":
return {
shapeInstruction:
"The user requested a table. Output ONLY one GitHub-flavored Markdown table (header row, separator row, data rows) — no code fences and no prose before or after.",
citationInstruction:
"Do not embed [N] citation markers inside table cells; supporting sources appear in the evidence panel.",
calculationInstruction: compactCalculation,
};
case "memo":
return {
shapeInstruction: `Produce a banker-style memo. Do not include To, From, Date, or Subject headers. Use exactly these markdown section headings:
Expand Down Expand Up @@ -1030,6 +1070,69 @@ function citationIndices(text: string): number[] {
// Shared by the superlative gate and the sentence/word-limit shape policies.
const SENTENCE_SPLITTER = /(?<=[.!?])\s+(?!\[\d+\])/;

function stripCodeFences(text: string): string {
return text.replace(/```[a-z]*\r?\n?/gi, "").replace(/```/g, "").trim();
}

// Upper bound on a structured block we will validate and return verbatim.
// Truncating JSON breaks validity, so oversized blocks fail honestly instead.
const MAX_STRUCTURED_BLOCK_CHARS = 20_000;

/**
* First balanced top-level JSON object/array in the text, string-aware so
* braces inside quoted values don't unbalance the scan. Null when nothing
* balanced is found.
*/
function extractJsonBlock(raw: string): string | null {
const text = stripCodeFences(raw);
const start = text.search(/[[{]/);
if (start === -1) return null;
const open = text[start];
const close = open === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = inString;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return text.slice(start, i + 1);
}
}
return null;
}

/** Longest run of consecutive `| ... |` lines — header + separator minimum. */
function extractMarkdownTable(raw: string): string | null {
const lines = stripCodeFences(raw).split("\n");
let best: string[] = [];
let current: string[] = [];
for (const line of lines) {
if (/^\s*\|.*\|\s*$/.test(line)) {
current.push(line.trim());
} else {
if (current.length > best.length) best = current;
current = [];
}
}
if (current.length > best.length) best = current;
return best.length >= 2 ? best.join("\n") : null;
}

// Flatten memo prose into plain sentences: markdown bullet/number prefixes
// removed per line, whitespace collapsed.
function plainProse(parts: string[]): string {
Expand Down Expand Up @@ -1127,6 +1230,10 @@ export function applyDeterministicResponsePolicy(
blocking?: boolean;
verificationState?: EvidenceVerificationState;
}>,
// Unparsed model output. JSON/table shapes must read this: parseMemo is
// heading-driven and lossy, so structured blocks cannot be reconstructed
// from memo fields. Optional so prose-shape callers/tests stay unchanged.
rawText?: string,
): ParsedMemo {
const supportedRows = evidence.filter(
(row) =>
Expand Down Expand Up @@ -1197,6 +1304,41 @@ export function applyDeterministicResponsePolicy(
};
}

if (shape.kind === "json") {
const block = extractJsonBlock(rawText ?? honest.shortAnswer);
let rendered: string | null = null;
if (block && block.length <= MAX_STRUCTURED_BLOCK_CHARS) {
try {
rendered = JSON.stringify(JSON.parse(block), null, 2);
} catch {
rendered = null;
}
}
return {
shortAnswer:
rendered
?? "The run did not produce valid JSON. Retry the run, or drop the JSON constraint for a prose answer.",
whyItMatters: "",
// The limitation cannot ride inside the JSON without corrupting it, so
// it surfaces as a risks row (the compact gate shows risks when present).
risks: rendered && !hasSupportedUrl ? [SOURCE_NEEDED_LIMITATION] : [],
nextAction: "",
};
}

if (shape.kind === "table") {
const table = extractMarkdownTable(rawText ?? honest.shortAnswer);
const bounded = table && table.length <= MAX_STRUCTURED_BLOCK_CHARS ? table : null;
return {
shortAnswer:
bounded
?? "The run did not produce a Markdown table. Retry the run, or drop the table constraint for a prose answer.",
whyItMatters: "",
risks: bounded && !hasSupportedUrl ? [SOURCE_NEEDED_LIMITATION] : [],
nextAction: "",
};
}

if (shape.kind === "sentence") {
const prose = plainProse([honest.shortAnswer]) || plainProse([honest.whyItMatters]) || "Evidence review.";
const sentence = (prose.split(SENTENCE_SPLITTER)[0] ?? prose).trim().slice(0, 400);
Expand Down Expand Up @@ -2011,7 +2153,7 @@ Context: ${JSON.stringify(contextBundle)}${conversationSection}${pinnedSection}$
if (evidence.length > 0 && !/\[\d+\]/.test(parsed.whyItMatters)) {
parsed.whyItMatters = `${parsed.whyItMatters} [1]`;
}
parsed = applyDeterministicResponsePolicy(args.prompt, parsed, evidence);
parsed = applyDeterministicResponsePolicy(args.prompt, parsed, evidence, rawText);
const tr4 = {
step: "Bind evidence",
detail: `${evidence.length} citations from ${groundingChunks.length} Gemini chunks + ${fallbackSources.length} fallback sources + ${memoryEvidence.length} cached sources`,
Expand Down
15 changes: 15 additions & 0 deletions shared/redesign/answerFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Shared detection for machine-formatted answer bodies (JSON / Markdown
* table) produced by the json/table response shapes in
* convex/domains/redesign/chatRuns.ts. Both the live ChatSurface and the
* reproducible replay page consult this so the two renderers cannot disagree
* about when an answer needs monospace block treatment instead of prose.
*/
export function isStructuredAnswer(text: string): boolean {
const trimmed = text.trimStart();
if (/^[[{]/.test(trimmed)) return true;
const pipeLines = text
.split("\n")
.filter((line) => /^\s*\|.*\|\s*$/.test(line));
return pipeLines.length >= 2;
}
17 changes: 17 additions & 0 deletions src/features/redesign/agent-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@
gap: 18px;
}

/* JSON / Markdown-table answer bodies (json/table response shapes). Mono
block with its own horizontal scroll so wide tables never stretch the
workspace column. */
[data-redesign] .rd-answer-structured {
margin: 0;
padding: 12px 14px;
color: var(--rd-ink-strong);
font-family: var(--rd-font-mono, ui-monospace, monospace);
font-size: 12.5px;
line-height: 1.55;
white-space: pre;
overflow-x: auto;
background: var(--rd-surface-sunken, rgba(127, 127, 127, 0.06));
border: 1px solid var(--rd-line-faint);
border-radius: 8px;
}

[data-redesign] .rd-answer-copy {
margin: 0;
color: var(--rd-ink-strong);
Expand Down
29 changes: 17 additions & 12 deletions src/features/redesign/pages/ReproducibleChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useRedesignChatByHash, type ChatAnswer } from "../hooks/useRedesignChatRun";
import { isStructuredAnswer } from "../../../../shared/redesign/answerFormat";

interface ReproducibleChatPageProps {
hash: string;
Expand Down Expand Up @@ -220,18 +221,22 @@ export function ReproducibleChatPage({ hash }: ReproducibleChatPageProps) {

<section>
<div className="rd-eyebrow" style={{ marginBottom: 6 }}>Short answer</div>
<p style={{
fontFamily: "var(--rd-font-display)",
fontSize: 18,
fontWeight: 510,
lineHeight: 1.4,
color: "var(--rd-ink-strong)",
letterSpacing: "-0.18px",
whiteSpace: "pre-wrap",
margin: 0,
}}>
{packet.shortAnswer}
</p>
{isStructuredAnswer(packet.shortAnswer) ? (
<pre className="rd-answer-structured">{packet.shortAnswer}</pre>
) : (
<p style={{
fontFamily: "var(--rd-font-display)",
fontSize: 18,
fontWeight: 510,
lineHeight: 1.4,
color: "var(--rd-ink-strong)",
letterSpacing: "-0.18px",
whiteSpace: "pre-wrap",
margin: 0,
}}>
{packet.shortAnswer}
</p>
)}
</section>

{packet.whyItMatters?.trim() && <section>
Expand Down
Loading
Loading