Files
dsh-context/tests/host/attachment.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

389 lines
18 KiB
TypeScript

// The attachment-location route (src/host/attachment.ts): session-authorized named-path resolution
// for verbatim file attachments, plus the route's gating/transport behavior.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach } from 'vitest'
import { ATTACHMENT_ROUTE, referencedAttachmentOf, imagePreviewNameOf, watchAttachmentChannel } from '../../src/host/attachment'
import type { ColdReadGate } from '../../src/host/coldRead'
type RouteFetch = (request: Request) => Promise<Response>
interface CtxSpec {
connection?: unknown
sessionQuery?: unknown
attachments?: unknown
}
/** The fleet route spec's host ctx double: cordis inject semantics over a service map. */
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch?: RouteFetch }; disposers: (() => void)[] } {
const captured: { path?: string; fetch?: RouteFetch } = {}
const disposers: (() => void)[] = []
const services = new Map<string, unknown>()
if ('connection' in spec) services.set('connection', spec.connection)
if ('sessionQuery' in spec) services.set('sessionQuery', spec.sessionQuery)
if ('attachments' in spec) services.set('attachments', spec.attachments)
const ctx = {
get: (name: string) => services.get(name),
effect(fn: () => unknown, _label?: string) {
const d = fn()
if (typeof d === 'function') disposers.push(d as () => void)
return () => {}
},
inject(deps: string[], cb: (c: unknown) => unknown) {
if (!deps.every(d => services.has(d))) return
const d = cb(ctx)
if (typeof d === 'function') disposers.push(d as () => void)
},
}
if (spec.connection !== undefined && spec.connection !== null) {
const conn = spec.connection as { fetch?: { register?: unknown } }
if (typeof conn.fetch?.register === 'function') {
conn.fetch.register = (route: { path: string; fetch: RouteFetch }) => {
captured.path = route.path
captured.fetch = route.fetch
return () => {}
}
}
}
return { ctx: ctx as unknown as Context, captured, disposers }
}
const OPEN_GATE: ColdReadGate = { admit: read => read() }
const SKIP_GATE: ColdReadGate = { admit: () => Promise.resolve(undefined) }
function armed(spec: CtxSpec = {}, gate: ColdReadGate = OPEN_GATE): { captured: { path?: string; fetch?: RouteFetch } } {
const { ctx, captured } = ctxOf({ connection: { fetch: { register: () => () => {} } }, ...spec })
watchAttachmentChannel(ctx, gate)
return { captured }
}
async function call(captured: { fetch?: RouteFetch }, body: unknown): Promise<Record<string, unknown>> {
const request = new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const response = await captured.fetch!(request)
return await response.json() as Record<string, unknown>
}
const BARE_IMAGE_EVENT = {
type: 'user/message',
seq: 10,
time: 2,
data: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i2' } }] },
}
/** The screenshot session's own content: three verbatim files, then an image, then the text. */
const USER_EVENT = {
type: 'user/message',
seq: 9,
time: 1,
data: {
content: [
{ type: 'file', attachment: { attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 } },
{ type: 'file', attachment: { attachmentId: 'sha256:f2', name: 'report.docx', bytes: 778285 } },
{ type: 'image', attachment: { attachmentId: 'sha256:i1', name: 'result-image.jpg', mediaType: 'image/jpeg', bytes: 146227, width: 1568, height: 1350 } },
{ type: 'text', text: 'how many files?' },
],
},
}
function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1)
}
function observing(events: unknown): { observeSession(id: string, options: unknown): Promise<unknown>; disposed(): boolean } {
let disposed = false
return {
observeSession: () => Promise.resolve({
events,
[Symbol.dispose]() { disposed = true },
}),
disposed: () => disposed,
}
}
const ALIAS = '/home/me/.dsh/attachments/v1/files/a3/a3f0/report.md'
describe('referencedAttachmentOf', () => {
test('finds the block citing the id at every real citation site', () => {
assert.deepEqual(
referencedAttachmentOf([USER_EVENT], 'sha256:f1'),
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
)
// A tool result's own content, and a nested block inside it.
assert.deepEqual(
referencedAttachmentOf([
{ type: 'tool/result', seq: 2, time: 2, data: { message: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i9', width: 1, height: 1 } }] } } },
], 'sha256:i9'),
{ kind: 'image', attachmentId: 'sha256:i9', width: 1, height: 1 },
)
assert.deepEqual(
referencedAttachmentOf([
{
type: 'tool/result',
seq: 3,
time: 3,
data: { message: { content: [{ type: 'tool-result', content: [{ type: 'text', text: 'shot' }, { type: 'image', attachment: { attachmentId: 'sha256:i10', width: 2, height: 2 } }] }] } },
},
], 'sha256:i10'),
{ kind: 'image', attachmentId: 'sha256:i10', width: 2, height: 2 },
)
// A spliced inbox delivery, before its user/message materializes.
assert.deepEqual(
referencedAttachmentOf([
{ type: 'agent/inbox/spliced', seq: 4, time: 4, data: { target: 'next-turn', inserted: [{ content: [{ type: 'file', attachment: { attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 } }] }] } },
], 'sha256:f9'),
{ kind: 'file', attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 },
)
})
test('the walk is bounded and total over a hostile log', () => {
// Deeper than the cap: the block is out of reach, so nothing resolves.
let deep: Record<string, unknown> = { type: 'file', attachment: { attachmentId: 'sha256:deep', name: 'x.md', bytes: 1 } }
for (let i = 0; i < 10; i++) deep = { nest: deep }
assert.equal(referencedAttachmentOf([{ type: 'user/message', data: deep }], 'sha256:deep'), null)
// An object that throws on the walk drops that event, and the next one still serves.
const hostile = new Proxy({}, { ownKeys() { throw new Error('hostile payload') } })
assert.equal(referencedAttachmentOf([{ type: 'x', data: hostile }], 'sha256:f1'), null)
assert.deepEqual(
referencedAttachmentOf([{ type: 'x', data: hostile }, USER_EVENT], 'sha256:f1'),
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
)
})
test('an id the log never cites resolves null, over hostile entries', () => {
assert.equal(referencedAttachmentOf([USER_EVENT], 'sha256:other'), null)
assert.equal(referencedAttachmentOf([], 'sha256:f1'), null)
assert.equal(
referencedAttachmentOf([
null,
42,
{ type: 'x' },
{ type: 'x', data: null },
{ type: 'x', data: { content: 'not-an-array' } },
{ type: 'x', data: { content: [null, 'plain', { type: 'file' }] } },
{ type: 'x', data: { message: null } },
], 'sha256:f1'),
null,
)
})
})
describe('watchAttachmentChannel gating', () => {
test('no route without the connection service or its fetch registry', () => {
assert.equal(ctxOf({}).captured.path, undefined)
assert.equal(ctxOf({ connection: {} }).captured.path, undefined)
assert.equal(ctxOf({ connection: { fetch: {} } }).captured.path, undefined)
assert.equal(armed().captured.path, ATTACHMENT_ROUTE)
})
test('a rejecting registry keeps the route absent without throwing', () => {
const ctx = {
get: (name: string) => name === 'connection'
? {
fetch: {
register: () => {
throw new Error('registration rejected')
},
},
}
: undefined,
inject(_deps: string[], cb: (c: unknown) => unknown) { cb(ctx) },
}
assert.doesNotThrow(() => { watchAttachmentChannel(ctx as unknown as Context, OPEN_GATE) })
})
test('bad bodies, a missing session, and a missing attachment are typed failures', async () => {
const { captured } = armed()
const badJson = await captured.fetch!(new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, { method: 'POST', body: '{' }))
assert.equal((await badJson.json() as { ok: boolean }).ok, false)
for (const body of [undefined, null, {}, { sessionId: 42 }, { sessionId: '' }, { sessionId: 's1' }, { sessionId: 's1', attachmentId: 7 }, { sessionId: 's1', attachmentId: '' }]) {
const r = await call(captured, body)
assert.equal(r.ok, false, JSON.stringify(body))
}
})
test('no sessionQuery, a verbless query, or a skipped read answers a typed null', async () => {
assert.deepEqual(await call(armed().captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
assert.deepEqual(await call(armed({ sessionQuery: {} }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
assert.deepEqual(await call(armed({ sessionQuery: observing(null) }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
const skipped = armed({ sessionQuery: observing([USER_EVENT]) }, SKIP_GATE)
assert.deepEqual(await call(skipped.captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: null })
})
test('an id the session never cited resolves null (the authorization rule)', async () => {
const query = observing([USER_EVENT])
const { captured } = armed({ sessionQuery: query })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:invented' }), { ok: true, value: null })
assert.equal(query.disposed(), true)
})
test('a throwing observation surfaces as an internal failure', async () => {
const { captured } = armed({ sessionQuery: { observeSession: () => Promise.reject(new Error('log unreadable')) } })
const r = await call(captured, { sessionId: 's1', attachmentId: 'a' })
assert.equal(r.ok, false)
assert.equal((r.error as { message: string }).message, 'log unreadable')
const nonError = armed({ sessionQuery: { observeSession: () => Promise.reject('nope') } })
assert.equal(((await call(nonError.captured, { sessionId: 's1', attachmentId: 'a' })).error as { message: string }).message, 'nope')
})
})
describe('watchAttachmentChannel resolution', () => {
test('a cited file resolves its named host path and the observation disposes', async () => {
const query = observing([USER_EVENT])
const seen: unknown[] = []
const { captured } = armed({
sessionQuery: query,
attachments: {
fileHostPath: (ref: unknown) => {
seen.push(ref)
return ALIAS
},
},
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
assert.deepEqual(seen, [{ attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 }])
assert.equal(query.disposed(), true)
})
test('a store without an image face answers a typed null for an image', async () => {
const { captured } = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { fileHostPath: () => ALIAS },
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
})
test('every unlocatable attachment shape resolves a typed null', async () => {
const cases: { attachments: unknown; id: string }[] = [
// No store composed, a store without the verb, and a store answering nothing path-shaped.
{ attachments: undefined, id: 'sha256:f1' },
{ attachments: {}, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => undefined }, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => '' }, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => 42 }, id: 'sha256:f1' },
// A hostile reference (bad digest, unsanitized name) throws inside the store.
{
attachments: {
fileHostPath: () => {
throw new Error('invalid attachment ref')
},
},
id: 'sha256:f1',
},
]
for (const c of cases) {
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: c.attachments })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c))
}
})
test('an observation without a dispose method still serves', async () => {
const { captured } = armed({
sessionQuery: { observeSession: () => Promise.resolve({ events: [USER_EVENT] }) },
attachments: { fileHostPath: () => ALIAS },
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
})
test('a nameless file reference still resolves through the store', async () => {
const { captured } = armed({
sessionQuery: observing([{ type: 'user/message', seq: 1, data: { content: [{ type: 'file', attachment: { attachmentId: 'sha256:f3' } }] } }]),
attachments: { fileHostPath: (ref: unknown) => `${ALIAS}:${JSON.stringify(ref)}` },
})
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:f3' })
assert.deepEqual(r.value, { path: `${ALIAS}:${JSON.stringify({ attachmentId: 'sha256:f3', name: '', bytes: 0 })}` })
})
})
describe('image preview names', () => {
test('an image keeps a viewable suffix, leaf only', () => {
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'photo.PNG', mediaType: 'image/jpeg' }), 'photo.PNG')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: '../../evil.svg' }), 'evil.svg')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'C:\\tmp\\x.webp' }), 'x.webp')
})
test('anything the column cannot view is named by the verified media type', () => {
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'clipboard', mediaType: 'image/jpeg' }), 'attachment.jpg')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'shot.png', mediaType: 'image/webp' }), 'shot.png', 'a suffix a viewer claims keeps the displayed name; the viewer renders the stored bytes')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'scan.tiff', mediaType: 'image/png' }), 'attachment.png')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/gif' }), 'attachment.gif')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/tiff' }), null)
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a' }), null)
})
})
describe('watchAttachmentChannel image aliases', () => {
const homes: string[] = []
afterEach(() => {
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
})
/** A real store layout: `<home>/attachments/v1/objects/<xx>/<digest>` holding the image bytes. */
function storeOf(bytes: string): { home: string; objectPath: string; store: { imageHostPath: () => string } } {
const home = mkdtempSync(join(tmpdir(), 'dsh-context-att-'))
homes.push(home)
const digest = 'ab'.repeat(32)
const objectPath = join(home, 'attachments', 'v1', 'objects', 'ab', digest)
mkdirSync(join(home, 'attachments', 'v1', 'objects', 'ab'), { recursive: true })
writeFileSync(objectPath, bytes)
return { home, objectPath, store: { imageHostPath: () => objectPath } }
}
test('a cited image answers a named hard link under the plugin preview cache', async () => {
const { home, objectPath, store } = storeOf('image-bytes')
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: store })
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
const path = (r.value as { path: string }).path
assert.equal(path, join(home, 'cache', 'dsh-context', 'previews', 'ab', basename(objectPath), 'result-image.jpg'))
assert.equal(readFileSync(path, 'utf8'), 'image-bytes', 'the link shares the stored bytes')
// A second read reuses the link instead of failing on EEXIST.
const again = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
assert.deepEqual(again.value, { path })
})
test('an unfamiliar object layout and an unlocatable object both answer a typed null', async () => {
const flat = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { imageHostPath: () => join(tmpdir(), 'objects', 'ab', 'digest') },
})
assert.deepEqual(await call(flat.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
const { home } = storeOf('image-bytes')
const missing = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { imageHostPath: () => join(home, 'attachments', 'v1', 'objects', 'ab', 'gone') },
})
assert.deepEqual(await call(missing.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
})
test('a hostile or empty image reference resolves a typed null', async () => {
const cases: { store: unknown; id: string }[] = [
{ store: {}, id: 'sha256:i1' },
{
store: {
imageHostPath: () => {
throw new Error('invalid attachment ref')
},
},
id: 'sha256:i1',
},
{ store: { imageHostPath: () => '' }, id: 'sha256:i1' },
{ store: { imageHostPath: () => 42 }, id: 'sha256:i1' },
// The cited image carries no media type and no usable suffix.
{ store: { imageHostPath: () => '/x/attachments/v1/objects/ab/digest' }, id: 'sha256:i2' },
]
for (const c of cases) {
const { captured } = armed({ sessionQuery: observing([USER_EVENT, BARE_IMAGE_EVENT]), attachments: c.store })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c.id))
}
})
})