mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
514 lines
20 KiB
TypeScript
514 lines
20 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* View registration acceptance on the real framework stack: the plugin fiber
|
|
* registers Trajectory into a real SlotsService view ring, tabs
|
|
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
|
* projection apply uses) without collapsing chat, trajectory renders the
|
|
* event ledger with its timing overview, and fiber disposal removes the tab.
|
|
* Timeline projection and inclusive focus edge cases ride along.
|
|
*/
|
|
import { Context } from 'cordis'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
|
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
|
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
|
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
|
import {
|
|
TrajectoryView, type TrajectoryViewInjected,
|
|
} from '../src/client/TrajectoryView.tsx'
|
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
|
|
|
const SID = 's1' as SessionId
|
|
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 = [
|
|
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
|
{
|
|
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
|
|
timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
|
|
},
|
|
{
|
|
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
|
|
content: [], isError: false, callView: null, resultView: null,
|
|
},
|
|
{
|
|
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
|
|
timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
|
|
},
|
|
] as unknown as ConversationSnapshot['nodes']
|
|
|
|
function historySnapshot(
|
|
nodes: ConversationSnapshot['nodes'],
|
|
inspection: Partial<SessionHistoryInspection> = {},
|
|
): SessionHistorySnapshot {
|
|
return {
|
|
state: 'ready',
|
|
error: null,
|
|
hasMore: false,
|
|
inspection: {
|
|
eventNodes: nodes,
|
|
contexts: [{ id: 0, nodes }],
|
|
requests: [],
|
|
callSchemas: new Map(),
|
|
interruptedNodes: [],
|
|
partial: null,
|
|
runningCalls: [],
|
|
codeDispatches: new Map(),
|
|
...inspection,
|
|
},
|
|
}
|
|
}
|
|
|
|
function standaloneHistory(
|
|
snapshot: SessionHistorySnapshot,
|
|
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
|
|
const store = createSnapshotStore(snapshot)
|
|
return {
|
|
useHistory: bindSnapshotSelector(store),
|
|
loadAllHistory: () => Promise.resolve(),
|
|
}
|
|
}
|
|
|
|
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
|
const store = createSnapshotStore({
|
|
nodes, pending: [], partial: null,
|
|
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
|
})
|
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
|
}
|
|
|
|
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
|
function emptySessions() {
|
|
const store = createSnapshotStore<SessionListState>(
|
|
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
|
return bindSnapshotSelector(store)
|
|
}
|
|
|
|
function emptyWorkspaces() {
|
|
const store = createSnapshotStore<WorkspaceListState>({
|
|
items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
|
recentWorkspaceId: undefined,
|
|
})
|
|
return bindSnapshotSelector(store)
|
|
}
|
|
|
|
/** Standalone view props: the session-scope standard kit the outlet would bake. */
|
|
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|
return {
|
|
sessionId: SID,
|
|
useSession: fakeSession(nodes).useSession,
|
|
useSessions: emptySessions(),
|
|
useWorkspaces: emptyWorkspaces(),
|
|
useProjection: (() => undefined) as never,
|
|
} as unknown as ConvViewProps
|
|
}
|
|
|
|
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
|
async function bench(snapshot = historySnapshot(NODES)) {
|
|
const ctx = new Context()
|
|
const slots = new SlotsService(ctx)
|
|
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
|
const historyStore = createSnapshotStore(snapshot)
|
|
const history: SessionHistoryFace = {
|
|
sessionId: SID,
|
|
getSnapshot: () => historyStore.getSnapshot(),
|
|
subscribe: listener => historyStore.subscribe(listener),
|
|
loadAll: loadAllHistory,
|
|
}
|
|
// The conversation entry's role: declare the ring, then seed the chat entry.
|
|
slots.register({
|
|
name: 'root',
|
|
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
|
}, (_p: { renderSlot?: unknown }) => null)
|
|
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
|
slots.register(
|
|
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
|
// 'conversation' inject is an ordering edge; the bench declares the ring
|
|
// itself, so a stub satisfies the wait.
|
|
ctx.provide('conversation', {})
|
|
ctx.provide('sessionHistory', { source: () => history })
|
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
return { ctx, slots, fiber, loadAllHistory }
|
|
}
|
|
|
|
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
|
function tabsOf(slots: SlotsService): ViewTab[] {
|
|
return slots.entries('conversation.view')
|
|
.map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
|
|
}
|
|
|
|
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
|
|
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
|
const sessionSnapshot = createSnapshotStore({
|
|
running: false, removed: false, promptError: null, nodes,
|
|
pending: [],
|
|
openState: 'open' as const, hasMore: true, loadingOlder: false,
|
|
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
|
})
|
|
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
|
const chat = createChatStore().create()
|
|
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
|
// render it with the session standard kit (what SlotOutlet does for a
|
|
// list-kind session slot, minus machinery).
|
|
const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
|
|
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
|
|
if (entry === undefined) return null
|
|
const View = entry.component as FC<ConvViewProps>
|
|
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
|
|
const injected = injectEntry === undefined
|
|
? {}
|
|
: injectEntry(SID)
|
|
const injectedProps = 'hooks' in injected
|
|
? {
|
|
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
|
useHistory: bindSnapshotSelector(
|
|
(injected as TrajectoryViewInjected).hooks.history,
|
|
),
|
|
}
|
|
: injected
|
|
return (
|
|
<View
|
|
{...injectedProps}
|
|
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
|
key={key}
|
|
/>
|
|
)
|
|
}) as unknown as ConversationSessionProps['renderSlot']
|
|
return render(
|
|
<ConversationSession
|
|
sessionId={SID}
|
|
SessionProvider={({ children }) => children(SID)}
|
|
useSession={useSession}
|
|
useSessions={emptySessions()}
|
|
useWorkspaces={emptyWorkspaces()}
|
|
useProjection={(() => undefined)}
|
|
useStore={bindSnapshotSelector(chat)}
|
|
actions={chat.actions}
|
|
renderSlot={renderSlot}
|
|
views={{
|
|
list: () => tabsOf(slots),
|
|
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
|
version: () => slots.getVersion('conversation.view'),
|
|
}}
|
|
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
|
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
|
|
bindDraftMirror={() => () => {}}
|
|
open={vi.fn()}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
describe('plugin registration', () => {
|
|
it('registers trajectory after chat on the ring', async () => {
|
|
const b = await bench()
|
|
expect(tabsOf(b.slots)).toEqual([
|
|
{ id: 'chat', label: 'Chat' },
|
|
{ id: 'trajectory', label: 'Trajectory' },
|
|
])
|
|
})
|
|
|
|
it('fiber disposal removes the tab and leaves chat standing', async () => {
|
|
const b = await bench()
|
|
await b.fiber.dispose()
|
|
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
|
})
|
|
})
|
|
|
|
describe('tab switching in ConversationRoot', () => {
|
|
it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
|
const b = await bench()
|
|
const view = mount(b.slots)
|
|
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
|
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
expect(screen.queryByText(/turns ·/)).toBeNull()
|
|
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
|
|
expect(screen.queryByRole('columnheader')).toBeNull()
|
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
|
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
|
|
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
|
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
|
expect(screen.queryByTestId('chat-body')).toBeNull()
|
|
await vi.waitFor(() => {
|
|
expect(b.loadAllHistory).toHaveBeenCalledOnce()
|
|
})
|
|
const signal = b.loadAllHistory.mock.calls[0]?.[0]
|
|
expect(signal?.aborted).toBe(false)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
|
expect(signal?.aborted).toBe(true)
|
|
})
|
|
|
|
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
|
const b = await bench()
|
|
mount(b.slots)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
|
|
fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' })
|
|
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
|
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
|
|
expect(screen.getByText('Completed')).toBeTruthy()
|
|
expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close details' }))
|
|
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
|
})
|
|
|
|
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
|
const b = await bench()
|
|
mount(b.slots)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
|
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
|
toJSON: () => ({}),
|
|
})
|
|
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
|
|
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
|
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
|
|
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
|
.toBe('outside')
|
|
|
|
const tablePane = screen.getByRole('table').parentElement
|
|
expect(tablePane).not.toBeNull()
|
|
fireEvent.click(tablePane as HTMLElement)
|
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
|
.toBeNull()
|
|
|
|
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
|
|
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
|
|
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
|
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
|
.toBe('outside')
|
|
fireEvent.contextMenu(plot)
|
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('empty window keeps the toolbar and reports no timing data', async () => {
|
|
const b = await bench(historySnapshot([]))
|
|
mount(b.slots)
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
|
expect(screen.getByText('No timing data')).toBeTruthy()
|
|
expect(screen.queryByRole('row')).toBeNull()
|
|
expect(screen.queryByText(/turns ·/)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('timeline projection', () => {
|
|
const turns = [{
|
|
turn: 1,
|
|
groups: [{
|
|
title: 'Step 1',
|
|
cells: [
|
|
{ index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
|
|
{ index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
|
|
{ index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
|
|
],
|
|
}],
|
|
}] satisfies readonly TrajectoryTurnModel[]
|
|
|
|
it('uses equal-width operation slots and stable semantic lanes', () => {
|
|
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
|
start: 0,
|
|
end: 3,
|
|
spans: [
|
|
{
|
|
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
|
|
},
|
|
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
|
|
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
|
|
],
|
|
turnBoundaries: [{ turn: 1, time: 0 }],
|
|
})
|
|
})
|
|
|
|
it('ignores durations and idle gaps while retaining turn boundaries', () => {
|
|
const separatedTurns = [
|
|
{
|
|
turn: 1,
|
|
groups: [{
|
|
title: 'Step 1',
|
|
cells: [
|
|
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
|
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
|
],
|
|
}],
|
|
},
|
|
{
|
|
turn: 2,
|
|
groups: [{
|
|
title: 'Step 1',
|
|
cells: [
|
|
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
|
],
|
|
}],
|
|
},
|
|
] satisfies readonly TrajectoryTurnModel[]
|
|
|
|
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
|
|
start: 0,
|
|
end: 3,
|
|
spans: [
|
|
{ index: 1, start: 0, end: 1 },
|
|
{ index: 2, start: 1, end: 2 },
|
|
{ index: 3, start: 2, end: 3 },
|
|
],
|
|
turnBoundaries: [
|
|
{ turn: 1, time: 0 },
|
|
{ turn: 2, time: 2 },
|
|
],
|
|
})
|
|
})
|
|
|
|
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
|
expect(deriveTrajectoryTimeline([])).toBeNull()
|
|
render(createElement(
|
|
TrajectoryView,
|
|
{
|
|
...standaloneProps([]),
|
|
...standaloneHistory(historySnapshot([])),
|
|
},
|
|
))
|
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
|
expect(screen.queryByRole('row')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('TrajectoryView branches', () => {
|
|
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
|
const retained = {
|
|
kind: 'user',
|
|
seq: 1,
|
|
time: 1_000,
|
|
content: [{ type: 'text', text: 'retained user' }],
|
|
source: null,
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const abandoned = {
|
|
kind: 'assistant',
|
|
seq: 3,
|
|
time: 3_000,
|
|
turn: 1,
|
|
step: 1,
|
|
blocks: [{ kind: 'text', text: 'abandoned response' }],
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const current = {
|
|
kind: 'assistant',
|
|
seq: 5,
|
|
time: 5_000,
|
|
turn: 2,
|
|
step: 1,
|
|
blocks: [{ kind: 'text', text: 'current response' }],
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const request = (startSeq: number, turn: number): RequestView => ({
|
|
purpose: 'assistant',
|
|
startSeq,
|
|
turn,
|
|
step: 1,
|
|
startedAt: startSeq * 1_000,
|
|
completedAt: startSeq * 1_000 + 100,
|
|
status: 'complete',
|
|
})
|
|
const store = createSnapshotStore(historySnapshot(
|
|
[retained, abandoned, current],
|
|
{
|
|
eventNodes: [retained, abandoned, current],
|
|
contexts: [
|
|
{ id: 0, nodes: [retained, abandoned] },
|
|
{
|
|
id: 1,
|
|
parentId: 0,
|
|
origin: 'rewind' as const,
|
|
originSeq: 4,
|
|
nodes: [retained, current],
|
|
},
|
|
],
|
|
requests: [request(2, 1), request(4, 2)],
|
|
callSchemas: new Map(),
|
|
},
|
|
))
|
|
|
|
const view = render(
|
|
<TrajectoryView
|
|
{...standaloneProps([])}
|
|
useHistory={bindSnapshotSelector(store)}
|
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.queryByText('abandoned response')).toBeNull()
|
|
expect(screen.getByText('current response')).toBeTruthy()
|
|
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
|
|
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
|
|
})
|
|
|
|
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
|
|
const retained = {
|
|
kind: 'user', seq: 1, time: 1_000,
|
|
content: [{ type: 'text', text: 'stop the task' }], source: null,
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const interruptedAssistant = {
|
|
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
|
|
blocks: [{ kind: 'text', text: 'partial response retained' }],
|
|
interrupted: true,
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const interruptedTool = {
|
|
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
|
|
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
|
|
content: [], isError: true,
|
|
error: { name: 'Interrupted', code: 'interrupted' },
|
|
callView: null, resultView: null,
|
|
} as unknown as ConversationSnapshot['nodes'][number]
|
|
const store = createSnapshotStore(historySnapshot(
|
|
[retained],
|
|
{
|
|
eventNodes: [retained],
|
|
contexts: [{ id: 0, nodes: [retained] }],
|
|
requests: [],
|
|
callSchemas: new Map(),
|
|
interruptedNodes: [interruptedAssistant, interruptedTool],
|
|
},
|
|
))
|
|
|
|
render(
|
|
<TrajectoryView
|
|
{...standaloneProps([])}
|
|
useHistory={bindSnapshotSelector(store)}
|
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByText('partial response retained')).toBeTruthy()
|
|
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('node half', () => {
|
|
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
|
expect(() => { nodeApply() }).not.toThrow()
|
|
})
|
|
})
|