|
@@ -0,0 +1,183 @@
|
|
|
|
|
+// @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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
|
|
+import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
|
|
|
|
+import { producedForClosing } 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'])
|
|
|
|
|
+ // 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 nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)]
|
|
|
|
|
+ const view = render(<ProducedFiles nodes={nodes} seq={3} 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')
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('a turn that produced nothing renders no row at all', () => {
|
|
|
|
|
+ const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)]
|
|
|
|
|
+ const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />)
|
|
|
|
|
+ expect(view.container.firstChild).toBeNull()
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+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: 'list', 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)
|
|
|
|
|
+
|
|
|
|
|
+ await fiber.dispose()
|
|
|
|
|
+ expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|