From be5336ee30f607c748264ce687f66c043ff548f5 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 22 Sep 2026 09:55:36 +0200 Subject: [PATCH 1/3] An agent's answer is readable in the chat A wfpy agent finishes a turn by writing its ports, and the runtime reports that as one JSON object whose port values are themselves JSON. The text reaching the chat is therefore escaped twice: a unified diff arrives as a single line of \n and \" and renders as one unreadable paragraph, which is what the planner's rounds have looked like all along. agent-outputs.ts turns such a message into Markdown, which the panel already parses and sanitizes: a heading per port, one line per scalar field, and a fenced block for anything long, tagged diff when it is one. Scalars come before blocks so the options and the reason read before the patch. Prose keeps its punctuation; an option string gets backticks. A value carrying its own fence gets a longer one. Anything that is not an outputs object returns null and goes through untouched, so ordinary replies are unaffected. --- packages/diagram-client/src/agent-outputs.ts | 96 +++++++++++++++++++ .../src/chat-panel-integrated.ts | 13 ++- .../diagram-client/test/agent-outputs.test.ts | 56 +++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/diagram-client/src/agent-outputs.ts create mode 100644 packages/diagram-client/test/agent-outputs.test.ts diff --git a/packages/diagram-client/src/agent-outputs.ts b/packages/diagram-client/src/agent-outputs.ts new file mode 100644 index 0000000..fc50316 --- /dev/null +++ b/packages/diagram-client/src/agent-outputs.ts @@ -0,0 +1,96 @@ +/** + * An agent's answer, formatted for reading. + * + * A wfpy agent finishes a turn by writing its ports, and the runtime reports + * that as one JSON object, `{"outputs": {"": ""}}`. A port's + * value is itself usually JSON, so the text that reaches the chat is escaped + * twice: a unified diff arrives as one line of `\n` and `\"` and renders as a + * single unreadable paragraph. + * + * `formatAgentOutputs` turns that into Markdown, which the panel already knows + * how to render and sanitize: a heading per port, one line per scalar field, + * and a fenced block for anything long, tagged `diff` when it is one. It + * returns null for anything that is not an outputs object, so ordinary replies + * go through untouched. + */ + +/** A value that reads better as a block than as a line. */ +function isBlock(value: string): boolean { + return value.includes('\n') || value.length > 160; +} + +/** Whether a string is a unified diff, i.e. worth syntax colouring as one. */ +function isDiff(value: string): boolean { + return /^---\s+\S/m.test(value) && /^\+\+\+\s+\S/m.test(value) || /^@@ -\d/m.test(value); +} + +/** A fence long enough to hold a value that itself contains backticks. */ +function fence(value: string): string { + let ticks = 3; + for (const run of value.match(/`+/g) ?? []) ticks = Math.max(ticks, run.length + 1); + return '`'.repeat(ticks); +} + +function block(value: string, lang: string): string { + const f = fence(value); + return `${f}${lang}\n${value.replace(/\s+$/, '')}\n${f}`; +} + +/** One field of a port's value. */ +function field(key: string, value: unknown): string { + if (value === null || value === undefined) return `- **${key}** \`null\``; + if (typeof value === 'boolean' || typeof value === 'number') return `- **${key}** \`${value}\``; + if (typeof value === 'string') { + if (!value) return `- **${key}** \`""\``; + if (isBlock(value)) return `**${key}**\n\n${block(value, isDiff(value) ? 'diff' : '')}`; + // Prose keeps its punctuation; anything else reads as a value. + const prose = /\s/.test(value) && /[.,;]/.test(value); + return prose ? `- **${key}** ${value}` : `- **${key}** \`${value}\``; + } + return `**${key}**\n\n${block(JSON.stringify(value, null, 2), 'json')}`; +} + +/** One port: its name, then its fields, scalars before blocks. */ +function port(name: string, value: unknown): string { + let inner: unknown = value; + if (typeof value === 'string') { + const text = value.trim(); + if (text.startsWith('{') || text.startsWith('[')) { + try { + inner = JSON.parse(text); + } catch { + inner = value; + } + } + } + if (inner === null || typeof inner !== 'object' || Array.isArray(inner)) { + return `### ${name}\n\n${field('value', inner).replace(/^- \*\*value\*\* /, '')}`; + } + const entries = Object.entries(inner as Record); + const lines = entries.filter(([, v]) => !(typeof v === 'string' && isBlock(v)) && typeof v !== 'object'); + const blocks = entries.filter(([, v]) => (typeof v === 'string' && isBlock(v)) || (v !== null && typeof v === 'object')); + const parts: string[] = [`### ${name}`]; + if (lines.length) parts.push(lines.map(([k, v]) => field(k, v)).join('\n')); + for (const [k, v] of blocks) parts.push(field(k, v)); + return parts.join('\n\n'); +} + +/** + * Markdown for an agent's outputs object, or null when `text` is not one. + */ +export function formatAgentOutputs(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('{') || !trimmed.includes('"outputs"')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (parsed === null || typeof parsed !== 'object') return null; + const outputs = (parsed as Record)['outputs']; + if (outputs === null || typeof outputs !== 'object' || Array.isArray(outputs)) return null; + const entries = Object.entries(outputs as Record); + if (!entries.length) return null; + return entries.map(([name, value]) => port(name, value)).join('\n\n'); +} diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index 1a827af..4c2003e 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -7,6 +7,7 @@ import { html, render, nothing, TemplateResult } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdownSafe } from './markdown'; +import { formatAgentOutputs } from './agent-outputs'; import { shouldStick } from './chat-scroll'; import { RunAgentStreamActionHandler, @@ -23,6 +24,16 @@ import { */ const markdownCache = new Map(); const MARKDOWN_CACHE_MAX = 500; +/** + * An agent's answer arrives as one JSON object whose port values are + * themselves JSON, so a diff reaches the chat escaped twice and renders as a + * paragraph of `\n`. Reformat that as Markdown before parsing it; anything + * else is returned unchanged. + */ +function readable(content: string): string { + return formatAgentOutputs(content) ?? content; +} + function renderMarkdownMemo(md: string): string { const cached = markdownCache.get(md); if (cached !== undefined) { @@ -1572,7 +1583,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ${!isUser && item.thinking ? this.thinkingTemplate(item.thinking) : nothing}
- ${isUser ? item.content : unsafeHTML(renderMarkdownMemo(item.content))} + ${isUser ? item.content : unsafeHTML(renderMarkdownMemo(readable(item.content)))}
`; diff --git a/packages/diagram-client/test/agent-outputs.test.ts b/packages/diagram-client/test/agent-outputs.test.ts new file mode 100644 index 0000000..f35bcb4 --- /dev/null +++ b/packages/diagram-client/test/agent-outputs.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { formatAgentOutputs } from '../src/agent-outputs'; + +/** The shape the runtime reports when an agent writes its ports. */ +const planner = JSON.stringify({ + outputs: { + Next: JSON.stringify({ + front_options: 'ftd=auto', + handshake_options: 'decision-slots=4 ready-slots-outside-loops=true', + model_patch: '--- a/core/cpu.py\n+++ b/core/cpu.py\n@@ -704,7 +704,7 @@\n- cmd = MemCmd.Prefetch(nxt)\n+ cmd = MemCmd.Ifetch(nxt)\n', + reason: 'Launch issues Ifetch instead of Prefetch, decoupling decode from the register read.', + stop: false + }) + } +}); + +describe('formatAgentOutputs', () => { + it('leaves anything that is not an outputs object alone', () => { + expect(formatAgentOutputs('Round 3 looks good to me.')).toBeNull(); + expect(formatAgentOutputs('{"not_outputs": 1}')).toBeNull(); + expect(formatAgentOutputs('{"outputs": "a string"}')).toBeNull(); + expect(formatAgentOutputs('{"outputs": {} }')).toBeNull(); + expect(formatAgentOutputs('{"outputs": {')).toBeNull(); + }); + + it('names each port as a heading', () => { + expect(formatAgentOutputs(planner)).toContain('### Next'); + }); + + it('unescapes the diff and fences it for colouring', () => { + const out = formatAgentOutputs(planner)!; + expect(out).toContain('```diff'); + expect(out).toContain('+ cmd = MemCmd.Ifetch(nxt)'); + expect(out).not.toContain('\\n'); + }); + + it('puts scalars on their own lines and prose without backticks', () => { + const out = formatAgentOutputs(planner)!; + expect(out).toContain('- **front_options** `ftd=auto`'); + expect(out).toContain('- **stop** `false`'); + expect(out).toContain('- **reason** Launch issues Ifetch'); + }); + + it('reads the short fields before the long ones', () => { + const out = formatAgentOutputs(planner)!; + expect(out.indexOf('front_options')).toBeLessThan(out.indexOf('model_patch')); + }); + + it('survives a value that is not JSON, and one that carries backticks', () => { + const plain = JSON.stringify({ outputs: { Log: 'nothing to report' } }); + expect(formatAgentOutputs(plain)).toContain('nothing to report'); + const ticks = JSON.stringify({ outputs: { Note: JSON.stringify({ body: 'a\n```\nfence\n```\ninside' }) } }); + const out = formatAgentOutputs(ticks)!; + expect(out).toContain('````'); + }); +}); From 87d27f47ef27b3840b7b59044e0aaeb4c6dc2a71 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 22 Sep 2026 09:59:29 +0200 Subject: [PATCH 2/3] The property panel unwraps a port value and keeps a diff out of Markdown Two faults on the same values the chat now formats. A port's value is itself JSON, so the tree showed one escaped string truncated at 500 characters instead of the fields inside it. And a diff went through the Markdown heuristic, whose list rule matches a line beginning with a dash and a space, which is every removed line: a patch rendered as a bullet list with its minus signs eaten. The string branch now unwraps an embedded JSON value and recurses, and sends a diff or any multi-line string to a scrolling pre instead. The three predicates are shared with the chat's formatter rather than copied, so the two panels agree on what a diff is. --- packages/diagram-client/src/agent-outputs.ts | 33 +++++++++++-------- .../diagram-client/src/diagram-client.css | 22 +++++++++++++ packages/diagram-client/src/property-panel.ts | 17 ++++++++++ .../diagram-client/test/agent-outputs.test.ts | 25 +++++++++++++- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/diagram-client/src/agent-outputs.ts b/packages/diagram-client/src/agent-outputs.ts index fc50316..3d876bb 100644 --- a/packages/diagram-client/src/agent-outputs.ts +++ b/packages/diagram-client/src/agent-outputs.ts @@ -15,12 +15,12 @@ */ /** A value that reads better as a block than as a line. */ -function isBlock(value: string): boolean { +export function isBlock(value: string): boolean { return value.includes('\n') || value.length > 160; } /** Whether a string is a unified diff, i.e. worth syntax colouring as one. */ -function isDiff(value: string): boolean { +export function looksLikeDiff(value: string): boolean { return /^---\s+\S/m.test(value) && /^\+\+\+\s+\S/m.test(value) || /^@@ -\d/m.test(value); } @@ -42,7 +42,7 @@ function field(key: string, value: unknown): string { if (typeof value === 'boolean' || typeof value === 'number') return `- **${key}** \`${value}\``; if (typeof value === 'string') { if (!value) return `- **${key}** \`""\``; - if (isBlock(value)) return `**${key}**\n\n${block(value, isDiff(value) ? 'diff' : '')}`; + if (isBlock(value)) return `**${key}**\n\n${block(value, looksLikeDiff(value) ? 'diff' : '')}`; // Prose keeps its punctuation; anything else reads as a value. const prose = /\s/.test(value) && /[.,;]/.test(value); return prose ? `- **${key}** ${value}` : `- **${key}** \`${value}\``; @@ -51,18 +51,23 @@ function field(key: string, value: unknown): string { } /** One port: its name, then its fields, scalars before blocks. */ -function port(name: string, value: unknown): string { - let inner: unknown = value; - if (typeof value === 'string') { - const text = value.trim(); - if (text.startsWith('{') || text.startsWith('[')) { - try { - inner = JSON.parse(text); - } catch { - inner = value; - } - } +/** + * A string that is itself JSON, parsed; otherwise the string. A port's value + * arrives escaped inside the outputs object, so this is the second unwrap. + */ +export function parseEmbedded(value: unknown): unknown { + if (typeof value !== 'string') return value; + const text = value.trim(); + if (!text.startsWith('{') && !text.startsWith('[')) return value; + try { + return JSON.parse(text); + } catch { + return value; } +} + +function port(name: string, value: unknown): string { + const inner: unknown = parseEmbedded(value); if (inner === null || typeof inner !== 'object' || Array.isArray(inner)) { return `### ${name}\n\n${field('value', inner).replace(/^- \*\*value\*\* /, '')}`; } diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css index 09ed365..3e82782 100644 --- a/packages/diagram-client/src/diagram-client.css +++ b/packages/diagram-client/src/diagram-client.css @@ -2734,6 +2734,28 @@ details[open] > summary .agent-chat-expand-arrow { font-style: italic; } +/* A value too long or too structured for one line: a diff, or any string + with newlines in it. Kept out of Markdown, which would read a diff's + removed lines as a bullet list. */ +.agent-json-pre { + margin: 4px 0 0; + padding: 6px 8px; + max-height: 22em; + overflow: auto; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11.5px; + line-height: 1.45; + white-space: pre; + tab-size: 4; + color: var(--vscode-editor-foreground, #ccc); + background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.1)); + border: 1px solid var(--vscode-widget-border, rgba(127, 127, 127, 0.25)); + border-radius: 3px; +} +.agent-json-diff { + color: var(--vscode-editor-foreground, #ccc); +} + /* ── Markdown rendering inside agent chat ──────────────────────────── */ .agent-md-content { diff --git a/packages/diagram-client/src/property-panel.ts b/packages/diagram-client/src/property-panel.ts index 9c749a7..69c14e0 100644 --- a/packages/diagram-client/src/property-panel.ts +++ b/packages/diagram-client/src/property-panel.ts @@ -1,4 +1,5 @@ import { inject, injectable } from 'inversify'; +import { looksLikeDiff, isBlock, parseEmbedded } from './agent-outputs'; import { ISelectionListener, IActionDispatcher, TYPES } from '@eclipse-glsp/client'; import { renderMarkdownSafe, @@ -738,6 +739,22 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { return; } if (typeof value === 'string') { + // A port's value is itself JSON: unwrap it and show its fields + // rather than one escaped line. + const embedded = parseEmbedded(value); + if (embedded !== value) { + this.renderJsonTree(container, embedded, depth); + return; + } + // A diff must not go through Markdown: its removed lines begin + // with `-` and would render as a bullet list. + if (looksLikeDiff(value) || isBlock(value)) { + const pre = document.createElement('pre'); + pre.className = looksLikeDiff(value) ? 'agent-json-pre agent-json-diff' : 'agent-json-pre'; + pre.textContent = value.replace(/\s+$/, ''); + container.appendChild(pre); + return; + } // Detect markdown content and render as formatted HTML. if (this.looksLikeMarkdown(value)) { const mdDiv = document.createElement('div'); diff --git a/packages/diagram-client/test/agent-outputs.test.ts b/packages/diagram-client/test/agent-outputs.test.ts index f35bcb4..abf39d2 100644 --- a/packages/diagram-client/test/agent-outputs.test.ts +++ b/packages/diagram-client/test/agent-outputs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAgentOutputs } from '../src/agent-outputs'; +import { formatAgentOutputs, looksLikeDiff, isBlock, parseEmbedded } from '../src/agent-outputs'; /** The shape the runtime reports when an agent writes its ports. */ const planner = JSON.stringify({ @@ -54,3 +54,26 @@ describe('formatAgentOutputs', () => { expect(out).toContain('````'); }); }); + +describe('the helpers the property panel shares', () => { + it('knows a diff from prose, and from a bullet list', () => { + expect(looksLikeDiff('--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a\n+b')).toBe(true); + expect(looksLikeDiff('@@ -704,7 +704,7 @@\n-old\n+new')).toBe(true); + expect(looksLikeDiff('- one\n- two\n- three')).toBe(false); + expect(looksLikeDiff('a short reason.')).toBe(false); + }); + + it('unwraps a port value that is itself JSON, and leaves other strings alone', () => { + expect(parseEmbedded('{"a": 1}')).toEqual({ a: 1 }); + expect(parseEmbedded('[1, 2]')).toEqual([1, 2]); + expect(parseEmbedded('not json')).toBe('not json'); + expect(parseEmbedded('{ broken')).toBe('{ broken'); + expect(parseEmbedded(7)).toBe(7); + }); + + it('calls a value a block when it has newlines or runs long', () => { + expect(isBlock('one\ntwo')).toBe(true); + expect(isBlock('x'.repeat(200))).toBe(true); + expect(isBlock('ftd=auto')).toBe(false); + }); +}); From a1e4655ada7073df5a959dd618d069d8b1803789 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 22 Sep 2026 10:14:42 +0200 Subject: [PATCH 3/3] The new module's comment keeps the core product-neutral Gate 1 of the neutrality check refuses the product names in packages/*/src, and the doc comment opened "A wfpy agent finishes a turn". The sentence says the same thing about any agent that writes ports, so the name was carrying nothing. --- packages/diagram-client/src/agent-outputs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/diagram-client/src/agent-outputs.ts b/packages/diagram-client/src/agent-outputs.ts index 3d876bb..5ec2573 100644 --- a/packages/diagram-client/src/agent-outputs.ts +++ b/packages/diagram-client/src/agent-outputs.ts @@ -1,8 +1,8 @@ /** * An agent's answer, formatted for reading. * - * A wfpy agent finishes a turn by writing its ports, and the runtime reports - * that as one JSON object, `{"outputs": {"": ""}}`. A port's + * An agent finishes a turn by writing its ports, and the runtime reports that + * as one JSON object, `{"outputs": {"": ""}}`. A port's * value is itself usually JSON, so the text that reaches the chat is escaped * twice: a unified diff arrives as one line of `\n` and `\"` and renders as a * single unreadable paragraph.