mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
|
|
// cordis Context + SlotsService ledger + the web-react renderer + this
|
|
// package's own apply — no outlet twins. Proves the keyed
|
|
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
|
|
// entryKey (the bash sample lands through its plugin), unregistered tools
|
|
// fall back to GenericToolCard at the render site, live registration/unload
|
|
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
|
// (sessionId) => I into row components, and a registrant's
|
|
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
|
// semantics until the service (and with it the hole declaration) is present.
|
|
|
|
import { Context } from 'cordis'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, render } from '@testing-library/react'
|
|
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
|
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
const SID = 's1' as SessionId
|
|
|
|
afterEach(cleanup)
|
|
// The chat store persists under its declared key; clear between cases.
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
|
|
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
|
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
|
call: { name, argsRaw: args },
|
|
callTime: seq * 1_000 - 500,
|
|
content: [], isError: false, callView: null, resultView: null,
|
|
})
|
|
|
|
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
|
return {
|
|
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
|
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
|
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
|
} as ConversationSnapshot
|
|
}
|
|
|
|
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
|
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
|
function AppRoot({ renderSlot }: AppRootProps) {
|
|
return <>{renderSlot('conversation', {})}</>
|
|
}
|
|
|
|
/**
|
|
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
|
|
* fakes at the service seams only (external boundaries), the package apply on
|
|
* its own fiber, and the test AppFrame occupying 'root'.
|
|
*/
|
|
async function bench(nodes: ToolResultNode[]) {
|
|
const ctx = new Context()
|
|
const slotsFiber = ctx.plugin(SlotsService)
|
|
await slotsFiber.await()
|
|
const slots = ctx.get('slots') as SlotsService
|
|
|
|
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
|
const list = createSnapshotStore<SessionListState>({
|
|
ids: [SID],
|
|
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
|
current: SID,
|
|
phase: 'ready',
|
|
})
|
|
// Identity-stable provide bundle: the renderer caches hooks per source and
|
|
// inject results per bundle, both by object identity. Registered providers
|
|
// (the package's input contribution) materialize into it lazily, once.
|
|
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
|
|
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
|
|
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
|
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
|
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
|
|
const bindingOf = (id: SessionId) => ({
|
|
sessionId: id,
|
|
ctx: actxFake,
|
|
session: {
|
|
sessionId: id,
|
|
loadOlder: vi.fn(),
|
|
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
|
|
// Observable face for the input machine's queue read face.
|
|
getSnapshot: () => session.getSnapshot(),
|
|
subscribe: (fn: () => void) => session.subscribe(fn),
|
|
},
|
|
})
|
|
ctx.provide('sessions', {
|
|
list,
|
|
binding: bindingOf,
|
|
scope: () => actxFake,
|
|
provideInfo: (id: string) => {
|
|
if (id !== SID) return undefined
|
|
if (info === undefined) {
|
|
const hooks: Record<string, unknown> = { session }
|
|
const props: Record<string, unknown> = {}
|
|
for (const provider of providers) {
|
|
const c = provider(bindingOf(SID))
|
|
Object.assign(hooks, c.hooks ?? {})
|
|
Object.assign(props, c.props ?? {})
|
|
}
|
|
info = { sessionId: SID, hooks, props }
|
|
}
|
|
return info
|
|
},
|
|
maybeProvideInfo(id: string | undefined) {
|
|
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
|
},
|
|
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
|
scopeOf: () => SID,
|
|
create: vi.fn(),
|
|
open: vi.fn(),
|
|
updateIntent: vi.fn(),
|
|
})
|
|
ctx.provide('workspaces', {
|
|
list: createSnapshotStore<WorkspaceListState>({
|
|
items: [], state: 'idle', phase: 'ready', error: null,
|
|
baselinesReady: true, recentWorkspaceId: undefined,
|
|
}),
|
|
startSession: vi.fn(),
|
|
sendSession: vi.fn(),
|
|
})
|
|
ctx.provide('layout', layout)
|
|
ctx.provide('locale', { bind: () => (key: string) => key })
|
|
|
|
slots.install(createSlotRenderer())
|
|
slots.register({
|
|
name: 'root',
|
|
children: {
|
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
|
'details': { kind: 'single', scope: 'session' },
|
|
},
|
|
}, AppRoot)
|
|
|
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
return { ctx, slots, fiber, session, list, layout }
|
|
}
|
|
|
|
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
|
function mountApp(slots: SlotsService) {
|
|
return render(<>{slots.renderSlot('root', {})}</>)
|
|
}
|
|
|
|
describe('keyed toolview hole through the real machinery', () => {
|
|
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
|
|
const b = await bench([
|
|
toolResult(3, 'c1', 'bash'),
|
|
toolResult(4, 'c2', 'mystery', '{"n":1}'),
|
|
])
|
|
const view = mountApp(b.slots)
|
|
// bash: the sample plugin's keyed registration took the row (root
|
|
// session → global arm, decided inside the component off useSessions).
|
|
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
|
expect(view.getByText('Bash')).toBeTruthy()
|
|
expect(view.getByText('Build')).toBeTruthy()
|
|
// mystery: no registration under that key → render-site fallback.
|
|
expect(view.getByText('Tool call')).toBeTruthy()
|
|
})
|
|
|
|
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
|
const b = await bench([toolResult(3, 'c1', 'bash')])
|
|
const view = mountApp(b.slots)
|
|
view.getByText('Build').click()
|
|
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
|
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
|
|
const view = mountApp(b.slots)
|
|
expect(view.getByText('Tool call')).toBeTruthy()
|
|
let dispose = (): void => {}
|
|
await act(async () => {
|
|
dispose = b.slots.register(
|
|
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
|
() => <div data-testid="mystery-row" />)
|
|
})
|
|
// Per-key version tick: the row flipped without a remount of the view.
|
|
expect(view.getByTestId('mystery-row')).toBeTruthy()
|
|
expect(view.queryByText('Tool call')).toBeNull()
|
|
await act(async () => { dispose() })
|
|
expect(view.queryByTestId('mystery-row')).toBeNull()
|
|
expect(view.getByText('Tool call')).toBeTruthy()
|
|
})
|
|
|
|
it('a duplicate key registration fails loud at load', async () => {
|
|
const b = await bench([])
|
|
expect(() => b.slots.register(
|
|
{ name: 'conversation.chat.toolview', key: 'bash' },
|
|
() => null,
|
|
)).toThrow(/key "bash"/)
|
|
})
|
|
|
|
it('the inject channel feeds (sessionId) => I into the row component', async () => {
|
|
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
|
|
const poked: string[] = []
|
|
b.slots.register({
|
|
name: 'conversation.chat.toolview',
|
|
key: 'probe',
|
|
// Two-way business face: data derived from the session id out, a
|
|
// callback closing over it back in — the askuser-pattern inject shape.
|
|
inject: (sessionId: SessionId) => ({
|
|
mark: `for:${sessionId}`,
|
|
poke: () => { poked.push(sessionId) },
|
|
}),
|
|
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
|
|
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
|
))
|
|
const view = mountApp(b.slots)
|
|
const row = view.getByTestId('probe-row')
|
|
expect(row.textContent).toBe(`for:${SID}`)
|
|
row.click()
|
|
expect(poked).toEqual([SID])
|
|
})
|
|
})
|
|
|
|
describe('registrant load-order seam', () => {
|
|
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
|
const ctx = new Context()
|
|
const slotsFiber = ctx.plugin(SlotsService)
|
|
await slotsFiber.await()
|
|
const slots = ctx.get('slots') as SlotsService
|
|
ctx.provide('sessions', {
|
|
list: createSnapshotStore<SessionListState>({
|
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
|
}),
|
|
binding: () => undefined,
|
|
scope: () => undefined,
|
|
provideInfo: () => undefined,
|
|
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
|
provide: () => () => {},
|
|
create: vi.fn(),
|
|
open: vi.fn(),
|
|
updateIntent: vi.fn(),
|
|
})
|
|
ctx.provide('workspaces', {
|
|
list: createSnapshotStore<WorkspaceListState>({
|
|
items: [], state: 'idle', phase: 'ready', error: null,
|
|
baselinesReady: true, recentWorkspaceId: undefined,
|
|
}),
|
|
startSession: vi.fn(),
|
|
sendSession: vi.fn(),
|
|
})
|
|
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
|
ctx.provide('locale', { bind: () => (key: string) => key })
|
|
slots.register({
|
|
name: 'root',
|
|
children: {
|
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
|
'details': { kind: 'single', scope: 'session' },
|
|
},
|
|
}, AppRoot)
|
|
|
|
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
|
// semantics hold it — apply must not run while 'conversation' is absent.
|
|
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
|
|
// fiber's isConstructor branch.)
|
|
let applyRuns = 0
|
|
const registrantApply = (registrantCtx: Context): void => {
|
|
applyRuns += 1
|
|
registrantCtx.slots.register(
|
|
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
|
}
|
|
const late = ctx.plugin({
|
|
name: 'late-registrant',
|
|
inject: ['slots', 'conversation'],
|
|
apply: registrantApply,
|
|
})
|
|
await Promise.resolve()
|
|
expect(applyRuns).toBe(0)
|
|
|
|
// Mounting the package resolves the seam: service present ⟹ the chat
|
|
// entry (and its hole declaration) is already on the ledger, so the
|
|
// suspended registrant lands without an undeclared-slot throw.
|
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
await late.await()
|
|
expect(applyRuns).toBe(1)
|
|
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
|
.toEqual(expect.arrayContaining(['bash', 'late']))
|
|
})
|
|
})
|