Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/tasks/tasks-local/src/index.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
#	packages/tasks/tasks/src/index.ts
This commit is contained in:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -8,9 +8,11 @@
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
@@ -61,26 +63,36 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
expect(surface.inject).toEqual([
'slots', 'conversationEvents', 'conversationViews', 'sessions',
])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
const { surface } = await loadArtifact()
const ctx = new Context()
const slots = new SlotsService(ctx)
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(ConversationViewRegistry).await()
// The conversation entry's role: the ring must be declared before riders land.
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin reads sessionHistory for its per-session history source;
// slot availability is tracked by slots.inject.
ctx.provide('sessionHistory', {})
// Paging is session-owned; this registration-only probe never renders the
// entry, so the binding stays deliberately empty.
ctx.provide('sessions', { binding: () => undefined })
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
const events = ctx.get('conversationEvents') as ConversationEventRegistry
const views = ctx.get('conversationViews') as ConversationViewRegistry
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
expect(events.entries().length).toBeGreaterThan(0)
expect(views.entries()).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.view')).toHaveLength(0)
expect(events.entries()).toEqual([])
expect(views.entries()).toEqual([])
})
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {

View File

@@ -1,102 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches,
trajectoryBranchContainsRequest,
} from '../src/client/context-branches.ts'
const checkpoint = {
kind: 'context',
seq: 100,
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
form: null,
} as ConversationNode
const abandoned = {
kind: 'assistant',
seq: 20,
time: 20,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned' }],
} as ConversationNode
const current = {
kind: 'user',
seq: 110,
time: 110,
content: [{ type: 'text', text: 'rewound' }],
source: { kind: 'plugin', plugin: 'rewind' },
} as ConversationNode
function request(
purpose: RequestView['purpose'],
startSeq: number,
resultSeq?: number,
replacementSeq?: number,
): RequestView {
const base = {
startSeq,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete' as const,
...(resultSeq === undefined ? {} : { resultSeq }),
}
return purpose === 'assistant'
? { ...base, purpose, turn: 1, step: 1 }
: {
...base,
purpose,
turn: 1,
step: 0,
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
const contexts: ConversationContext[] = [
{ id: 0, nodes: [checkpoint, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind',
originSeq: 110,
nodes: [checkpoint, current],
},
]
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.key).toBe('rewind:110')
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 10, 20),
)).toBe(false)
expect(trajectoryBranchContainsRequest(
successor,
request('compaction', 90, 95, 100),
)).toBe(true)
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 111),
)).toBe(true)
})
it('keeps branch identity when prepended generations shift local ids', () => {
const branch = (id: number) => deriveTrajectoryContextBranches([{
id,
origin: 'rewind',
originSeq: 110,
nodes: [current],
}])[0]
expect(branch(1)?.key).toBe(branch(9)?.key)
})
})

View File

@@ -0,0 +1,287 @@
import type { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type {
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts'
import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts'
import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts'
import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts'
const DEFINITIONS: ConversationNodeDefinition[] = []
const registrationContext = {
conversationEvents: {
register: (definition: ConversationNodeDefinition) => {
DEFINITIONS.push(definition)
return () => {}
},
},
} as unknown as Context
registerTrajectoryMessageDefinitions(registrationContext)
registerTrajectoryRequestHeaderDefinition(registrationContext)
registerTrajectoryAssistantDefinition(registrationContext)
registerTrajectoryToolDefinition(registrationContext)
registerTrajectoryCompactionDefinitions(registrationContext)
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] {
return DEFINITIONS
}
fallbackEntry(): undefined {
return undefined
}
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] {
return [trajectoryViewDefinition]
}
}
function at(
seq: number,
type: string,
data: unknown,
extra: Record<string, unknown> = {},
): ConversationEventInput {
return {
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
data,
...extra,
} as unknown as ConversationEventInput['event'],
view: undefined,
}
}
function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(
new TestEventDefinitions(),
new TestViewDefinitions(),
)
value.replaceWindow(events, false)
value.flush()
return value
}
function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot {
const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined
if (current === undefined) throw new Error('trajectory view was not registered')
return current
}
function assistantMessage(id: string, text: string) {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'test', model: 'test' },
}
}
describe('Trajectory conversation Definitions', () => {
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
}),
at(4, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
}),
])
expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }])
expect(snapshot(value).requests).toMatchObject([{
purpose: 'assistant',
status: 'running',
usage: { inputTokens: 10, outputTokens: 3 },
}])
value.append(at(5, 'llm/retry', {
retryId: 'retry-1',
turn: 1,
step: 1,
provider: 'test',
mode: 'normal',
policyKey: 'test-normal',
retry: 1,
maxRetries: 2,
delayMs: 25,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}))
value.append(at(6, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
}))
value.append(at(7, 'step/end', { turn: 1, step: 1 }))
value.flush()
const settled = snapshot(value)
expect(settled.partial).toBeNull()
expect(settled.eventNodes).toMatchObject([{
kind: 'assistant',
seq: 6.1,
interrupted: true,
blocks: [{ kind: 'text', text: 'second attempt' }],
}])
expect(settled.requests).toMatchObject([{
purpose: 'assistant',
status: 'error',
retry: 1,
maxRetries: 2,
retryDelayMs: 25,
usage: { inputTokens: 10, outputTokens: 3 },
}])
})
it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
const current = snapshot(assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool/call', {
turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}',
}),
at(4, 'tool/call', {
turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}',
}),
at(5, 'tool/code-dispatch-start', {
rootCallId: 'root-a',
parentCallId: 'root-a',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
}),
at(6, 'tool/code-dispatch', {
rootCallId: 'root-a',
parentCallId: 'root-a',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
content: [{ type: 'text', text: 'contents' }],
}),
at(7, 'step/end', { turn: 1, step: 1 }),
]))
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
kind: 'tool-result',
callId: 'child',
call: { name: 'read' },
}])
})
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
const current = snapshot(assembler([
at(1, 'compact/start', { compactionId: 'complete', turn: null }),
at(2, 'compact/summary', {
compactionId: 'complete',
turn: null,
summary: 'summary',
provider: 'test',
model: 'test',
maxTokens: 100,
usage: { inputTokens: 20, outputTokens: 5 },
}),
at(3, 'user/message', {
id: 'checkpoint',
role: 'user',
content: [{ type: 'text', text: 'summary checkpoint' }],
source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
}),
at(4, 'compact/end', { compactionId: 'complete', turn: null }),
at(5, 'compact/start', { compactionId: 'orphan', turn: null }),
at(6, 'session/end-seed', {}),
]))
expect(current.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 1,
status: 'complete',
resultSeq: 2,
replacementSeq: 3,
summary: 'summary',
},
{
purpose: 'compaction',
startSeq: 5,
status: 'error',
completedAt: 1_700_000_000_006,
},
])
})
it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'test', model: 'test' },
system: 'system prompt',
tools: [],
},
}),
at(3, 'step/start', { turn: 1, step: 1 }),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-1', 'first'),
}),
at(5, 'step/end', { turn: 1, step: 1 }),
at(6, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
}),
at(7, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
}),
at(8, 'step/start', { turn: 1, step: 2 }),
])
value.append(at(9, 'user/message', {
id: 'm1',
role: 'user',
content: [{ type: 'text', text: 'steer here' }],
source: { kind: 'user' },
}))
value.flush()
const steering = snapshot(value)
expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering')
expect(steering.eventLocations.get(9)).toMatchObject({
kind: 'step',
turn: { turn: 1 },
step: { step: 2 },
})
value.append(at(10, 'assistant/message', {
turn: 1,
step: 2,
message: assistantMessage('assistant-2', 'second'),
}))
value.flush()
const current = snapshot(value)
expect(current.requests.map(request => request.purpose === 'assistant'
? request.prompt?.system
: undefined)).toEqual(['system prompt', 'system prompt'])
expect(current.requests.map(request => request.purpose === 'assistant'
? request.promptChange?.kind
: undefined)).toEqual(['initial', undefined])
})
})

View File

@@ -6,7 +6,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, RequestView,
ConversationLocation, ConversationSnapshot, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
@@ -84,7 +84,10 @@ describe('deriveTrajectoryLayout', () => {
input: 10, output: 20, think: 5, timeSeconds: 5,
})
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool).toMatchObject({
text: 'bash',
previewMarkdown: '{"command":"ls"}',
})
expect(tool?.timeSeconds).toBe(1.3)
})
@@ -99,7 +102,10 @@ describe('deriveTrajectoryLayout', () => {
})
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
kind: 'tool',
text: 'bash',
previewMarkdown: '{"command":"pwd"}',
timeSeconds: null,
})
})
@@ -132,7 +138,8 @@ describe('deriveTrajectoryLayout', () => {
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
index: 2,
kind: 'message',
text: 'streaming',
text: '',
previewMarkdown: 'streaming',
timeSeconds: null,
}])
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
@@ -222,8 +229,120 @@ describe('deriveTrajectoryLayout', () => {
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
'first',
'ok1',
])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
'second',
'ok2',
])
})
it('places steering in its resolved step instead of the turn-opening Message group', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'first step' }],
},
{
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
content: [{ type: 'text', text: 'change direction' }], source: null,
},
{
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'text', text: 'second step' }],
},
] as unknown as ConversationSnapshot['nodes']
const data = { get: () => undefined }
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
const turn = {
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
}
const eventLocations = new Map<number, ConversationLocation>([[
3,
{ kind: 'step', turn, step },
]])
const turns = deriveTrajectoryLayout({
nodes,
eventLocations,
partial: null,
runningCalls: [],
})
expect(turns).toHaveLength(1)
expect(turns[0]?.groups.map(group => group.title)).toEqual([
'Message', 'Step 1', 'Step 2',
])
expect(turns[0]?.groups[2]?.cells).toMatchObject([
{ kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
{ kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 },
])
})
it('keeps a running request boundary after steering input', () => {
const nodes = [{
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
content: [{ type: 'text', text: 'change direction' }], source: null,
}] as unknown as ConversationSnapshot['nodes']
const data = { get: () => undefined }
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
const turn = {
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
}
const eventLocations = new Map<number, ConversationLocation>([[
3,
{ kind: 'step', turn, step },
]])
const turns = deriveTrajectoryLayout({
nodes,
eventLocations,
partial: null,
runningCalls: [],
requests: [{
purpose: 'assistant',
startSeq: 2,
turn: 1,
step: 2,
startedAt: 2_000,
completedAt: null,
status: 'running',
}],
})
expect(turns[0]?.groups[0]?.cells).toMatchObject([
{ kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
{ kind: 'message', requestOnly: true, sourceSeq: 2 },
])
})
it('uses the following assistant step while a historical window lacks steering Location', () => {
const nodes = [
{
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
content: [{ type: 'text', text: 'change direction' }], source: null,
},
{
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3,
blocks: [{ kind: 'text', text: 'continued' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]).toMatchObject({
turn: 2,
groups: [{
title: 'Step 3',
cells: [
{ kind: 'user', previewMarkdown: 'change direction' },
{ kind: 'message', previewMarkdown: 'continued' },
],
}],
})
})
it('places standalone compaction chronologically in its own between-turn section', () => {
@@ -263,7 +382,8 @@ describe('deriveTrajectoryLayout', () => {
cells: [{
kind: 'compacted',
sourceSeq: 3,
text: 'standalone summary',
text: '',
previewMarkdown: 'standalone summary',
}],
}])
})
@@ -279,7 +399,7 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '…', input: 11, output: 22, think: 3,
text: '', previewMarkdown: '…', input: 11, output: 22, think: 3,
})
})
@@ -296,9 +416,8 @@ describe('deriveTrajectoryLayout', () => {
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
expect(message?.text.endsWith('…')).toBe(true)
expect(message?.text.length).toBeLessThanOrEqual(513)
expect(message?.text).toBe('')
expect(message?.previewMarkdown).toBe(thinking)
expect(message?.thinkingDetail).toBe(thinking)
})
@@ -331,7 +450,7 @@ describe('deriveTrajectoryLayout', () => {
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
expect(message?.timeSeconds).toBe(0.5)
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
@@ -394,7 +513,9 @@ describe('run_code sub-dispatch cells', () => {
expect(cells[0]?.text).toBe('Tool call only')
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({
text: 'bash', previewMarkdown: '{"x":1}', timeSeconds: 1,
})
expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
})
@@ -405,7 +526,9 @@ describe('run_code sub-dispatch cells', () => {
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
expect(sub).toMatchObject({
text: 'grep', previewMarkdown: '{"pattern":"x"}', timeSeconds: null,
})
})
it('recursively flattens nested child calls immediately after their parent', () => {

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
} from '../src/client/trajectory-contract.ts'
import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts'
function assistantRequest(startSeq: number, step: number): Extract<RequestView, { purpose: 'assistant' }> {
return {
purpose: 'assistant',
startSeq,
turn: 1,
step,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
}
}
function contribution(
key: string,
anchorSeq: number,
data: TrajectoryContribution,
): TrajectoryConversationViewNode {
return {
key, kind: key, id: key, target: 'trajectory', anchorSeq,
location: { kind: 'session' },
data,
}
}
function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] {
const data = { get: () => undefined }
const stepLocation = {
turn,
step,
start: undefined,
end: undefined,
status: 'unknown' as const,
data,
}
const turnLocation = {
turn,
start: undefined,
end: undefined,
status: 'unknown' as const,
steps: [stepLocation],
data,
}
return { kind: 'step', turn: turnLocation, step: stepLocation }
}
function compactionRequest(startSeq: number): Extract<RequestView, { purpose: 'compaction' }> {
return {
purpose: 'compaction',
startSeq,
turn: null,
step: 0,
startedAt: startSeq,
completedAt: null,
status: 'running',
}
}
describe('TrajectorySnapshotBuilder', () => {
it('inherits one request header across requests without repeating its prompt change', () => {
const prompt = {
config: { provider: 'test', model: 'test' },
system: 'one initial prompt',
tools: [],
}
const nodes: TrajectoryConversationViewNode[] = [
{
key: 'header',
kind: 'trajectory-request-header',
id: '2',
target: 'trajectory',
anchorSeq: 2,
location: { kind: 'session' },
data: {
kind: 'request-header',
header: {
seq: 2,
time: 2,
prompt,
change: { seq: 2, time: 2, kind: 'initial' },
location: { kind: 'session' },
},
},
},
...[assistantRequest(3, 1), assistantRequest(5, 2)].map(request => ({
key: `assistant:${request.step}`,
kind: 'trajectory-assistant-step',
id: `1:${request.step}`,
target: 'trajectory' as const,
anchorSeq: request.startSeq,
location: { kind: 'session' as const },
data: { kind: 'assistant' as const, partial: null, request },
})),
]
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
expect(snapshot.requests.map(request => request.purpose === 'assistant'
? request.prompt?.system
: undefined)).toEqual(['one initial prompt', 'one initial prompt'])
expect(snapshot.requests.map(request => request.purpose === 'assistant'
? request.promptChange?.kind
: undefined)).toEqual(['initial', undefined])
})
it('indexes exact step headers and the active tool schema without backward scans', () => {
const basePrompt = {
config: { provider: 'test', model: 'base' },
system: 'base prompt',
tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }],
}
const exactPrompt = {
config: { provider: 'test', model: 'exact' },
system: 'exact prompt',
tools: [{ name: 'edit', description: 'Edit', parameters: { type: 'object' } }],
}
const nodes: TrajectoryConversationViewNode[] = [
contribution('header:base', 2, {
kind: 'request-header',
header: {
seq: 2,
time: 2,
prompt: basePrompt,
change: { seq: 2, time: 2, kind: 'initial' },
location: { kind: 'session' },
},
}),
contribution('assistant:1', 3, {
kind: 'assistant',
partial: null,
request: assistantRequest(3, 1),
}),
contribution('assistant:2', 5, {
kind: 'assistant',
partial: null,
request: assistantRequest(5, 2),
}),
contribution('header:exact', 6, {
kind: 'request-header',
header: {
seq: 6,
time: 6,
prompt: exactPrompt,
change: { seq: 6, time: 6, kind: 'system', previous: basePrompt },
location: stepLocation(1, 2),
},
}),
contribution('tool', 7, {
kind: 'tool',
root: {
callId: 'call-edit',
name: 'edit',
argsRaw: '{}',
turn: 1,
step: 2,
time: 7,
callView: null,
subCalls: [],
},
}),
]
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
expect(snapshot.requests.map(request => request.purpose === 'assistant'
? request.prompt?.system
: undefined)).toEqual(['base prompt', 'exact prompt'])
expect(snapshot.callSchemas.get('call-edit')).toEqual(exactPrompt.tools[0])
})
it('applies session boundaries and turn errors with linear request indexes', () => {
const nodes: TrajectoryConversationViewNode[] = [
...[assistantRequest(1, 1), assistantRequest(3, 2)].map(request => contribution(
`assistant:${request.step}`,
request.startSeq,
{ kind: 'assistant', partial: null, request },
)),
contribution('turn-end', 5, {
kind: 'turn-end',
turn: 1,
time: 5,
error: 'turn failed',
}),
contribution('compact:10', 10, {
kind: 'compaction',
request: compactionRequest(10),
}),
contribution('compact:12', 12, {
kind: 'compaction',
request: compactionRequest(12),
}),
contribution('session-end:14', 14, { kind: 'session-end', seq: 14, time: 14 }),
contribution('session-end:16', 16, { kind: 'session-end', seq: 16, time: 16 }),
]
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
expect(snapshot.requests).toMatchObject([
{ purpose: 'assistant', step: 1, status: 'complete' },
{ purpose: 'assistant', step: 2, status: 'error', error: 'turn failed' },
{ purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 },
{ purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 },
])
})
it('keeps cached contribution order across content updates and structural inserts', () => {
const builder = new TrajectorySnapshotBuilder()
const first = contribution('assistant:1', 1, {
kind: 'assistant', partial: null, request: assistantRequest(1, 1),
})
const last = contribution('assistant:3', 5, {
kind: 'assistant', partial: null, request: assistantRequest(5, 3),
})
expect(builder.replace({ nodes: [last, first] }).requests.map(request => request.startSeq))
.toEqual([1, 5])
const updatedLast = contribution('assistant:3', 5, {
kind: 'assistant',
partial: null,
request: { ...assistantRequest(5, 3), status: 'error', error: 'failed' },
})
expect(builder.apply({ upserts: [updatedLast] }).requests.map(request => request.startSeq))
.toEqual([1, 5])
const middle = contribution('assistant:2', 3, {
kind: 'assistant', partial: null, request: assistantRequest(3, 2),
})
expect(builder.apply({ upserts: [middle] }).requests.map(request => request.startSeq))
.toEqual([1, 3, 5])
})
})

View File

@@ -308,6 +308,43 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Request #2')).toBeTruthy()
})
it('places the request boundary after leading steering input', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 2',
cells: [{
index: 1,
kind: 'user',
sourceSeq: 3,
text: 'change direction',
timeSeconds: 0,
}, {
index: 2,
kind: 'message',
sourceSeq: 4,
text: 'continued',
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable
turns={turns}
requestNumbers={[{
seq: 2,
turn: 1,
step: 2,
group: 'Step 2',
number: 1,
}]}
{...FOLD_PROPS}
/>)
const request = screen.getByRole('button', { name: 'Request #1' })
expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT')
})
it('follows appended records only while the ledger is already at the bottom', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const tablePane = screen.getByRole('table').parentElement as HTMLElement

View File

@@ -7,18 +7,20 @@
* 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 { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, 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 { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import {
ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore,
EMPTY_CHAT_SNAPSHOT,
} from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
ConversationSnapshot, RequestView,
SessionId, SessionListState, SnapshotStore, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
@@ -35,9 +37,11 @@ import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
const sessionSnapshots = new WeakMap<SlotsService, SnapshotStore<ConversationSnapshot>>()
const tConversation: ConversationSessionHeaderProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
@@ -67,37 +71,54 @@ const NODES = [
function historySnapshot(
nodes: ConversationSnapshot['nodes'],
inspection: Partial<SessionHistoryInspection> = {},
): SessionHistorySnapshot {
inspection: Partial<TrajectorySnapshot> = {},
): ConversationSnapshot {
const trajectory: TrajectorySnapshot = {
eventNodes: nodes,
eventLocations: new Map(),
requests: [],
callSchemas: new Map(),
partial: null,
runningCalls: [],
...inspection,
}
return {
state: 'ready',
error: null,
hasMore: false,
baseSeq: nodes[0]?.seq ?? 0,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [],
partial: null,
runningCalls: [],
...inspection,
sessionId: SID,
views: {
get: target => target === 'trajectory' ? trajectory : undefined,
},
chat: EMPTY_CHAT_SNAPSHOT,
nodes,
turnTimings: new Map(),
turnEnds: new Map(),
partial: trajectory.partial,
runningCalls: trajectory.runningCalls,
pending: [],
queue: [],
running: false,
subagent: null,
composerPhase: 'active',
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: nodes.length === 0,
lastAgentError: null,
}
}
function standaloneHistory(
snapshot: SessionHistorySnapshot,
snapshot: ConversationSnapshot,
): Pick<
ComponentProps<typeof TrajectoryView>,
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
'useSession' | 'loadOlder'
> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadHistoryTail: () => Promise.resolve(),
loadOlderHistory: () => Promise.resolve(false),
useSession: bindSnapshotSelector(store),
loadOlder: () => Promise.resolve(false),
}
}
@@ -112,11 +133,8 @@ function standaloneDuration(): Pick<
}
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'],
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
const store = createSnapshotStore(historySnapshot(nodes))
return { store, useSession: bindSnapshotSelector(store) }
}
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
@@ -149,16 +167,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadTail: loadHistoryTail,
loadOlder: loadOlderHistory,
const loadOlder = vi.fn(() => Promise.resolve())
const sessionStore = createSnapshotStore(snapshot)
const session = {
getSnapshot: () => sessionStore.getSnapshot(),
subscribe: (listener: () => void) => sessionStore.subscribe(listener),
loadOlder,
}
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(ConversationViewRegistry).await()
ctx.provide('sessions', {
binding: () => ({ session }),
})
sessionSnapshots.set(slots, sessionStore)
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -167,10 +188,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
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)
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
return { ctx, slots, fiber, loadOlder, sessionStore }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -181,21 +201,20 @@ function tabsOf(slots: SlotsService): ViewTab[] {
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
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'],
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
const useSession = bindSnapshotSelector(sessionSnapshot)
const chat = createChatStore().create()
const views = {
list: () => tabsOf(slots),
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}
const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never
const inputActions = { setDraft: vi.fn(), submit: vi.fn() }
const useInput = bindSnapshotSelector(createSnapshotStore({
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
})) as never
const inputActions = {
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
}
// 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).
@@ -211,10 +230,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
loadHistoryTail: trajectory.loadHistoryTail,
loadOlderHistory: trajectory.loadOlderHistory,
loadOlder: trajectory.loadOlder,
setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
}
})()
@@ -256,6 +273,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
actions={chat.actions}
renderSlot={renderSlot}
views={views}
releaseSessionImages={vi.fn()}
useInput={useInput}
inputActions={inputActions}
bindDraftMirror={() => () => {}}
@@ -275,8 +293,16 @@ describe('plugin registration', () => {
it('fiber disposal removes the tab and leaves chat standing', async () => {
const b = await bench()
const events = b.ctx.get('conversationEvents') as ConversationEventRegistry
const views = b.ctx.get('conversationViews') as ConversationViewRegistry
expect(events.entries().length).toBeGreaterThan(0)
expect(views.entries()).toHaveLength(1)
await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
expect(events.entries()).toEqual([])
expect(views.entries()).toEqual([])
})
it('shares one browser-wide duration preference across session injections', async () => {
@@ -296,6 +322,23 @@ describe('plugin registration', () => {
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
})
it('reports whether loading older history changed the Trajectory snapshot', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.view')
.find(candidate => candidate.options.id === 'trajectory')
const injectEntry = entry!.inject as unknown as (
sessionId: SessionId,
) => TrajectoryViewInjected
const injected = injectEntry(SID)
expect(await injected.loadOlder()).toBe(false)
b.loadOlder.mockImplementationOnce(async () => {
b.sessionStore.set(historySnapshot([...NODES]))
})
expect(await injected.loadOlder()).toBe(true)
})
})
describe('tab switching in ConversationRoot', () => {
@@ -317,13 +360,9 @@ describe('tab switching in ConversationRoot', () => {
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.loadHistoryTail).toHaveBeenCalledOnce()
})
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
expect(b.loadOlder).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
expect(b.loadOlder).not.toHaveBeenCalled()
})
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
@@ -1068,7 +1107,7 @@ describe('timeline projection', () => {
})
})
describe('TrajectoryView branches', () => {
describe('TrajectoryView state', () => {
it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore()
const commonProps = {
@@ -1101,111 +1140,6 @@ describe('TrajectoryView branches', () => {
.toBe('true')
})
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([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
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('does not remount the ledger when prepending shifts a rewind generation id', () => {
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'stable rewind response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const snapshot = (id: number) => historySnapshot([current], {
contexts: [{
id,
origin: 'rewind' as const,
originSeq: 4,
nodes: [current],
}],
})
const store = createSnapshotStore(snapshot(1))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
const row = screen.getByRole('row', { name: /stable rewind response/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
act(() => { store.set(snapshot(2)) })
expect(screen.getByRole('row', { name: /stable rewind response/ })
.getAttribute('aria-selected')).toBe('true')
})
it('keeps ledger and timeline selection on the same event after prepend', () => {
const older = {
kind: 'user', seq: 1, time: 1_000,
@@ -1220,9 +1154,8 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
@@ -1237,47 +1170,6 @@ describe('TrajectoryView branches', () => {
)).toBeTruthy()
})
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([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
})
describe('node half', () => {