522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition,
|
|
ConversationViewDefinition, SessionId, SessionListState,
|
|
} 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 {
|
|
WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
|
|
} from '../src/client/WorkflowRunPanel.tsx'
|
|
import { apply, inject } from '../src/client/index.ts'
|
|
import { zh } from '../src/client/locales.ts'
|
|
import {
|
|
workflowRunDefinition, type WorkflowRunChatData,
|
|
} from '../src/client/workflow-definition.ts'
|
|
import { apply as applyNode } from '../src/index.ts'
|
|
import { apply as applyInvariant } from '../src/invariant.ts'
|
|
import type {} from '../src/client/index.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const PARENT_ID = 'parent' as SessionId
|
|
const CHILD_ID = 'child-1' as SessionId
|
|
|
|
interface ChatSnapshot {
|
|
readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
|
|
}
|
|
|
|
class TestEventDefinitions {
|
|
entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] }
|
|
fallbackEntry(): undefined { return undefined }
|
|
}
|
|
|
|
class TestViewDefinitions {
|
|
entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] }
|
|
}
|
|
|
|
const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
|
|
target: 'chat',
|
|
create: () => {
|
|
let nodes = new Map<string, ChatConversationViewNode>()
|
|
const snapshot = (): ChatSnapshot => ({ nodes })
|
|
return {
|
|
empty: snapshot(),
|
|
replace: ({ nodes: values }) => {
|
|
nodes = new Map(values.map(node => [node.key, node]))
|
|
return snapshot()
|
|
},
|
|
apply: ({ upserts }) => {
|
|
nodes = new Map(nodes)
|
|
for (const node of upserts) nodes.set(node.key, node)
|
|
return snapshot()
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
function at(seq: number, type: string, data: unknown): ConversationEventInput {
|
|
return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined }
|
|
}
|
|
|
|
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
|
|
return { ...input, role, location: { kind: 'unresolved' } }
|
|
}
|
|
|
|
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
|
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
|
value.replaceWindow(entries, hasMore)
|
|
value.flush()
|
|
return value
|
|
}
|
|
|
|
function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined {
|
|
const snapshot = value.snapshot('chat') as ChatSnapshot
|
|
return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
|
|
}
|
|
|
|
function completeEvents(): ConversationEventInput[] {
|
|
return [
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
|
at(4, 'tool-workflow/agent-start', {
|
|
runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1',
|
|
}),
|
|
at(5, 'tool-workflow/agent-start', {
|
|
runId: 'run-1', seq: 2, label: 'second', childId: 'child-2',
|
|
}),
|
|
at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
|
|
at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
|
|
at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }),
|
|
at(9, 'step/end', { turn: 1, step: 1 }),
|
|
at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
|
]
|
|
}
|
|
|
|
describe('workflow-run Conversation Definition', () => {
|
|
it('groups exact phase identities in first-member order and preserves terminal members', () => {
|
|
const value = assembler(completeEvents())
|
|
const data = workflowData(value)
|
|
expect(data).toEqual({
|
|
name: 'audit',
|
|
status: 'failed',
|
|
phases: [
|
|
{
|
|
key: 'value:0:', phase: '',
|
|
members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
|
|
},
|
|
{
|
|
key: 'missing', phase: null,
|
|
members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }],
|
|
},
|
|
],
|
|
})
|
|
const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]!
|
|
expect(node.anchorSeq).toBe(3)
|
|
expect(node.kind).toBe('workflow-run')
|
|
})
|
|
|
|
it('keeps an update-only tail pending until prepend supplies the unique start', () => {
|
|
const tail = completeEvents().slice(3)
|
|
const value = assembler(tail, true)
|
|
expect(workflowData(value)).toBeUndefined()
|
|
value.prepend(completeEvents().slice(0, 3), false)
|
|
value.flush()
|
|
expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents())))
|
|
})
|
|
|
|
it('produces the same final data through live append as complete replay', () => {
|
|
const events = completeEvents()
|
|
const value = assembler(events.slice(0, 3))
|
|
for (const event of events.slice(3)) value.append(event)
|
|
value.flush()
|
|
expect(workflowData(value)).toEqual(workflowData(assembler(events)))
|
|
})
|
|
|
|
it('shows missing terminal facts as interrupted only after the owning Location closes', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
|
at(4, 'tool-workflow/agent-start', {
|
|
runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1',
|
|
}),
|
|
])
|
|
expect(workflowData(value)?.status).toBe('running')
|
|
value.append(at(5, 'step/end', { turn: 1, step: 1 }))
|
|
value.flush()
|
|
expect(workflowData(value)).toMatchObject({
|
|
status: 'interrupted',
|
|
phases: [{ members: [{ status: 'interrupted' }] }],
|
|
})
|
|
})
|
|
|
|
it('retains a zero-member run as its own completed node', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }),
|
|
at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }),
|
|
])
|
|
expect(workflowData(value)).toEqual({
|
|
name: 'empty', status: 'completed', phases: [],
|
|
})
|
|
})
|
|
|
|
it('folds same-phase cancellation and a turn-level interruption', () => {
|
|
const cancelled = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }),
|
|
at(3, 'tool-workflow/agent-start', {
|
|
runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1',
|
|
}),
|
|
at(4, 'tool-workflow/agent-start', {
|
|
runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2',
|
|
}),
|
|
at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }),
|
|
at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }),
|
|
at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }),
|
|
])
|
|
expect(workflowData(cancelled)).toMatchObject({
|
|
status: 'cancelled',
|
|
phases: [{ phase: 'Research', members: [{ status: 'cancelled' }, { status: 'completed' }] }],
|
|
})
|
|
|
|
const interruptedTurn = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }),
|
|
at(3, 'tool-workflow/agent-start', {
|
|
runId: 'turn', seq: 1, label: 'open', childId: 'child-1',
|
|
}),
|
|
at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
|
])
|
|
expect(workflowData(interruptedTurn)?.status).toBe('interrupted')
|
|
})
|
|
|
|
it('handles session/unresolved placement and defensive Definition calls', () => {
|
|
const sessionLevel = assembler([
|
|
at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }),
|
|
at(2, 'tool-workflow/agent-start', {
|
|
runId: 'session', seq: 1, label: 'open', childId: 'child-1',
|
|
}),
|
|
])
|
|
expect(workflowData(sessionLevel)?.status).toBe('running')
|
|
|
|
const invalidStart = matched(at(1, 'tool-workflow/agent-start', {
|
|
runId: 'direct', seq: 1, label: 'member', childId: 'child-1',
|
|
}), 'start')
|
|
const emptyContext: Parameters<typeof workflowRunDefinition.start>[0] = {
|
|
key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct',
|
|
matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(),
|
|
}
|
|
const reader: Parameters<typeof workflowRunDefinition.start>[2] = { previous: () => undefined }
|
|
expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader))
|
|
.toThrow('workflow-run start requires tool-workflow/run-start')
|
|
|
|
const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start')
|
|
const startedContext = { ...emptyContext, matches: [start], start }
|
|
const state = workflowRunDefinition.start(startedContext, start, reader)
|
|
const updateContext: Parameters<typeof workflowRunDefinition.update>[0] = { ...startedContext, state }
|
|
const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update')
|
|
expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state)
|
|
expect(workflowRunDefinition.target).toBe('chat')
|
|
expect(workflowRunDefinition.buildViewNode?.({
|
|
...updateContext, matches: [], start: undefined,
|
|
})).toBeNull()
|
|
const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined
|
|
if (directNode === null) throw new Error('expected direct workflow Chat node')
|
|
if (directNode === undefined) throw new Error('expected workflow Chat view builder')
|
|
expect(directNode.kind).toBe('workflow-run')
|
|
expect((directNode.data as WorkflowRunChatData).status).toBe('running')
|
|
})
|
|
})
|
|
|
|
function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] {
|
|
return {
|
|
key: '12:workflow-runrun-1',
|
|
kind: 'workflow-run',
|
|
id: 'run-1',
|
|
target: 'chat',
|
|
anchorSeq: 3,
|
|
location: { kind: 'unresolved' },
|
|
visibility: 'visible',
|
|
data,
|
|
}
|
|
}
|
|
|
|
const phase = (overrides: Partial<WorkflowRunChatData['phases'][number]> = {}): WorkflowRunChatData['phases'][number] => ({
|
|
key: 'missing',
|
|
phase: null,
|
|
members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }],
|
|
...overrides,
|
|
})
|
|
|
|
const listState = (overrides: Partial<SessionListState> = {}): SessionListState => ({
|
|
ids: [PARENT_ID, CHILD_ID],
|
|
byId: {
|
|
[PARENT_ID]: {
|
|
id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
|
|
},
|
|
[CHILD_ID]: {
|
|
id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
|
|
running: true, blank: false, updatedAt: 0,
|
|
},
|
|
},
|
|
current: PARENT_ID,
|
|
phase: 'ready',
|
|
subagentsByParent: {},
|
|
tasksBySession: {},
|
|
currentAddress: undefined,
|
|
...overrides,
|
|
})
|
|
|
|
function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps {
|
|
return {
|
|
node: node(data),
|
|
sessionId: PARENT_ID,
|
|
useSessions: selector => selector(sessions),
|
|
useSession: (() => undefined) as WorkflowRunPanelProps['useSession'],
|
|
useProjection: () => undefined,
|
|
useInput: () => { throw new Error('unused') },
|
|
inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'],
|
|
useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'],
|
|
useTurnData: () => undefined,
|
|
selectedCallId: undefined,
|
|
cwd: undefined,
|
|
openFile: () => {},
|
|
inspectCall: () => {},
|
|
forkAt: () => {},
|
|
loadImage: () => Promise.reject(new Error('unused')),
|
|
fileMentions: () => undefined,
|
|
openSession,
|
|
t: makeTranslate(zh),
|
|
}
|
|
}
|
|
|
|
describe('WorkflowRunPanel', () => {
|
|
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
|
|
const running: WorkflowRunChatData = {
|
|
name: 'audit', status: 'running', phases: [phase()],
|
|
}
|
|
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
|
expect(screen.getByText('未分阶段')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
|
expect(screen.queryByText('未分阶段')).toBeNull()
|
|
|
|
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
|
|
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
|
|
expect(screen.queryByText('未分阶段')).toBeNull()
|
|
|
|
cleanup()
|
|
render(<WorkflowRunPanel {...panelProps(terminal)} />)
|
|
expect(screen.queryByText('未分阶段')).toBeNull()
|
|
})
|
|
|
|
it('supports root keyboard disclosure and renders a zero-member running state', () => {
|
|
render(<WorkflowRunPanel {...panelProps({
|
|
name: 'keyboard', status: 'running',
|
|
phases: [phase({ key: 'research', phase: 'Research' })],
|
|
})} />)
|
|
const header = screen.getByRole('button', { name: /^keyboard/ })
|
|
expect(header.getAttribute('aria-expanded')).toBe('true')
|
|
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
|
expect(header.getAttribute('aria-expanded')).toBe('true')
|
|
fireEvent.keyDown(header, { key: 'Enter' })
|
|
expect(header.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.keyDown(header, { key: ' ' })
|
|
expect(header.getAttribute('aria-expanded')).toBe('true')
|
|
expect(screen.getByText('Research')).toBeTruthy()
|
|
expect(screen.getByText('运行中 1')).toBeTruthy()
|
|
const phaseHeader = screen.getByRole('button', { name: /Research/ })
|
|
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
|
|
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
|
|
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
|
fireEvent.keyDown(phaseHeader, { key: ' ' })
|
|
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
cleanup()
|
|
render(<WorkflowRunPanel {...panelProps({
|
|
name: 'empty', status: 'running', phases: [],
|
|
})} />)
|
|
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
|
})
|
|
|
|
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
|
|
render(<WorkflowRunPanel {...panelProps({
|
|
name: 'audit', status: 'running',
|
|
phases: [
|
|
phase({ key: 'value:0:', phase: '', members: [{
|
|
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
|
|
}] }),
|
|
phase({ key: 'missing', phase: null, members: [{
|
|
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
|
|
}] }),
|
|
],
|
|
})} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
|
expect(screen.getByText('空成员名')).toBeTruthy()
|
|
expect(screen.queryByText('second')).toBeNull()
|
|
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
|
expect(screen.getByText('second')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
|
expect(screen.queryByText('空成员名')).toBeNull()
|
|
expect(screen.getByText('second')).toBeTruthy()
|
|
})
|
|
|
|
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
|
|
const completed: WorkflowRunChatData = {
|
|
name: 'repo-audit', status: 'completed',
|
|
phases: [phase({
|
|
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
|
|
})],
|
|
}
|
|
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
|
|
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
|
|
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.click(completedHeader)
|
|
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
|
|
completedView.unmount()
|
|
|
|
const mixed: WorkflowRunChatData = {
|
|
name: 'repo-audit', status: 'failed',
|
|
phases: [phase({
|
|
members: [
|
|
{ seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' },
|
|
{ seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
|
|
],
|
|
})],
|
|
}
|
|
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
|
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
|
|
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
|
|
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
|
|
expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2)
|
|
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
|
mixedView.unmount()
|
|
|
|
const interrupted: WorkflowRunChatData = {
|
|
name: 'repo-audit', status: 'interrupted',
|
|
phases: [
|
|
phase({
|
|
members: [
|
|
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
|
|
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
|
|
],
|
|
}),
|
|
phase({
|
|
key: 'interrupted-only', phase: 'Interrupted only',
|
|
members: [{
|
|
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
|
|
}],
|
|
}),
|
|
],
|
|
}
|
|
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
|
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
|
|
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
|
|
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
|
})
|
|
|
|
it('opens only a running ordinary-list subagent proven to have this parent', () => {
|
|
const data: WorkflowRunChatData = {
|
|
name: 'audit', status: 'running', phases: [phase()],
|
|
}
|
|
const openSession = vi.fn()
|
|
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
|
|
expect(openSession).toHaveBeenCalledWith('child-1')
|
|
})
|
|
|
|
it.each([
|
|
['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
|
|
['remote row', listState({ byId: {
|
|
...listState().byId,
|
|
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined },
|
|
} }), 'running'],
|
|
['wrong parent', listState({ byId: {
|
|
...listState().byId,
|
|
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId },
|
|
} }), 'running'],
|
|
['list terminal', listState({ byId: {
|
|
...listState().byId,
|
|
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false },
|
|
} }), 'running'],
|
|
['member terminal', listState(), 'completed'],
|
|
] as const)('does not navigate when %s', (_name, sessions, memberStatus) => {
|
|
const data: WorkflowRunChatData = {
|
|
name: 'audit', status: 'running',
|
|
phases: [phase({
|
|
members: [{
|
|
seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus,
|
|
}],
|
|
})],
|
|
}
|
|
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
|
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
|
cleanup()
|
|
})
|
|
})
|
|
|
|
class TestSessions extends Service {
|
|
readonly opened: SessionId[] = []
|
|
constructor(ctx: Context) { super(ctx, 'sessions') }
|
|
open(id: SessionId): void { this.opened.push(id) }
|
|
}
|
|
|
|
describe('plugin lifecycle', () => {
|
|
it('registers and removes the Definition and keyed renderer with its fiber', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SlotsService).await()
|
|
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
|
await ctx.plugin(ConversationEventRegistry).await()
|
|
await ctx.plugin(TestSessions).await()
|
|
ctx.slots.register({
|
|
name: 'root',
|
|
children: { 'conversation.chat.node': { kind: 'keyed', 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.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
|
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
|
const entry = ctx.slots.entries('conversation.chat.node')[0]!
|
|
const face = entry.inject?.() as unknown as WorkflowRunInjected
|
|
face.openSession(CHILD_ID)
|
|
expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
|
|
await fiber.dispose()
|
|
expect(ctx.conversationEvents.entries()).toEqual([])
|
|
expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
|
|
|
|
const replacement = ctx.plugin({ inject: [...inject], apply })
|
|
await replacement.await()
|
|
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
|
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
|
await replacement.dispose()
|
|
})
|
|
|
|
it('keeps the node half inert and registers invariant ownership', async () => {
|
|
applyNode()
|
|
const registered: string[] = []
|
|
const ctx = new Context()
|
|
ctx.provide('invariants')
|
|
ctx.set('invariants', {
|
|
register: (pkg: string) => { registered.push(pkg); return () => {} },
|
|
} as never)
|
|
await applyInvariant(ctx)
|
|
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run'])
|
|
})
|
|
})
|