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.