// @vitest-environment jsdom /** * ui-deliverables browser half: the derivation contract of * `producedForClosing` over engine-published Turn data, the row's rendering * and opener wiring, and the plugin registrations' fiber-teardown removal * (HMR safety) against the real SlotsService. */ import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ConversationEventRegistry, ConversationNodeAssembler, SlotsService, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } 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, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, type DeliverablesTurnData, } 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) class TestTurnDataStore implements ConversationLocationDataStore { private readonly values = new Map() get>( key: Key, ): Readonly | undefined { return this.values.get(key) as Readonly | undefined } set>( key: Key, value: ConversationTurnDataMap[Key], ): void { this.values.set(key, value) } } const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => { const data = new TestTurnDataStore() if (deliverables !== undefined) data.set('deliverables', deliverables) return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data } } const produced = (...values: ReadonlyArray): DeliverablesTurnData => ({ produced: values.map(([seq, path]) => ({ seq, path })), }) function tailOwner( data: DeliverablesTurnData | undefined, seq: number, openFile: (path: string) => void = () => {}, turn = 1, ): TurnTailOwnerProps { return { seq, openFile, turn: turnLocation(turn, data) } } interface TimelineSnapshot { readonly timeline: ConversationTimelineSnapshot } class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } fallbackEntry(): ConversationNodeDefinition | undefined { return undefined } } class TestViewDefinitions { entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] } } const timelineViewDefinition: ConversationViewDefinition = { target: 'test', create: () => { let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } } return { empty: current, replace: ({ timeline }) => (current = { timeline }), apply: ({ timeline }) => (current = { timeline }), } }, } function at( seq: number, type: string, data: unknown, view?: ConversationEventInput['view'], ): ConversationEventInput { return { event: { seq, time: seq * 1_000, type, data, ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), } as ConversationEventInput['event'], view, } } function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch { return { ...input, role, location: { kind: 'unresolved' } } } function call( seq: number, callId: string, view: ToolResultNode['callView'], turn = 1, ): ConversationEventInput { return at( seq, 'tool/call', { turn, step: 1, callId, name: 'fixture', arguments: '{}' }, { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } }, ) } function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput { return at(seq, 'tool/result', { turn, step: 1, message: { source: { type: 'tool-result', callId }, content: [{ type: 'tool-result', content: [], isError }], }, }) } function diff(...paths: string[]): ToolResultNode['callView'] { return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), locations: paths.map(path => ({ path })), } } function edit(path: string): ToolResultNode['callView'] { return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } } function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) value.replaceWindow(entries, hasMore) value.flush() return value } function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly | undefined { const snapshot = value.snapshot('test') as TimelineSnapshot return snapshot.timeline.turns.get(turn)?.data.get('deliverables') } describe('produced-file Turn data', () => { it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => { const data = produced( [3, 'out/index.html'], [4, 'out/app.css'], [4, 'out/index.html'], [8, 'after.txt'], ) expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css']) expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css']) expect(producedForClosing(undefined)).toEqual([]) expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), call(2, 'write', diff('out/index.html', 'out/app.css')), result(3, 'write'), call(4, 'edit', edit('notes.md')), result(5, 'edit'), call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }), result(7, 'read'), call(8, 'failed', diff('broken.txt')), result(9, 'failed', true), call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }), result(11, 'locationless'), ]) expect(producedForClosing(deliverablesOf(value))).toEqual([ 'out/index.html', 'out/app.css', 'notes.md', ]) }) it('ignores calls without mutation locations, orphan results, and replacement results', () => { const replacement = result(8, 'replacement') const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }), result(3, 'no-view'), call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }), result(5, 'locationless-edit'), result(6, 'orphan'), call(7, 'replacement', diff('replaced.txt')), { ...replacement, event: { ...replacement.event, surfaceOp: { op: 'replace', start: 1, end: 1 }, } as ConversationEventInput['event'], }, at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), ]) expect(producedForClosing(deliverablesOf(value))).toEqual([]) }) it('rejects an invalid start match and preserves state for an unrelated update', () => { const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start') const emptyContext: Parameters[0] = { key: 'deliverables:1', kind: 'deliverables', id: '1', matches: [startMatch], start: startMatch, state: undefined, current: new Map(), } const reader: Parameters[2] = { previous: () => undefined } const state = deliverablesDefinition.start(emptyContext, startMatch, reader) const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update') const context: Parameters[0] = { ...emptyContext, state } expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader)) .toThrow('deliverables start requires turn/start') expect(deliverablesDefinition.update(context, unrelated)).toBe(state) }) it('replays a tail page once prepend supplies its missing Turn start', () => { const value = assembler([ call(10, 'late', diff('history.txt')), result(11, 'late'), ], true) expect(deliverablesOf(value)).toBeUndefined() value.prepend([at(1, 'turn/start', { turn: 1 })], false) value.flush() expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt']) }) it('extends the same Turn data incrementally on live append', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), call(2, 'first', diff('first.txt')), result(3, 'first'), ]) const first = deliverablesOf(value) expect(producedForClosing(first)).toEqual(['first.txt']) value.append(call(4, 'second', diff('second.txt'))) value.append(result(5, 'second')) value.flush() expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt']) }) }) 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() await ctx.plugin(ConversationEventRegistry).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) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) 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 = tailOwner( produced([2, 'site/report.html']), 3, (path) => { 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(tailOwner(undefined, 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() }) })