fe505e1e91
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.
224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
// @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(<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()
|
||
})
|
||
})
|