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
101 changes: 101 additions & 0 deletions packages/diagram-client/src/agent-outputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* An agent's answer, formatted for reading.
*
* An agent finishes a turn by writing its ports, and the runtime reports that
* as one JSON object, `{"outputs": {"<port>": "<value>"}}`. 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. */
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. */
export function looksLikeDiff(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, 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}\``;
}
return `**${key}**\n\n${block(JSON.stringify(value, null, 2), 'json')}`;
}

/** One port: its name, then its fields, scalars before blocks. */
/**
* 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\*\* /, '')}`;
}
const entries = Object.entries(inner as Record<string, unknown>);
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<string, unknown>)['outputs'];
if (outputs === null || typeof outputs !== 'object' || Array.isArray(outputs)) return null;
const entries = Object.entries(outputs as Record<string, unknown>);
if (!entries.length) return null;
return entries.map(([name, value]) => port(name, value)).join('\n\n');
}
13 changes: 12 additions & 1 deletion packages/diagram-client/src/chat-panel-integrated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,16 @@ import {
*/
const markdownCache = new Map<string, string>();
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) {
Expand Down Expand Up @@ -1572,7 +1583,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
</div>
${!isUser && item.thinking ? this.thinkingTemplate(item.thinking) : nothing}
<div class="chat-row-body">
${isUser ? item.content : unsafeHTML(renderMarkdownMemo(item.content))}
${isUser ? item.content : unsafeHTML(renderMarkdownMemo(readable(item.content)))}
</div>
</div>
`;
Expand Down
22 changes: 22 additions & 0 deletions packages/diagram-client/src/diagram-client.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions packages/diagram-client/src/property-panel.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
Expand Down
79 changes: 79 additions & 0 deletions packages/diagram-client/test/agent-outputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { formatAgentOutputs, looksLikeDiff, isBlock, parseEmbedded } 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('````');
});
});

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);
});
});
Loading