From 85363fd967fb1f3cf596b0ea4438b8423e817aca Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:09:55 +0100 Subject: [PATCH 1/9] feat(hub+cli): surface mermaid parse failures back to the agent (#829) When the hub stores an assistant message containing one or more ```mermaid blocks whose diagram type is not recognised, it immediately emits a synthetic user-message back to the connected CLI socket. The CLI enqueues it and the agent sees it as the next user turn, prompting self-correction without any browser-round-trip. Changes: - hub/src/sync/mermaid.ts: extractFailingMermaidBlocks + buildMermaidRenderIssueHint Extracts mermaid fences from assistant message text, validates the first token against a case-insensitive set of known diagram types (mermaid v11), returns one MermaidIssue per failing block. - hub/src/socket/handlers/cli/sessionHandlers.ts: hook after bgDelta Calls the extractor after addMessage; emits a UserMessage-shaped update directly to the originating CLI socket (not to web clients). - cli/src/api/apiSession.ts: add '' to SYSTEM_INJECTION_PREFIXES Prevents the hint from being classified as an external human turn in the session log. - hub/src/sync/mermaid.test.ts: 13 unit tests covering extraction and hint building. Closes #829 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/api/apiSession.ts | 1 + .../socket/handlers/cli/sessionHandlers.ts | 25 ++++ hub/src/sync/mermaid.test.ts | 119 ++++++++++++++++++ hub/src/sync/mermaid.ts | 98 +++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 hub/src/sync/mermaid.test.ts create mode 100644 hub/src/sync/mermaid.ts diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index cd98d69a51..8d80167e48 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -46,6 +46,7 @@ const SYSTEM_INJECTION_PREFIXES = [ '', '', '', + '', ] function extractRawUserTextContent(content: unknown): string | null { diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 7ffec6dbbe..ecf2074a80 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -9,6 +9,7 @@ import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams' import { extractBackgroundTaskDelta } from '../../../sync/backgroundTasks' import { shouldRecordSessionActivity } from '../../../sync/sessionActivity' +import { extractFailingMermaidBlocks, buildMermaidRenderIssueHint } from '../../../sync/mermaid' import type { CliSocketWithData } from '../../socketTypes' import type { SessionEndReason } from '@hapi/protocol' import type { AccessErrorReason, AccessResult } from './types' @@ -141,6 +142,30 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session onBackgroundTaskDelta?.(sid, bgDelta) } + const mermaidIssues = extractFailingMermaidBlocks(content) + if (mermaidIssues.length > 0) { + const hintText = buildMermaidRenderIssueHint(mermaidIssues) + socket.emit('update', { + id: randomUUID(), + seq: msg.seq, + createdAt: Date.now(), + body: { + t: 'new-message' as const, + sid, + message: { + id: randomUUID(), + seq: msg.seq, + createdAt: Date.now(), + localId: null, + content: { + role: 'user', + content: { type: 'text', text: hintText } + } + } + } + }) + } + const update = { id: randomUUID(), seq: msg.seq, diff --git a/hub/src/sync/mermaid.test.ts b/hub/src/sync/mermaid.test.ts new file mode 100644 index 0000000000..026fd6ee4c --- /dev/null +++ b/hub/src/sync/mermaid.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'bun:test' +import { extractFailingMermaidBlocks, buildMermaidRenderIssueHint } from './mermaid' + +// Helper to wrap text in a Claude-style assistant message envelope +function assistantMessage(text: string): unknown { + return { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }] + } + } +} + +describe('extractFailingMermaidBlocks', () => { + it('returns [] for non-assistant messages', () => { + expect(extractFailingMermaidBlocks({ type: 'user', message: { role: 'user', content: 'hello' } })).toEqual([]) + expect(extractFailingMermaidBlocks(null)).toEqual([]) + expect(extractFailingMermaidBlocks('plain string')).toEqual([]) + }) + + it('returns [] when no mermaid blocks present', () => { + expect(extractFailingMermaidBlocks(assistantMessage('Here is some text with no diagrams.'))).toEqual([]) + }) + + it('returns [] for valid diagram types', () => { + const msg = assistantMessage('```mermaid\ngraph TD\n A --> B\n```') + expect(extractFailingMermaidBlocks(msg)).toEqual([]) + }) + + it('returns [] for valid type with extra whitespace after type name', () => { + const msg = assistantMessage('```mermaid\nflowchart LR\n A --> B\n```') + expect(extractFailingMermaidBlocks(msg)).toEqual([]) + }) + + it('returns an issue for an unknown diagram type', () => { + const msg = assistantMessage('```mermaid\ngrph TD\n A --> B\n```') + const issues = extractFailingMermaidBlocks(msg) + expect(issues).toHaveLength(1) + expect(issues[0].snippet).toContain('grph TD') + }) + + it('is case-insensitive for known types', () => { + const msg = assistantMessage('```mermaid\nFLOWCHART TD\n A --> B\n```') + expect(extractFailingMermaidBlocks(msg)).toEqual([]) + }) + + it('detects an empty block as invalid', () => { + const msg = assistantMessage('```mermaid\n\n```') + const issues = extractFailingMermaidBlocks(msg) + expect(issues).toHaveLength(1) + }) + + it('returns one issue per failing block, skips valid ones', () => { + const text = [ + '```mermaid', + 'graph TD', + ' A --> B', + '```', + '', + 'Some text', + '', + '```mermaid', + 'grph TD', + ' A --> B', + '```', + ].join('\n') + const issues = extractFailingMermaidBlocks(assistantMessage(text)) + expect(issues).toHaveLength(1) + expect(issues[0].snippet).toContain('grph TD') + }) + + it('returns multiple issues when multiple blocks are invalid', () => { + const text = [ + '```mermaid', + 'grph TD', + ' A --> B', + '```', + '', + '```mermaid', + 'sequenceDagram', + ' A->>B: hello', + '```', + ].join('\n') + const issues = extractFailingMermaidBlocks(assistantMessage(text)) + expect(issues).toHaveLength(2) + }) + + it('truncates snippet to 500 chars', () => { + const longCode = 'unknowntype\n' + 'A --> B\n'.repeat(100) + const msg = assistantMessage('```mermaid\n' + longCode + '\n```') + const issues = extractFailingMermaidBlocks(msg) + expect(issues).toHaveLength(1) + expect(issues[0].snippet.length).toBeLessThanOrEqual(500) + }) +}) + +describe('buildMermaidRenderIssueHint', () => { + it('wraps a single issue in render-issue tags', () => { + const hint = buildMermaidRenderIssueHint([{ snippet: 'grph TD\n A --> B' }]) + expect(hint).toMatch(/^/) + expect(hint).toMatch(/<\/render-issue>$/) + expect(hint).toContain('grph TD') + }) + + it('labels blocks when multiple issues', () => { + const hint = buildMermaidRenderIssueHint([ + { snippet: 'grph TD\n A --> B' }, + { snippet: 'sequenceDagram\n A->>B: hi' } + ]) + expect(hint).toContain('Block 1:') + expect(hint).toContain('Block 2:') + }) + + it('does not label block when only one issue', () => { + const hint = buildMermaidRenderIssueHint([{ snippet: 'grph TD' }]) + expect(hint).not.toContain('Block 1:') + }) +}) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts new file mode 100644 index 0000000000..ab069bb134 --- /dev/null +++ b/hub/src/sync/mermaid.ts @@ -0,0 +1,98 @@ +import { isObject } from '@hapi/protocol' +import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' + +// Diagram types recognised by mermaid v11. Checked case-insensitively against +// the first token of each ```mermaid block. +const KNOWN_DIAGRAM_TYPES = new Set([ + 'flowchart', + 'graph', + 'sequencediagram', + 'classdiagram', + 'statediagram', + 'statediagram-v2', + 'erdiagram', + 'gantt', + 'journey', + 'gitgraph', + 'pie', + 'quadrantchart', + 'requirementdiagram', + 'mindmap', + 'timeline', + 'sankey-beta', + 'xychart-beta', + 'block-beta', + 'packet-beta', + 'architecture-beta', + 'c4context', + 'c4container', + 'c4component', + 'c4dynamic', + 'c4deployment', + 'zenuml', + 'kanban', +]) + +export interface MermaidIssue { + snippet: string +} + +function extractTextFromContent(content: unknown): string | null { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + const parts: string[] = [] + for (const block of content) { + if (isObject(block) && block.type === 'text' && typeof block.text === 'string') { + parts.push(block.text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +function findInvalidMermaidBlocks(markdown: string): MermaidIssue[] { + const issues: MermaidIssue[] = [] + // Match ```mermaid fences; multiline, anchored to line start + const fenceRegex = /^```mermaid[ \t]*\r?\n([\s\S]*?)^```/gm + let match: RegExpExecArray | null + while ((match = fenceRegex.exec(markdown)) !== null) { + const code = match[1] + const firstWord = code.trim().split(/[\s\n\r]/)[0]?.toLowerCase() ?? '' + if (!firstWord || !KNOWN_DIAGRAM_TYPES.has(firstWord)) { + issues.push({ snippet: code.slice(0, 500) }) + } + } + return issues +} + +/** + * Inspects a message content blob (as stored in the hub DB) and returns one + * entry per ```mermaid block whose first token is not a recognised diagram + * type. Only processes assistant-role messages; returns [] for everything else. + */ +export function extractFailingMermaidBlocks(messageContent: unknown): MermaidIssue[] { + const record = unwrapRoleWrappedRecordEnvelope(messageContent) + if (!record || record.role !== 'assistant') return [] + + const text = extractTextFromContent(record.content) + if (!text) return [] + + return findInvalidMermaidBlocks(text) +} + +export function buildMermaidRenderIssueHint(issues: MermaidIssue[]): string { + const blocks = issues + .map((issue, i) => { + const label = issues.length > 1 ? `Block ${i + 1}:\n` : '' + return `${label}\`\`\`\n${issue.snippet.trim()}\n\`\`\`` + }) + .join('\n\n') + const noun = issues.length === 1 ? 'block' : 'blocks' + return ( + `\n` + + `A mermaid ${noun} in your previous response could not be rendered — ` + + `the diagram type was not recognised. The user saw it as raw text.\n\n` + + `${blocks}\n\n` + + `If the diagram was intended, please re-emit it with corrected syntax.\n` + + `` + ) +} From 3817efea52c3731a8e4a2b7d49fae3f4b6140307 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:13:34 +0100 Subject: [PATCH 2/9] fix(hub): skip %%{init} mermaid directives when checking diagram type getDiagramType() now skips blank lines and lines starting with %% before extracting the first token, preventing false positives when agents use %%{init: {...}}%% config directives before the diagram type keyword. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/sync/mermaid.test.ts | 5 +++++ hub/src/sync/mermaid.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hub/src/sync/mermaid.test.ts b/hub/src/sync/mermaid.test.ts index 026fd6ee4c..6e3787b381 100644 --- a/hub/src/sync/mermaid.test.ts +++ b/hub/src/sync/mermaid.test.ts @@ -45,6 +45,11 @@ describe('extractFailingMermaidBlocks', () => { expect(extractFailingMermaidBlocks(msg)).toEqual([]) }) + it('skips %%{init} config directives when determining diagram type', () => { + const text = '```mermaid\n%%{init: {"theme": "base"}}%%\ngraph TD\n A --> B\n```' + expect(extractFailingMermaidBlocks(assistantMessage(text))).toEqual([]) + }) + it('detects an empty block as invalid', () => { const msg = assistantMessage('```mermaid\n\n```') const issues = extractFailingMermaidBlocks(msg) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts index ab069bb134..c45d99da4b 100644 --- a/hub/src/sync/mermaid.ts +++ b/hub/src/sync/mermaid.ts @@ -49,6 +49,15 @@ function extractTextFromContent(content: unknown): string | null { return parts.length > 0 ? parts.join('\n') : null } +function getDiagramType(code: string): string { + for (const line of code.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('%%')) continue + return trimmed.split(/\s/)[0].toLowerCase() + } + return '' +} + function findInvalidMermaidBlocks(markdown: string): MermaidIssue[] { const issues: MermaidIssue[] = [] // Match ```mermaid fences; multiline, anchored to line start @@ -56,8 +65,8 @@ function findInvalidMermaidBlocks(markdown: string): MermaidIssue[] { let match: RegExpExecArray | null while ((match = fenceRegex.exec(markdown)) !== null) { const code = match[1] - const firstWord = code.trim().split(/[\s\n\r]/)[0]?.toLowerCase() ?? '' - if (!firstWord || !KNOWN_DIAGRAM_TYPES.has(firstWord)) { + const diagramType = getDiagramType(code) + if (!diagramType || !KNOWN_DIAGRAM_TYPES.has(diagramType)) { issues.push({ snippet: code.slice(0, 500) }) } } From 97651ea9bd74664a2a206d73f0a2aa3ff42b6379 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:33:39 +0100 Subject: [PATCH 3/9] fix(hub): handle role:'agent' wrapper when checking mermaid blocks Live CLI sessions wrap assistant output in role:'agent' + content.type:'output' (Claude) or type:'codex' (Codex/Gemini/OpenCode). The previous guard only accepted role:'assistant', so real sessions never triggered the hint. Adds extractTextFromRecord() to dig through both agent wrapper shapes before falling back to the bare role:'assistant' format used in tests and older clients. Adds 4 new tests covering the two live-session envelope formats. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/sync/mermaid.test.ts | 59 +++++++++++++++++++++++++++++++++++- hub/src/sync/mermaid.ts | 26 ++++++++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/hub/src/sync/mermaid.test.ts b/hub/src/sync/mermaid.test.ts index 6e3787b381..27bca5282e 100644 --- a/hub/src/sync/mermaid.test.ts +++ b/hub/src/sync/mermaid.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import { extractFailingMermaidBlocks, buildMermaidRenderIssueHint } from './mermaid' -// Helper to wrap text in a Claude-style assistant message envelope +// Bare { type:'assistant', message:... } envelope (e.g. test / older formats) function assistantMessage(text: string): unknown { return { type: 'assistant', @@ -12,6 +12,39 @@ function assistantMessage(text: string): unknown { } } +// role:'agent' + type:'output' — the real live-session format for Claude +function agentOutputMessage(text: string): unknown { + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }] + } + } + }, + meta: { sentFrom: 'cli' } + } +} + +// role:'agent' + type:'codex' — Codex/Gemini/OpenCode format +function agentCodexMessage(text: string): unknown { + return { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: text + } + }, + meta: { sentFrom: 'cli' } + } +} + describe('extractFailingMermaidBlocks', () => { it('returns [] for non-assistant messages', () => { expect(extractFailingMermaidBlocks({ type: 'user', message: { role: 'user', content: 'hello' } })).toEqual([]) @@ -19,6 +52,30 @@ describe('extractFailingMermaidBlocks', () => { expect(extractFailingMermaidBlocks('plain string')).toEqual([]) }) + it('returns an issue for invalid mermaid in live-session agent output (Claude format)', () => { + const msg = agentOutputMessage('```mermaid\ngrph TD\n A --> B\n```') + const issues = extractFailingMermaidBlocks(msg) + expect(issues).toHaveLength(1) + expect(issues[0].snippet).toContain('grph TD') + }) + + it('returns [] for valid mermaid in live-session agent output (Claude format)', () => { + const msg = agentOutputMessage('```mermaid\ngraph TD\n A --> B\n```') + expect(extractFailingMermaidBlocks(msg)).toEqual([]) + }) + + it('returns an issue for invalid mermaid in codex agent format', () => { + const msg = agentCodexMessage('```mermaid\ngrph TD\n A --> B\n```') + const issues = extractFailingMermaidBlocks(msg) + expect(issues).toHaveLength(1) + expect(issues[0].snippet).toContain('grph TD') + }) + + it('returns [] for valid mermaid in codex agent format', () => { + const msg = agentCodexMessage('```mermaid\nflowchart LR\n A --> B\n```') + expect(extractFailingMermaidBlocks(msg)).toEqual([]) + }) + it('returns [] when no mermaid blocks present', () => { expect(extractFailingMermaidBlocks(assistantMessage('Here is some text with no diagrams.'))).toEqual([]) }) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts index c45d99da4b..9df0b0bb17 100644 --- a/hub/src/sync/mermaid.ts +++ b/hub/src/sync/mermaid.ts @@ -73,16 +73,36 @@ function findInvalidMermaidBlocks(markdown: string): MermaidIssue[] { return issues } +function extractTextFromRecord(record: { role: string; content: unknown }): string | null { + if (record.role === 'assistant') { + return extractTextFromContent(record.content) + } + if (record.role === 'agent' && isObject(record.content)) { + if (record.content.type === 'output' && isObject(record.content.data)) { + // Claude: data is { type: 'assistant', message: { role: 'assistant', content: [...] } } + const inner = unwrapRoleWrappedRecordEnvelope(record.content.data) + if (inner?.role === 'assistant') return extractTextFromContent(inner.content) + } else if (record.content.type === 'codex' && isObject(record.content.data)) { + // Codex/Gemini/OpenCode: data is { type: 'message', message: } + if (record.content.data.type === 'message' && typeof record.content.data.message === 'string') { + return record.content.data.message + } + } + } + return null +} + /** * Inspects a message content blob (as stored in the hub DB) and returns one * entry per ```mermaid block whose first token is not a recognised diagram - * type. Only processes assistant-role messages; returns [] for everything else. + * type. Handles both the bare role:'assistant' format and the live-session + * role:'agent' wrapper (type:'output' for Claude, type:'codex' for Codex/Gemini). */ export function extractFailingMermaidBlocks(messageContent: unknown): MermaidIssue[] { const record = unwrapRoleWrappedRecordEnvelope(messageContent) - if (!record || record.role !== 'assistant') return [] + if (!record) return [] - const text = extractTextFromContent(record.content) + const text = extractTextFromRecord(record) if (!text) return [] return findInvalidMermaidBlocks(text) From 4619cf5e0059819e9c2e532f9855417d5835b30f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:46:52 +0100 Subject: [PATCH 4/9] fix(hub): handle mermaid YAML frontmatter and add 11.14 diagram types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDiagramType() now skips YAML frontmatter blocks (--- delimiters) so diagrams with a title/config preamble are not falsely flagged. Also adds the six diagram types shipped in mermaid 11.12–11.14 that were missing from the allowlist: radar-beta, treemap, treeview-beta, venn-beta, wardley-beta, ishikawa. The actual keyword strings were verified from the mermaid 11.14.0 dist bundle (treemap and ishikawa have no -beta suffix). Adds two new test cases covering frontmatter skipping and all six new types. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/sync/mermaid.test.ts | 12 ++++++++++++ hub/src/sync/mermaid.ts | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/hub/src/sync/mermaid.test.ts b/hub/src/sync/mermaid.test.ts index 27bca5282e..91dfd62ac9 100644 --- a/hub/src/sync/mermaid.test.ts +++ b/hub/src/sync/mermaid.test.ts @@ -107,6 +107,18 @@ describe('extractFailingMermaidBlocks', () => { expect(extractFailingMermaidBlocks(assistantMessage(text))).toEqual([]) }) + it('skips YAML frontmatter when determining diagram type', () => { + const text = '```mermaid\n---\ntitle: My diagram\n---\ngraph TD\n A --> B\n```' + expect(extractFailingMermaidBlocks(assistantMessage(text))).toEqual([]) + }) + + it('returns [] for mermaid 11.12+ diagram types (radar-beta, treemap, treeview-beta, venn-beta, wardley-beta, ishikawa)', () => { + for (const type of ['radar-beta', 'treemap', 'treeView-beta', 'venn-beta', 'wardley-beta', 'ishikawa']) { + const msg = assistantMessage(`\`\`\`mermaid\n${type}\n A --> B\n\`\`\``) + expect(extractFailingMermaidBlocks(msg), `should accept ${type}`).toEqual([]) + } + }) + it('detects an empty block as invalid', () => { const msg = assistantMessage('```mermaid\n\n```') const issues = extractFailingMermaidBlocks(msg) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts index 9df0b0bb17..83e3544c04 100644 --- a/hub/src/sync/mermaid.ts +++ b/hub/src/sync/mermaid.ts @@ -1,8 +1,8 @@ import { isObject } from '@hapi/protocol' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' -// Diagram types recognised by mermaid v11. Checked case-insensitively against -// the first token of each ```mermaid block. +// Diagram types recognised by mermaid v11 (up to 11.14). Checked +// case-insensitively against the first non-blank, non-directive token. const KNOWN_DIAGRAM_TYPES = new Set([ 'flowchart', 'graph', @@ -31,6 +31,13 @@ const KNOWN_DIAGRAM_TYPES = new Set([ 'c4deployment', 'zenuml', 'kanban', + // Added in 11.12–11.14 + 'radar-beta', + 'treemap', + 'treeview-beta', + 'venn-beta', + 'wardley-beta', + 'ishikawa', ]) export interface MermaidIssue { @@ -50,10 +57,16 @@ function extractTextFromContent(content: unknown): string | null { } function getDiagramType(code: string): string { + let inFrontmatter = false for (const line of code.split('\n')) { const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('%%')) continue - return trimmed.split(/\s/)[0].toLowerCase() + if (!trimmed) continue + if (trimmed === '---') { + inFrontmatter = !inFrontmatter + continue + } + if (inFrontmatter || trimmed.startsWith('%%')) continue + return trimmed.split(/\s+/)[0].toLowerCase() } return '' } From d4e622735eec80f7e3f8897c6ca7a484d30e6e6b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:50:22 +0100 Subject: [PATCH 5/9] fix(hub): use Date.now() seq for synthetic render-issue hint message The synthetic message is not stored in the hub DB, so using msg.seq (the stored assistant message's position) as its sequence number would cause two 'new-message' updates with identical seqs on any client receiving both. Using Date.now() matches the pattern used by other synthetic hub updates (update-session, update-metadata). Also fixes a minor test case that used camelCase treeView-beta instead of the canonical lowercase treeview-beta. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/socket/handlers/cli/sessionHandlers.ts | 9 +++++---- hub/src/sync/mermaid.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index ecf2074a80..dc7a16109a 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -145,17 +145,18 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session const mermaidIssues = extractFailingMermaidBlocks(content) if (mermaidIssues.length > 0) { const hintText = buildMermaidRenderIssueHint(mermaidIssues) + const hintSeq = Date.now() socket.emit('update', { id: randomUUID(), - seq: msg.seq, - createdAt: Date.now(), + seq: hintSeq, + createdAt: hintSeq, body: { t: 'new-message' as const, sid, message: { id: randomUUID(), - seq: msg.seq, - createdAt: Date.now(), + seq: hintSeq, + createdAt: hintSeq, localId: null, content: { role: 'user', diff --git a/hub/src/sync/mermaid.test.ts b/hub/src/sync/mermaid.test.ts index 91dfd62ac9..52120c83be 100644 --- a/hub/src/sync/mermaid.test.ts +++ b/hub/src/sync/mermaid.test.ts @@ -113,7 +113,7 @@ describe('extractFailingMermaidBlocks', () => { }) it('returns [] for mermaid 11.12+ diagram types (radar-beta, treemap, treeview-beta, venn-beta, wardley-beta, ishikawa)', () => { - for (const type of ['radar-beta', 'treemap', 'treeView-beta', 'venn-beta', 'wardley-beta', 'ishikawa']) { + for (const type of ['radar-beta', 'treemap', 'treeview-beta', 'venn-beta', 'wardley-beta', 'ishikawa']) { const msg = assistantMessage(`\`\`\`mermaid\n${type}\n A --> B\n\`\`\``) expect(extractFailingMermaidBlocks(msg), `should accept ${type}`).toEqual([]) } From 4c518ee922fb0bb999b74b23d5e1eae69adbd2ae Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:01:54 +0100 Subject: [PATCH 6/9] fix(hub): use msg.seq for render-issue hint to avoid advancing CLI cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB message seqs are small per-session integers; Date.now() returns an epoch-ms value orders of magnitude larger. The CLI's IncomingMessageFilter stores the max seen seq and uses it as afterSeq on reconnect — a Date.now() seq would advance the cursor past every real stored message, causing all subsequent messages to be skipped on backfill. Using msg.seq keeps the hint at the same cursor position as the triggering assistant message. Dedup is by UUID (id field), not seq, so there is no collision. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/socket/handlers/cli/sessionHandlers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index dc7a16109a..337ff2f3be 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -145,18 +145,18 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session const mermaidIssues = extractFailingMermaidBlocks(content) if (mermaidIssues.length > 0) { const hintText = buildMermaidRenderIssueHint(mermaidIssues) - const hintSeq = Date.now() + const hintCreatedAt = Date.now() socket.emit('update', { id: randomUUID(), - seq: hintSeq, - createdAt: hintSeq, + seq: msg.seq, + createdAt: hintCreatedAt, body: { t: 'new-message' as const, sid, message: { id: randomUUID(), - seq: hintSeq, - createdAt: hintSeq, + seq: msg.seq, + createdAt: hintCreatedAt, localId: null, content: { role: 'user', From 914788cb43fb1189806760098cc9f2796ad30328 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:26:29 +0100 Subject: [PATCH 7/9] feat(web): replace mermaid raw-code fallback with render-error placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a beefy amber warning card when a mermaid block fails to render instead of spilling raw markup text into the chat. Adds a three-state render cycle (pending → silent skeleton, error → warning card, success → SVG) so the brief async-parse window is also clean. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../assistant-ui/mermaid-diagram.tsx | 86 ++++++++++++++----- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 35a77e17b2..e8018636ae 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -1,5 +1,5 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' -import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react' +import { useEffect, useId, useState } from 'react' import { cn } from '@/lib/utils' let initializedTheme: 'light' | 'dark' | null = null @@ -64,22 +64,51 @@ async function ensureMermaid(theme: 'light' | 'dark') { return mermaid } -function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) { +function MermaidRenderError({ className }: { className?: string }) { return ( -
-            {props.code}
-        
+
+
+ + + + + +
+
+

+ Diagram rendering failed +

+

+ The agent used an unrecognised diagram type. It has been notified and + will provide a corrected version below. +

+
+
+ ) } +type RenderState = 'pending' | 'error' | 'success' + export function MermaidDiagram(props: SyntaxHighlighterProps) { const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) - const [renderError, setRenderError] = useState(false) + const [state, setState] = useState('pending') const [svg, setSvg] = useState(null) const id = useId().replace(/:/g, '-') @@ -101,6 +130,8 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { useEffect(() => { let cancelled = false + setState('pending') + setSvg(null) const render = async () => { try { @@ -108,19 +139,17 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const isValid = await mermaid.parse(props.code, { suppressErrors: true }) if (cancelled) return if (!isValid) { - setSvg(null) - setRenderError(true) + setState('error') return } const result = await mermaid.render(`mermaid-${id}`, props.code) if (cancelled) return setSvg(result.svg) - setRenderError(false) + setState('success') } catch { if (cancelled) return - setSvg(null) - setRenderError(true) + setState('error') } } @@ -131,20 +160,31 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) - if (renderError || !svg) { - return + if (state === 'error') { + return + } + + if (state === 'success' && svg) { + return ( +
+
+
+ ) } + // pending: render a silent skeleton matching the diagram area height return (
-
-
+ data-rendered="pending" + className="min-h-[160px] animate-pulse rounded-b-xl bg-[var(--app-code-bg)] opacity-40" + /> ) } From 8e7c4f5dad057ffa381a48daa4a118593d94f663 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:35:00 +0100 Subject: [PATCH 8/9] =?UTF-8?q?fix(hub):=20make=20render-issue=20hint=20im?= =?UTF-8?q?perative=20=E2=80=94=20agent=20must=20re-emit=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the conditional "if the diagram was intended" escape hatch and replaces the polite request with an unconditional MUST directive. Agents previously reasoned out of correcting when the original breakage felt intentional; the mandatory framing closes that gap. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/sync/mermaid.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts index 83e3544c04..f294a7d9f3 100644 --- a/hub/src/sync/mermaid.ts +++ b/hub/src/sync/mermaid.ts @@ -131,10 +131,11 @@ export function buildMermaidRenderIssueHint(issues: MermaidIssue[]): string { const noun = issues.length === 1 ? 'block' : 'blocks' return ( `\n` + - `A mermaid ${noun} in your previous response could not be rendered — ` + - `the diagram type was not recognised. The user saw it as raw text.\n\n` + + `A mermaid ${noun} in your previous response failed to render — ` + + `the diagram type was not recognised. The user sees an error placeholder, not a diagram.\n\n` + `${blocks}\n\n` + - `If the diagram was intended, please re-emit it with corrected syntax.\n` + + `You MUST re-emit the corrected diagram now. Do not explain, apologise, or comment — ` + + `just output the fixed mermaid block with a valid diagram type. This is required.\n` + `` ) } From 202bef4056fb1fede817a912ae2fa27d76d97eca Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:47:08 +0100 Subject: [PATCH 9/9] fix(hub+web): address Codex review findings - Add classDiagram-v2 to known diagram types to prevent false-positive render-issue hints on a valid mermaid alias - Update error card secondary text to avoid overpromising agent notification for non-type-recognition failures (syntax errors) - Update web tests to assert on .aui-mermaid-render-error placeholder and absence of raw markup rather than the removed .aui-mermaid-fallback via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- hub/src/sync/mermaid.ts | 1 + .../assistant-ui/mermaid-diagram.test.tsx | 19 +++++++++++-------- .../assistant-ui/mermaid-diagram.tsx | 3 +-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/hub/src/sync/mermaid.ts b/hub/src/sync/mermaid.ts index f294a7d9f3..1f6ff67158 100644 --- a/hub/src/sync/mermaid.ts +++ b/hub/src/sync/mermaid.ts @@ -8,6 +8,7 @@ const KNOWN_DIAGRAM_TYPES = new Set([ 'graph', 'sequencediagram', 'classdiagram', + 'classdiagram-v2', 'statediagram', 'statediagram-v2', 'erdiagram', diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx index 463d44d853..a3d31a36af 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx @@ -69,16 +69,18 @@ describe('MermaidDiagram', () => { expect(MARKDOWN_COMPONENTS_BY_LANGUAGE.mermaid.SyntaxHighlighter).toBe(MermaidDiagram) }) - it('falls back to source and suppresses Mermaid parse-error side effects for invalid syntax', async () => { + it('shows error placeholder (not raw source) when parse returns false', async () => { document.documentElement.dataset.theme = 'dark' mermaidMocks.parseMock.mockResolvedValueOnce(false) renderMermaid('graph TD\nA --') await waitFor(() => { - const fallback = document.querySelector('.aui-mermaid-fallback') - expect(fallback).toBeTruthy() - expect(fallback?.textContent).toBe('graph TD\nA --') + const placeholder = document.querySelector('.aui-mermaid-render-error') + expect(placeholder).toBeTruthy() + expect(placeholder?.getAttribute('data-rendered')).toBe('false') + // raw markup must NOT be exposed to the user + expect(placeholder?.textContent).not.toContain('graph TD') }) expect(mermaidMocks.parseMock).toHaveBeenCalledWith('graph TD\nA --', { suppressErrors: true }) @@ -86,16 +88,17 @@ describe('MermaidDiagram', () => { expect(mermaidMocks.setParseErrorHandlerMock).toHaveBeenCalled() }) - it('falls back to source and asks Mermaid not to inject its own error SVG when render throws', async () => { + it('shows error placeholder (not raw source) and suppresses Mermaid error SVG when render throws', async () => { mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed')) const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01' renderMermaid(code) await waitFor(() => { - const fallback = document.querySelector('.aui-mermaid-fallback') - expect(fallback).toBeTruthy() - expect(fallback?.textContent).toBe(code) + const placeholder = document.querySelector('.aui-mermaid-render-error') + expect(placeholder).toBeTruthy() + expect(placeholder?.getAttribute('data-rendered')).toBe('false') + expect(placeholder?.textContent).not.toContain('gantt') }) expect(mermaidMocks.renderMock).toHaveBeenCalled() diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index e8018636ae..2d3dceb911 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -95,8 +95,7 @@ function MermaidRenderError({ className }: { className?: string }) { Diagram rendering failed

- The agent used an unrecognised diagram type. It has been notified and - will provide a corrected version below. + The diagram could not be rendered. A corrected version may follow below.