Files
dsh-context/tests/client/components/browserMessageCategories.spec.ts
T
bowenliang123 6487602f1e feat(client): render message attachments in order and preview them in the sidebar
A user message's image and file blocks now render as one ordered attachment
block in the Context browser: images keep their session-authorized thumbnail,
other files show their type glyph, extension, and stored size, and the row
chip counts every attachment. One click IS the preview — the card hands the
attachment's named host path to dsh's own preview column (the File Activity
card's `previewAddressOf` → `openResourceVia` path), which renders markdown,
code, PDF, office, and image viewers under the file's real name. A refused
open reports on that card alone; the image lightbox and the per-type modal it
replaced are gone with `images.tsx`.

The path comes from the plugin's own `/api/dsh-context/attachment` route (the
same authenticated `/api` fence, Connection exact-route registry, and
cold-read gate as the detail and fleet routes). It answers only an id the
session's own log cites — a depth-bounded, total walk over the event payload
(user content, tool-result blocks, spliced inbox deliveries) — and answers
the store's named alias: `attachments.fileHostPath` for verbatim files, and a
published-or-reused named hard link for images, whose objects are digest-named
and suffixless while the preview column dispatches viewers by suffix. The
link shares the immutable object bytes and lives in a disposable cache
(`<home>/cache/dsh-context/previews/`); a store that is not host-file-backed,
or a media type the harness never admits, resolves the typed null the card
reports as an unavailable preview.

`Section` moves to its own module so the attachment block heads itself
without importing the tool card, and the compat matrix probes `FileTypeIcon`
alongside the icons it already pins.
2026-10-13 21:19:47 +08:00

478 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { ContextBrowserProps } from '../../../src/client/components/browser'
import type { ContextHeaders } from '../../../src/shared/types'
import type { ConversationNodeLike, ImageLoader } from '../../../src/client/services'
import { click, flush, mount, query, queryAll, surfaceNode, text } from '../helpers/kit'
import { Browser, catRow, elemRows, kit, props, tl, typeToolSearch } from './browserHarness'
describe('ContextBrowser message categories', () => {
// One rich live surface exercising every NodeContent/BlocksBody branch.
const convNodes: ConversationNodeLike[] = [
// user with two images then text (image group flush mid-loop + trailing)
{ kind: 'user', seq: 1, content: [
{ type: 'image', attachment: { attachmentId: 'a1', name: 'one.png', bytes: 2048, width: 100, height: 50 } },
{ type: 'image', attachment: { attachmentId: 'a2' } },
{ type: 'text', text: 'with images' },
] },
// user with text then one image
{ kind: 'user', seq: 2, content: [
{ type: 'text', text: 'one pic' },
{ kind: 'image', attachment: { attachmentId: 'a3' } },
] },
// user without a content array → hint fallback
{ kind: 'user', seq: 5 },
// assistant full block cascade
{ kind: 'assistant', seq: 68, blocks: [
{ kind: 'text', text: 'final answer' },
{ type: 'reasoning', text: 'thinking hard' },
{ kind: 'tool-call', name: 'bash', argsRaw: '{"command":"ls","timeout":30,"opts":{"a":1}}' },
{ kind: 'tool-call', name: 'broken', argsRaw: 'not json' },
{ type: 'tool-call', arguments: '{"file_path":"x.ts"}' },
{ kind: 'tool-call', name: 'noargs' },
{ type: 'tool-result', content: [
{ type: 'text', text: 'inner result' },
{ type: 'image', attachment: { attachmentId: 'b1' } },
] },
{ type: 'tool-result', content: 'not-an-array' },
{ kind: 'image', attachment: { attachmentId: 'b2', name: 'pic.png', bytes: 4096, width: 640, height: 480 } },
{ type: 'mystery', foo: 1 },
{ type: 'text', text: 42 },
{ kind: 'reasoning', text: 42 },
'plain string block',
{ foo: 'bar' },
] },
// assistant without blocks but with content (legacy shape)
{ kind: 'assistant', seq: 67, content: [{ type: 'text', text: 'legacy body' }] },
// assistant summary sources
{ kind: 'assistant', seq: 62, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] },
{ kind: 'assistant', seq: 63, blocks: [{ kind: 'tool-call', name: 'read', argsRaw: '{}' }] },
{ kind: 'assistant', seq: 64, blocks: [{ kind: 'tool-call', name: 'edit', argsRaw: '{"path":"b.ts"}' }] },
// compaction nodes
{ kind: 'compaction', seq: 70, summary: 'SUMMARY BODY' },
{ kind: 'compaction', seq: 71, summary: null },
{ kind: 'compaction', seq: 72, summary: '' },
// inject join
{ kind: 'user', seq: 54, content: [{ type: 'text', text: 'relay body full' }] },
]
const data = tl({
current: { system: 0, tools: 0, user: 40, inject: 20, skill: 0, assistant: 60, tool: 30, total: 150 },
nodes: [
surfaceNode({ seq: 1, tokens: 10, text: 'with images', time: 100 }),
surfaceNode({ seq: 2, tokens: 9, text: 'one pic', time: 200 }),
surfaceNode({ seq: 3, tokens: 5, text: 'fallback text', time: 300 }),
surfaceNode({ seq: 4, tokens: 4, text: '', time: 400 }),
surfaceNode({ seq: 5, tokens: 4, text: 'no content array', time: 500 }),
surfaceNode({ seq: 50, cat: 'inject', tokens: 5, form: 'snapshot', text: 'state' }),
surfaceNode({ seq: 51, cat: 'inject', tokens: 5, form: 'notice', text: 'heads up' }),
surfaceNode({ seq: 55, cat: 'inject', tokens: 5, form: 'notice', text: '' }),
surfaceNode({ seq: 56, cat: 'inject', tokens: 5, text: 'no form' }),
surfaceNode({ seq: 54, cat: 'inject', tokens: 5, form: 'relay', text: 'relay body' }),
surfaceNode({ seq: 61, cat: 'assistant', tokens: 8, calls: ['bash', 'write'], text: 'done all' }),
surfaceNode({ seq: 62, cat: 'assistant', tokens: 8, calls: ['write'] }),
surfaceNode({ seq: 63, cat: 'assistant', tokens: 8, calls: ['read'] }),
surfaceNode({ seq: 64, cat: 'assistant', tokens: 8 }),
surfaceNode({ seq: 65, cat: 'assistant', tokens: 8 }),
surfaceNode({ seq: 66, cat: 'assistant', tokens: 8, calls: [], text: 'plain text' }),
surfaceNode({ seq: 67, cat: 'assistant', tokens: 8, text: 'legacy' }),
surfaceNode({ seq: 68, cat: 'assistant', tokens: 20, text: 'full cascade' }),
surfaceNode({ seq: 70, tokens: 6, text: 'summary node' }),
surfaceNode({ seq: 71, tokens: 6, text: '' }),
surfaceNode({ seq: 72, tokens: 6, text: 'empty summary' }),
],
})
const loadImage: ImageLoader = async (att) => {
if (att.attachmentId === 'a3' || att.attachmentId === 'b1') throw new Error('denied')
return 'blob:' + att.attachmentId
}
const mountBrowser = async (over: Partial<ContextBrowserProps> = {}) =>
mount(h(Browser, props({ data, convNodes, loadImage, ...over })))
test('user rows: image chips on collapsed rows, join content, fallbacks', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const rows = elemRows(m)
assert.equal(rows.length, 8, 'user + compaction nodes, newest first')
assert.equal(text(query(rows[0], '.lc-br-preview')), 'empty summary')
// Attachment chips ride the collapsed row (seq 1 ×2 images, seq 2 ×1).
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
assert.ok(text(rowOf('with images')).includes('Attachments ×2'))
assert.ok(text(rowOf('one pic')).includes('Attachments'))
assert.ok(!text(rowOf('one pic')).includes('×'))
// Open seq 1: the chip makes way for the grid; loader resolves two cards.
await click(rowOf('with images'))
await flush()
let content = query(m.container, '.lc-br-content')
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Attachments ×2'),
'expanded row drops the chip')
assert.ok(text(content).includes('Attachments'))
const imgs = queryAll<HTMLImageElement>(content, '.lc-att-thumb img')
assert.equal(imgs.length, 2, 'both loads resolved')
assert.ok(imgs[0].src.includes('blob:a1'))
assert.ok(text(content).includes('one.png'))
assert.ok(text(content).includes('100×50'))
await click(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement)
// Open seq 2: single image, load REJECTS → error placeholder.
await click(rowOf('one pic'))
await flush()
content = query(m.container, '.lc-br-content')
assert.equal(queryAll(content, '.lc-att-err').length, 1)
assert.ok(text(query(content, '.lc-att-err')).includes('⚠'))
await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement)
// Join missed with preview text: content section + the window note.
await click(rowOf('fallback text'))
content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('fallback text'))
assert.ok(text(content).includes('outside the loaded message window'))
await click(elemRows(m).find(r => text(r).includes('fallback text')) as HTMLElement)
// Join missed without text (seq 4, rows newest-first: 72,71,70,5,4,…): the note alone.
assert.ok(text(rows[4]).includes('(non-text message)'))
await click(rows[4])
content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
assert.ok(!text(content).includes('Content'))
await m.unmount()
})
test('user node whose conversation entry carries no content array shows the note', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
await click(elemRows(m).find(r => text(r).includes('no content array')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
await m.unmount()
})
test('a single-node category opens its node with the category', async () => {
const solo = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 },
nodes: [surfaceNode({ seq: 1, tokens: 10, text: 'only message', time: 100 })],
})
const m = await mount(h(Browser, props({
data: solo,
convNodes: [{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'only message' }] }],
})))
await click(catRow(m, 'user'))
assert.equal(elemRows(m).length, 1)
assert.equal(queryAll(m.container, '.lc-br-content').length, 1, 'the lone node is already expanded')
assert.ok(text(query(m.container, '.lc-br-content')).includes('only message'))
await m.unmount()
})
test('message rows filter by tag and preview text; another category opens unfiltered', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const input = query<HTMLInputElement>(m.container, '.lc-br-tool-search')
assert.equal(input.placeholder, 'Filter by message text…')
const previews = () => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
await typeToolSearch(m, 'summary')
assert.deepEqual(previews(), ['empty summary', 'summary node'])
// No match keeps the toolbar mounted so the filter can be cleared.
await typeToolSearch(m, 'zzz')
assert.equal(elemRows(m).length, 0)
assert.ok(text(m.container).includes('No rows match the current filter'))
assert.equal(queryAll(m.container, '.lc-br-tool-search').length, 1)
await typeToolSearch(m, '')
assert.equal(elemRows(m).length, 8)
// The call-breadcrumb tag matches too — both the fold's stamp ('bash › write')
// and the join-recovered breadcrumb on a mixed text+calls reply.
await click(catRow(m, 'assistant'))
assert.equal(query<HTMLInputElement>(m.container, '.lc-br-tool-search').value, '', 'another category opens unfiltered')
await typeToolSearch(m, 'bash')
assert.deepEqual(previews(), ['full cascade', 'done all'])
await m.unmount()
})
test('assistant kind chips: per-kind counts, click filters, re-click clears, switch resets', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
const toolctl = query(m.container, '.lc-br-toolctl')
assert.equal(queryAll(m.container, '.lc-br-toolctl .lc-gran').length, 1, 'only the assistant toolbar carries the kind group')
assert.equal(query(toolctl, '.lc-gran').getAttribute('title'), kit.t('browser.kindTip'))
assert.equal(query<HTMLInputElement>(toolctl, '.lc-br-tool-search').placeholder, 'Filter by reply, calls, or thinking…')
const chips = () => queryAll<HTMLButtonElement>(m.container, '.lc-br-toolctl .lc-gran-btn')
// Counts over ALL of the shown step's rows: thinking rides the join's
// reasoning block (seq 68), tools the joined calls (62/63/64/68) plus the
// unjoined node's `calls` stamp (61), answers the joined text blocks plus the nodes' own text (61/66/67/68).
assert.deepEqual(chips().map(c => text(c)), ['Thinking1', 'Tools5', 'Answer4'])
const previews = () => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
assert.equal(elemRows(m).length, 8)
await click(chips()[1])
assert.ok(chips()[1].className.includes('lc-gran-on'))
assert.deepEqual(previews(), ['full cascade', 'b.ts', '(empty reply)', 'a.ts', 'done all'])
// The counts report the step's composition — the text lens narrows on top of them.
await typeToolSearch(m, 'done')
assert.deepEqual(previews(), ['done all'])
assert.deepEqual(chips().map(c => text(c)), ['Thinking1', 'Tools5', 'Answer4'])
await typeToolSearch(m, 'zzz')
assert.equal(elemRows(m).length, 0)
assert.ok(text(query(m.container, '.lc-br-body')).includes('No rows match the current filter'))
assert.equal(chips().length, 3, 'the chips stay mounted on an empty match')
await typeToolSearch(m, '')
assert.equal(elemRows(m).length, 5)
await click(chips()[1])
assert.equal(elemRows(m).length, 8)
await click(chips()[0])
assert.deepEqual(previews(), ['full cascade'])
await click(chips()[2])
assert.ok(chips()[2].className.includes('lc-gran-on'))
assert.ok(!chips()[0].className.includes('lc-gran-on'), 'the kinds are exclusive')
assert.deepEqual(previews(), ['full cascade', 'legacy', 'Calls ', 'done all'])
await click(catRow(m, 'user'))
assert.equal(queryAll(m.container, '.lc-br-toolctl .lc-gran-btn').length, 0)
await click(catRow(m, 'assistant'))
assert.deepEqual(chips().map(c => text(c)), ['Thinking1', 'Tools5', 'Answer4'])
assert.ok(chips().every(c => !c.className.includes('lc-gran-on')))
assert.equal(elemRows(m).length, 8)
await m.unmount()
})
test('call-name capsules and heads are inert: a click never reveals the schema row', async () => {
const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [{ name: 'bash', tokens: 5 }, { name: 'write', tokens: 3 }] }] }
const m = await mountBrowser({ headers })
await click(catRow(m, 'assistant'))
// The breadcrumb capsule is plain text on the row button: a click bubbles to the row's own toggle —
// the row opens and nothing navigates to Tool Schemas.
const crumbRow = elemRows(m).find(r => text(r).includes('done all')) as HTMLElement
await click(query(crumbRow, '.lc-br-tag'))
assert.ok(!text(query(m.container, '.lc-br-cat-open')).includes('Tool Schemas'), 'no category jump')
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('done all'), 'the row toggle fired')
const cascade = elemRows(m).find(r => text(r).includes('full cascade')) as HTMLElement
await click(cascade)
const head = queryAll(m.container, '.lc-ts-card-head b').find(el => text(el) === '→ bash') as HTMLElement
await click(head)
assert.ok(!text(query(m.container, '.lc-br-cat-open')).includes('Tool Schemas'), 'no category jump')
const on = queryAll(m.container, '.lc-br-elem-on')
assert.equal(on.length, 1)
assert.ok(text(on[0]).includes('full cascade'), 'the open row never moved')
await m.unmount()
})
test('repeated call names fold into ×N capsules in first-appearance order', async () => {
const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [{ name: 'bash', tokens: 5 }, { name: 'write', tokens: 3 }] }] }
const data = tl({
current: { system: 0, tools: 0, user: 0, inject: 0, skill: 0, assistant: 8, tool: 0, total: 8 },
nodes: [surfaceNode({ seq: 2, cat: 'assistant', tokens: 8, calls: ['bash', 'write', 'bash', 'bash'] })],
})
const m = await mount(h(Browser, props({ data, headers })))
await click(catRow(m, 'assistant'))
// 'bash › write › bash › bash' groups into two capsules: the repeat multiplier keeps the first-appearance order.
const row = elemRows(m)[0]
const caps = queryAll(row, '.lc-br-tag')
assert.deepEqual(caps.map(c => text(c)), ['bash ×3', 'write'])
await m.unmount()
})
test('the assistant text filter scans the join’s reasoning blocks', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
// 'thinking hard' rides seq 68's reasoning block — no tag, preview, or node text carries it.
await typeToolSearch(m, 'thinking hard')
const rows = elemRows(m)
assert.equal(rows.length, 1)
assert.ok(text(rows[0]).includes('full cascade'))
// A malformed reasoning block (non-string text) drops from the scan whole — its value matches nothing.
await typeToolSearch(m, '42')
assert.equal(elemRows(m).length, 0)
assert.ok(text(query(m.container, '.lc-br-body')).includes('No rows match the current filter'))
await typeToolSearch(m, '')
assert.equal(elemRows(m).length, 8)
await m.unmount()
})
test('user images render a placeholder when no loader is wired', async () => {
const m = await mountBrowser({ loadImage: undefined })
await click(catRow(m, 'user'))
await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement)
assert.equal(queryAll(m.container, '.lc-att-ph').length, 1)
await m.unmount()
})
test('assistant rows: preview cascade and the full block vocabulary', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
const rows = elemRows(m)
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
const capsOf = (row: HTMLElement) => queryAll(row, '.lc-br-tag').map(c => text(c))
assert.deepEqual(capsOf(rowOf('done all')), ['bash', 'write'], 'call breadcrumb: one capsule per distinct call')
assert.ok(capsOf(rowOf('a.ts')).includes('write'), 'block summary previews a textless turn')
const tags = rows.map(r => {
const caps = capsOf(r)
const preview = text(query(r, '.lc-br-preview'))
return `${caps.length === 0 ? '∅' : caps.join(',')}$|${preview}`
})
assert.ok(tags.includes('read$|(empty reply)'), 'no self-summarizing call → empty marker')
assert.ok(tags.includes('edit$|b.ts'), 'a textless turn tags the joined call name and previews its summary')
assert.ok(tags.includes('bash,broken,noargs$|full cascade'), 'a mixed reply tags one capsule per join-recovered call, in order')
assert.ok(tags.includes('∅$|(empty reply)'), 'no join, no calls → empty marker')
assert.ok(tags.includes('∅$|Calls '), 'empty call list previews as a bare Calls label (nodeText)')
await click(rowOf('full cascade'))
const content = query(m.container, '.lc-br-content')
const heads = queryAll(content, '.lc-ts-card-head').map(el => text(el))
assert.ok(heads.some(s => s.includes('Answer')))
assert.ok(heads.some(s => s.includes('Reasoning')))
assert.ok(heads.some(s => s.includes('→ bash')))
assert.ok(heads.some(s => s.includes('→ broken')))
assert.ok(heads.some(s => s.includes('→ ?')), 'nameless call card')
assert.ok(heads.some(s => s.includes('→ noargs')))
assert.ok(heads.some(s => s.includes('Result')), 'nested tool-result text section')
assert.ok(heads.filter(s => s.includes('Other content')).length === 6, 'unknown blocks render raw JSON')
assert.ok(heads.some(s => s.includes('Attachments')))
const argVals = queryAll(content, '.lc-ts-arg-row').map(el => text(el))
assert.ok(argVals.some(s => s.includes('command') && s.includes('ls')))
assert.ok(argVals.some(s => s.includes('timeout') && s.includes('30')))
assert.ok(argVals.some(s => s.includes('opts') && s.includes('{"a":1}')))
// Unparseable args show raw; absent args show nothing.
assert.ok(text(content).includes('not json'))
assert.ok(text(content).includes('inner result'))
await flush()
// b1 rejects (error), b2 resolves.
assert.equal(queryAll(content, '.lc-att-err').length, 1)
assert.equal(queryAll(content, '.lc-att-thumb img').length, 1)
await m.unmount()
})
test('assistant with a legacy content array renders the generic content section', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
await click(elemRows(m).find(r => text(r).includes('legacy')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('Content'))
assert.ok(text(content).includes('legacy body'))
await m.unmount()
})
test('assistant without a join shows only the window note', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
const row = elemRows(m).find(r => text(r).includes('(empty reply)') && !text(r).includes('read')) as HTMLElement
await click(row)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
await m.unmount()
})
test('compaction nodes: summary text, null and empty summaries', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const rows = elemRows(m)
// Newest first: 72 (empty summary), 71 (null summary), 70 (summary node).
await click(rows[2])
let content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('Summary'))
assert.ok(text(content).includes('SUMMARY BODY'))
// Null summary → empty body.
await click(rows[1])
content = query(m.container, '.lc-br-content')
assert.equal(text(content), '')
// Empty-string summary → empty body.
await click(rows[0])
content = query(m.container, '.lc-br-content')
assert.equal(text(content), '')
await m.unmount()
})
test('inject rows: form tags, snapshot prefix, skill injects', async () => {
const m = await mountBrowser()
await click(catRow(m, 'inject'))
const rows = elemRows(m)
const tags = rows.map(r => {
const tag = r.querySelector<HTMLElement>('.lc-br-tag')
return `${tag === null ? '∅' : text(tag)}|${text(query(r, '.lc-br-preview'))}`
})
assert.ok(tags.includes('State Snapshot|Snapshot: state'))
assert.ok(tags.includes('Notice|heads up'))
assert.ok(tags.includes('Notice|Notice'), 'empty-string text keeps the form label')
assert.ok(tags.includes('Context Injection|no form'), 'formless inject defaults to the context label')
assert.ok(tags.includes('Agent Relay|relay body'))
await click(rows.find(r => text(r).includes('relay body')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('relay body full'))
await m.unmount()
})
test('inject rows label the fold-stamped source identity; the folded text stays filterable', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 0, inject: 45, skill: 0, assistant: 0, tool: 0, total: 45 },
nodes: [
// Stamped rows: the identity the events card names replaces the raw content preview (which stays one expand away).
surfaceNode({ seq: 50, cat: 'inject', tokens: 9, form: 'snapshot', name: '@deepseek-ai/dsh-system-prompt', text: 'policy sections' }),
surfaceNode({ seq: 51, cat: 'inject', tokens: 9, form: 'instructions', name: 'AGENTS.md', text: '<system-reminder> instructions' }),
// Unstamped (rows folded before the stamp existed): content stands.
surfaceNode({ seq: 52, cat: 'inject', tokens: 9, form: 'snapshot', text: 'state' }),
// Hostile drift: a non-string / empty stamp degrades to the content.
surfaceNode({ seq: 53, cat: 'inject', tokens: 9, name: 42 as never, text: 'drift body' }),
surfaceNode({ seq: 54, cat: 'inject', tokens: 9, name: '', text: 'empty body' }),
],
})
const m = await mount(h(Browser, props({ data })))
await click(catRow(m, 'inject'))
const tags = elemRows(m).map(r => {
const tag = r.querySelector<HTMLElement>('.lc-br-tag')
return `${tag === null ? '∅' : text(tag)}|${text(query(r, '.lc-br-preview'))}`
})
assert.ok(tags.includes('State Snapshot|@deepseek-ai/dsh-system-prompt'))
assert.ok(tags.includes('Instructions|AGENTS.md'))
assert.ok(tags.includes('State Snapshot|Snapshot: state'))
assert.ok(tags.includes('Context Injection|drift body'))
assert.ok(tags.includes('Context Injection|empty body'))
// The folded content left the preview but stays in the filter's lens.
await typeToolSearch(m, 'policy sections')
assert.deepEqual(elemRows(m).map(r => text(query(r, '.lc-br-preview'))), ['@deepseek-ai/dsh-system-prompt'])
await typeToolSearch(m, 'AGENTS.md')
assert.equal(elemRows(m).length, 1)
await m.unmount()
})
test('skill rows: loads/invocations name themselves, the catalog tags its form', async () => {
const convNodes: ConversationNodeLike[] = [
{ kind: 'tool-result', seq: 72, call: { name: 'skill', argsRaw: '{"description":"grill the plan"}' }, content: [{ type: 'text', text: 'skill body' }] },
]
const data = tl({
current: { system: 0, tools: 0, user: 0, inject: 0, skill: 40, assistant: 0, tool: 0, total: 40 },
nodes: [
// An invocation message previews its own text; the catalog digest tags
// its form; the `skill`-tool load (no node text) previews the call.
surfaceNode({ seq: 70, cat: 'skill', tokens: 9, skill: 'code-review', text: 'skill body' }),
surfaceNode({ seq: 71, cat: 'skill', tokens: 9, form: 'catalog' }),
surfaceNode({ seq: 72, cat: 'skill', tokens: 9, tool: 'skill', skill: 'grilling' }),
// Hostile drift: a skill node with neither a name nor a form.
surfaceNode({ seq: 73, cat: 'skill', tokens: 4 }),
// A stamped catalog digest previews its source identity, not the digest text.
surfaceNode({ seq: 74, cat: 'skill', tokens: 9, form: 'catalog', name: 'skill-catalog', text: 'digest body' }),
],
})
const m = await mount(h(Browser, props({ data, convNodes })))
assert.ok(text(catRow(m, 'skill')).includes('Skill Injections'))
assert.ok(text(catRow(m, 'skill')).includes('5 Items'))
await click(catRow(m, 'skill'))
const rows = elemRows(m)
assert.equal(rows.length, 5)
const tags = rows.map(r => {
const tag = r.querySelector<HTMLElement>('.lc-br-tag')
return `${tag === null ? '∅' : text(tag)}|${text(query(r, '.lc-br-preview'))}`
})
assert.ok(tags.includes('Skill · code-review|skill body'), 'the invocation names itself and previews its text')
assert.ok(tags.includes('Catalog Update|Catalog Update'), 'the textless catalog digest tags its form')
assert.ok(tags.includes('Skill · grilling|grill the plan'), 'the load previews its call summary')
assert.ok(tags.includes('Context Injection|Context Injection'), 'a nameless, formless skill row degrades to the context label')
assert.ok(tags.includes('Catalog Update|skill-catalog'), 'a stamped catalog digest previews its source identity')
await m.unmount()
})
})