Files
deepseek-harness/packages/client/ui-deliverables/tests/produced-files.spec.tsx
T
ZiyaZhang fe505e1e91 feat(web): a prose mention of a produced file opens it
The chatFileMentions service (provided by ui-deliverables beside its
turn-tail entry, reached via ctx.get) resolves inline-code tokens in the
closing message against the turn's produced locations: exact path or
unique basename links, ambiguity and unknowns stay inert. MarkdownText
gains the optional fileMentions seam — settled renders only, never
inside anchors.
2026-08-07 03:02:10 -07:00

224 lines
10 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.
// @vitest-environment jsdom
/**
* ui-deliverables browser half: the derivation contract of
* `producedForClosing` over finalized snapshot nodes, the row's rendering
* and opener wiring, and the plugin registrations' fiber-teardown removal
* (HMR safety) against the real SlotsService.
*/
import { Context } from 'cordis'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user',
seq,
time: seq * 1000,
content: [{ type: 'text', text }] as never,
source: null,
})
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: `Write ${paths[0] ?? ''}`,
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
locations: paths.map(path => ({ path })),
},
})
describe('producedForClosing derivation', () => {
it('attributes each turns written files to the assistant that closes it', () => {
const nodes: ConversationNode[] = [
user(1, 'build it'),
assistant(2, 'writing', 1),
wrote(3, 'a', 'out/index.html'),
// Same file touched twice in one turn is one deliverable, in first-seen order.
wrote(4, 'b', 'out/app.css', 'out/index.html'),
// A read is not a deliverable; a failed write has no file to open.
{ ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } },
{ ...wrote(6, 'd', 'out/broken.html'), isError: true },
assistant(7, 'done', 1),
user(8, 'again'),
assistant(9, 'second turn', 2),
]
expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
// A turn that produced nothing yields the empty list, and so does an
// anchor the window does not contain.
expect(producedForClosing(nodes, 9)).toEqual([])
expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([])
expect(producedForClosing(nodes, 999)).toEqual([])
})
it('counts a generic edit and never spills across the turn boundary', () => {
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'str_replace_editor'),
// str_replace_editor's insert mutates behind a generic card, so the
// discriminant is the render intent, not the card shape alone.
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
})
const nodes: ConversationNode[] = [
user(1, 'insert a line'),
inserted(2, 'i', 'notes.md'),
assistant(3, 'inserted', 1),
// Turn 2 mutates and then ends with no content text (interrupted, or its
// last text preceded the tool): its paths must not ride into turn 3.
user(4, 'now rewrite it'),
wrote(5, 'w', 'leaked.txt'),
user(6, 'and again'),
wrote(7, 'w2', 'notes.md'),
assistant(8, 'done', 3),
]
expect(producedForClosing(nodes, 3)).toEqual(['notes.md'])
// Turn 3 lists only its own file — and the dedup set did not suppress the
// rewrite of a path an earlier turn already touched.
expect(producedForClosing(nodes, 8)).toEqual(['notes.md'])
expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt')
})
it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => {
const nodes: ConversationNode[] = [
user(1, 'go'),
// A turnless surface node neither tracks nor resets the boundary.
{ kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null },
wrote(2, 'w', 'turn-one.txt'),
// A view-less result (window truncation) and cards without locations
// contribute nothing rather than crashing the walk.
toolResult(3, 'plain'),
{ ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } },
{ ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } },
assistant(6, 'mid narration', 1),
// Turn number advances with no user message in the window (truncated
// history): the accumulator must reset all the same.
assistant(7, 'closing', 2),
]
expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt'])
expect(producedForClosing(nodes, 7)).toEqual([])
})
})
describe('ProducedFiles row', () => {
const t = makeTranslate(zh)
it('renders capped chips with the full path reachable and opens one on click', () => {
// Seven files: six chips plus an explicit remainder — the row bounds what
// it shows and says so rather than dropping the rest silently.
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
const openFile = vi.fn<(path: string) => void>()
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
expect(view.getByText('产物')).toBeTruthy()
// Chips carry the basename; the full path stays reachable as the title.
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
expect(chip.textContent).toBe('a.html')
expect(chip.getAttribute('title')).toBe('deep/a.html')
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
expect(view.getByText('还有 1 个')).toBeTruthy()
fireEvent.click(chip)
expect(openFile).toHaveBeenCalledWith('deep/a.html')
})
})
describe('producedFileMentions resolver', () => {
const label = (path: string) => `打开 ${path}`
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
const opened: string[] = []
const resolver = producedFileMentions(
['out/index.html', 'a/style.css', 'b/style.css'],
(path) => { opened.push(path) },
label,
)
// Unique basename resolves to its full path; the full path rides title.
const byBasename = resolver.resolve('index.html')
expect(byBasename?.label).toBe('打开 out/index.html')
expect(byBasename?.title).toBe('out/index.html')
byBasename?.open()
expect(opened).toEqual(['out/index.html'])
// An exact path resolves even when its basename is ambiguous.
const exact = resolver.resolve('a/style.css')
expect(exact?.title).toBe('a/style.css')
// A basename two paths share stays unresolved rather than guessing,
// and so does a token naming nothing the turn wrote.
expect(resolver.resolve('style.css')).toBeUndefined()
expect(resolver.resolve('notes.md')).toBeUndefined()
expect(basename('a\\b\\c.txt')).toBe('c.txt')
})
})
describe('package shells', () => {
it('the node half mounts inert and the invariant companion registers ownership', async () => {
// The node half is deliberately inert; mounting it must simply not throw.
applyNode()
const registered: string[] = []
const ctx = new Context()
ctx.provide('invariants')
ctx.set('invariants', {
register: (pkg: string) => { registered.push(pkg); return () => {} },
} as never)
const dispose = await applyInvariant(ctx)
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables'])
expect(dispose).toBeTypeOf('function')
})
})
describe('plugin registration', () => {
it('registers the tail entry and fiber disposal removes it', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// The owning view's child declaration, stood up by a bench root entry.
ctx.slots.register({
name: 'root',
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
} as never, () => null)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.
const opened: string[] = []
const owner = {
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
seq: 3,
openFile: (path: string) => { opened.push(path) },
}
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
const mentions = service?.forClosing(owner)
mentions?.resolve('report.html')?.open()
expect(opened).toEqual(['site/report.html'])
// A turn that produced nothing yields no vocabulary at all.
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
await fiber.dispose()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
})
})