|
|
@@ -21,15 +21,26 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
|
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
|
|
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
|
|
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
|
|
|
+import { PresentedOpenController } from '../src/client/present-open.ts'
|
|
|
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
|
|
import {
|
|
|
- basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
|
|
+ basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles,
|
|
|
type DeliverablesTurnData,
|
|
|
} from '../src/client/turn-deliverables.ts'
|
|
|
import { apply, inject } from '../src/client/index.ts'
|
|
|
import { en, zh } from '../src/client/locales.ts'
|
|
|
+import { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
|
|
|
|
|
+function openProps(controller = new PresentedOpenController()) {
|
|
|
+ return {
|
|
|
+ openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
|
|
|
+ usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
|
|
|
+ select(controller.state.getSnapshot()),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
afterEach(() => {
|
|
|
cleanup()
|
|
|
vi.restoreAllMocks()
|
|
|
@@ -368,6 +379,10 @@ describe('produced-file Turn data', () => {
|
|
|
))
|
|
|
.toThrow('deliverables start requires turn/start')
|
|
|
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
|
|
|
+ for (const files of [[], [null, { path: '' }]]) {
|
|
|
+ const declaration = matched(at(3, 'deliverables/presented', { turn: 1, callId: 'present', files }), 'update')
|
|
|
+ expect(deliverablesDefinition.update(context, declaration)).toBe(state)
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
it('replays a tail page once prepend supplies its missing Turn start', () => {
|
|
|
@@ -494,7 +509,7 @@ describe('plugin registration', () => {
|
|
|
// 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' } },
|
|
|
+ children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } },
|
|
|
} as never, () => null)
|
|
|
// ui-theme's Appearance row binds a durable scope through these two.
|
|
|
const session = {
|
|
|
@@ -513,9 +528,8 @@ describe('plugin registration', () => {
|
|
|
await fiber.await()
|
|
|
const [entry] = ctx.slots.entries('conversation.chat.turnTail')
|
|
|
expect(entry).toBeDefined()
|
|
|
- // The row needs no injected Host capability: it hands a path to its owner
|
|
|
- // and nothing in it reaches the local machine.
|
|
|
- expect(entry?.inject).toBeUndefined()
|
|
|
+ expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1)
|
|
|
+ expect(entry?.inject).toBeDefined()
|
|
|
|
|
|
// The prose face is live while the plugin is: a produced turn yields a
|
|
|
// resolver whose matches open through the owner-supplied opener.
|
|
|
@@ -526,15 +540,106 @@ describe('plugin registration', () => {
|
|
|
(path) => { opened.push(path) },
|
|
|
)
|
|
|
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
|
|
|
- const mentions = service?.forClosing(owner)
|
|
|
+ const mentions = service?.forClosing(owner, SessionId('viewed-session'))
|
|
|
mentions?.resolve('report.html')?.open()
|
|
|
expect(opened).toEqual(['site/report.html'])
|
|
|
+ const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
|
|
|
+ vi.stubGlobal('fetch', fetcher)
|
|
|
+ const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3)
|
|
|
+ service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open()
|
|
|
+ expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
|
|
|
+ const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
|
|
|
+ await face.openPresented(SessionId('child-session'), 2, 0)
|
|
|
+ expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
|
|
|
// A turn that produced nothing yields no vocabulary at all.
|
|
|
- expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
|
|
|
+ expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined()
|
|
|
|
|
|
await fiber.dispose()
|
|
|
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
|
|
+ expect(ctx.slots.entries('tool.call.toolview')).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()
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+
|
|
|
+describe('presented files', () => {
|
|
|
+ const file = (path = 'report.docx') => ({ path })
|
|
|
+
|
|
|
+ it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => {
|
|
|
+ const value = assembler([
|
|
|
+ at(1, 'turn/start', { turn: 1 }),
|
|
|
+ at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }),
|
|
|
+ at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }),
|
|
|
+ at(4, 'turn/end', { turn: 1 }),
|
|
|
+ at(5, 'turn/start', { turn: 2 }),
|
|
|
+ ])
|
|
|
+ const first = presentedForClosing(tailOwner(deliverablesOf(value), 3))
|
|
|
+ expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }])
|
|
|
+ expect(presentedForClosing(tailOwner(deliverablesOf(value), 4)))
|
|
|
+ .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }])
|
|
|
+ expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('uses the viewed fork Session in every open action and retains all delivered files', () => {
|
|
|
+ const value = assembler([
|
|
|
+ at(1, 'turn/start', { turn: 1 }),
|
|
|
+ at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }),
|
|
|
+ ])
|
|
|
+ const owner = tailOwner(deliverablesOf(value), 3)
|
|
|
+ const matched = selectDeliverables(owner)!
|
|
|
+ const props = openProps()
|
|
|
+ props.openPresented.mockResolvedValue(undefined)
|
|
|
+ const view = render(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
|
|
|
+ expect(view.getAllByRole('button')).toHaveLength(8)
|
|
|
+ expect(view.queryByRole('link')).toBeNull()
|
|
|
+ fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' }))
|
|
|
+ expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0)
|
|
|
+ expect(view.queryByText('Produced')).toBeNull()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+
|
|
|
+it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) => {
|
|
|
+ expect(deliverablesDefinition.match(at(1, 'deliverables/presented', data).event)).toBeNull()
|
|
|
+})
|
|
|
+
|
|
|
+it.each([{}, { turn: '1', callId: 'bad', files: [] },
|
|
|
+ { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] },
|
|
|
+ { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null },
|
|
|
+])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => {
|
|
|
+ const value = assembler([
|
|
|
+ at(1, 'turn/start', { turn: 1 }),
|
|
|
+ call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }),
|
|
|
+ result(3, 'write-a'),
|
|
|
+ at(4, 'deliverables/presented', data),
|
|
|
+ ])
|
|
|
+ const owner = tailOwner(deliverablesOf(value), 5)
|
|
|
+ const matched = selectDeliverables(owner)!
|
|
|
+ const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
|
|
|
+ expect(view.getByText('Produced')).toBeTruthy()
|
|
|
+ expect(view.queryByText('Deliverables')).toBeNull()
|
|
|
+})
|
|
|
+
|
|
|
+it('shows file metadata and descriptions without hiding extensionless deliveries', () => {
|
|
|
+ const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented: [
|
|
|
+ { path: 'out/report.txt', description: 'Quarterly summary', seq: 2, index: 0 },
|
|
|
+ { path: 'LICENSE', seq: 2, index: 1 },
|
|
|
+ ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
|
|
|
+ expect(view.getByText('Quarterly summary')).toBeTruthy()
|
|
|
+ expect(view.getByText('TXT')).toBeTruthy()
|
|
|
+ expect(view.getByText('File')).toBeTruthy()
|
|
|
+ expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app')
|
|
|
+})
|
|
|
+
|
|
|
+
|
|
|
+it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => {
|
|
|
+ const controller = new PresentedOpenController()
|
|
|
+ controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase })
|
|
|
+ const props = openProps(controller)
|
|
|
+ const view = render(<Deliverables {...props} matched={{ produced: [], presented: [
|
|
|
+ { path: 'report.txt', seq: 2, index: 0 },
|
|
|
+ ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
|
|
|
+ expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`])
|
|
|
+ expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening')
|
|
|
+})
|