// @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 turn’s 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() 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() }) })