|
|
|
|
@@ -2,30 +2,44 @@
|
|
|
|
|
/**
|
|
|
|
|
* View registration acceptance on the real framework stack: the plugin fiber
|
|
|
|
|
* registers trajectory/waterfall into a real ConversationService, tabs switch
|
|
|
|
|
* inside ConversationRoot without collapsing chat, chrome.header renders the
|
|
|
|
|
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
|
|
|
|
|
* cases ride along.
|
|
|
|
|
* inside ConversationRoot (four-share props form; view rendering is
|
|
|
|
|
* in-component now) without collapsing chat, chrome.header renders the span
|
|
|
|
|
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
|
|
|
|
|
* ride along.
|
|
|
|
|
*/
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
|
|
import { createElement, Fragment, type FC, type ReactNode } from 'react'
|
|
|
|
|
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
|
|
|
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
|
|
|
|
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
|
import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
|
import type { ConversationInjected, ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
|
import {
|
|
|
|
|
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
|
|
|
|
|
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
|
|
|
|
import { createElement, type FC } from 'react'
|
|
|
|
|
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
|
|
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
|
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
|
|
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
|
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
|
// Export discipline: packages/client/AGENTS.md.
|
|
|
|
|
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
|
|
|
|
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
|
|
|
|
import type { ConversationSlotProps, ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
|
|
|
|
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
|
|
|
|
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
|
|
|
|
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
|
|
|
|
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
|
|
|
|
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
|
|
|
|
|
|
|
|
|
const SID = 's1' as SessionId
|
|
|
|
|
const fallbackSlots: ConversationInjected['slots'] = {
|
|
|
|
|
renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
|
|
|
|
|
}
|
|
|
|
|
/** Fallback-only renderSlot stub (no composer takeover in these benches). */
|
|
|
|
|
const fallbackRenderSlot: ConversationSlotProps['renderSlot'] =
|
|
|
|
|
(_key, _owner, opts) => opts?.fallback ?? null
|
|
|
|
|
/** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */
|
|
|
|
|
const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
|
|
|
|
|
|
|
|
|
afterEach(cleanup)
|
|
|
|
|
// The chat store persists under its declared key; clear so one case's active
|
|
|
|
|
// view cannot rehydrate into the next.
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
localStorage.clear()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
|
|
|
|
const NODES = [
|
|
|
|
|
@@ -37,7 +51,25 @@ const NODES = [
|
|
|
|
|
|
|
|
|
|
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
|
|
|
|
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
|
|
|
|
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
|
|
|
|
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
|
|
|
|
|
function emptySessions() {
|
|
|
|
|
const store = createSnapshotStore<SessionListState>(
|
|
|
|
|
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
|
|
|
|
return bindSnapshotSelector(store)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Chat-view stand-in props for standalone view mounts. */
|
|
|
|
|
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|
|
|
|
const chat = createChatStore().create()
|
|
|
|
|
return {
|
|
|
|
|
sessionId: SID,
|
|
|
|
|
useSession: fakeSession(nodes).useSession,
|
|
|
|
|
useStore: bindSnapshotSelector(chat),
|
|
|
|
|
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
|
|
|
|
|
} as unknown as ConvViewProps
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
|
|
|
|
|
@@ -51,28 +83,8 @@ async function bench() {
|
|
|
|
|
return { ctx, svc, fiber }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
|
|
|
|
|
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
|
|
|
|
|
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
|
|
|
|
const { useSession } = fakeSession(nodes)
|
|
|
|
|
const activeStore = createSnapshotStore<string | undefined>(undefined)
|
|
|
|
|
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
|
|
|
|
|
const viewProps = {
|
|
|
|
|
sessionId: SID, useSession,
|
|
|
|
|
useSelection: () => null,
|
|
|
|
|
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
|
|
|
|
|
slots: undefined,
|
|
|
|
|
} as unknown as ConvViewProps
|
|
|
|
|
const renderView = (entry: ViewEntry): ReactNode => {
|
|
|
|
|
const children: ReactNode[] = []
|
|
|
|
|
if (entry.chrome?.header !== undefined) {
|
|
|
|
|
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
|
|
|
|
|
}
|
|
|
|
|
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
|
|
|
|
|
if (entry.chrome?.footer !== undefined) {
|
|
|
|
|
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
|
|
|
|
|
}
|
|
|
|
|
return createElement(Fragment, null, children)
|
|
|
|
|
}
|
|
|
|
|
const sessionSnapshot = createSnapshotStore<{
|
|
|
|
|
running: boolean
|
|
|
|
|
removed: boolean
|
|
|
|
|
@@ -82,21 +94,26 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
|
|
|
|
|
}>({
|
|
|
|
|
running: false, removed: false, promptError: null, nodes, pending: [],
|
|
|
|
|
})
|
|
|
|
|
const chat = createChatStore().create()
|
|
|
|
|
return render(
|
|
|
|
|
<ConversationRoot
|
|
|
|
|
sessionId={SID}
|
|
|
|
|
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
|
|
|
|
|
useAncestry={() => ancestry}
|
|
|
|
|
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
|
|
|
|
|
useSessions={emptySessions()}
|
|
|
|
|
useStore={bindSnapshotSelector(chat)}
|
|
|
|
|
actions={chat.actions}
|
|
|
|
|
views={{
|
|
|
|
|
list: () => svc.views(),
|
|
|
|
|
subscribe: (fn) => svc.subscribeViews(fn),
|
|
|
|
|
version: () => svc.viewsVersion(),
|
|
|
|
|
}}
|
|
|
|
|
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
|
|
|
|
|
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
|
|
|
|
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
|
|
|
|
|
renderView={renderView}
|
|
|
|
|
slots={fallbackSlots}
|
|
|
|
|
send={vi.fn()}
|
|
|
|
|
stop={vi.fn()}
|
|
|
|
|
openDetails={vi.fn()}
|
|
|
|
|
loadOlder={vi.fn()}
|
|
|
|
|
open={vi.fn()}
|
|
|
|
|
renderSlot={fallbackRenderSlot}
|
|
|
|
|
SessionProvider={StubSessionProvider}
|
|
|
|
|
/>,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@@ -173,28 +190,22 @@ describe('span derivation', () => {
|
|
|
|
|
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
|
|
|
|
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
|
|
|
|
|
expect(container.firstChild).toBeNull()
|
|
|
|
|
render(createElement(TrajectoryView as FC<ConvViewProps>, {
|
|
|
|
|
sessionId: SID, useSession, useSelection: () => null,
|
|
|
|
|
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
|
|
|
|
|
} as unknown as ConvViewProps))
|
|
|
|
|
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
|
|
|
|
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
|
|
|
|
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('WaterfallView standalone branches', () => {
|
|
|
|
|
const props = (nodes: ConversationSnapshot['nodes']) => ({
|
|
|
|
|
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
|
|
|
|
|
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
|
|
|
|
|
} as unknown as ConvViewProps)
|
|
|
|
|
|
|
|
|
|
it('empty window renders the placeholder copy', () => {
|
|
|
|
|
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
|
|
|
|
|
render(createElement(WaterfallView as FC<ConvViewProps>,
|
|
|
|
|
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
|
|
|
|
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a turn without tool calls renders the node bar only', () => {
|
|
|
|
|
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
|
|
|
|
|
render(createElement(WaterfallView as FC<ConvViewProps>, props(nodes)))
|
|
|
|
|
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
|
|
|
|
|
expect(screen.getByTitle('1 nodes')).toBeTruthy()
|
|
|
|
|
expect(screen.queryByTitle(/tool calls/)).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|