fix(ui-trajectory): tighten conversation assembly contracts

This commit is contained in:
imccyu
2026-08-10 21:35:57 +08:00
parent b3d3e423f2
commit 62f5d05039
27 changed files with 525 additions and 534 deletions

View File

@@ -37,8 +37,8 @@ class TestEventDefinitions {
definitions: readonly ConversationNodeDefinition[],
fallback?: ConversationNodeDefinition,
) {
this.definitions = definitions.map(asChatDefinition)
this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback)
this.definitions = definitions
this.fallback = fallback
}
entries(): readonly ConversationNodeDefinition[] {
@@ -50,12 +50,6 @@ class TestEventDefinitions {
}
}
function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition {
return definition.buildViewNode === undefined || definition.target !== undefined
? definition
: { ...definition, target: 'chat' }
}
class TestViewDefinitions {
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
@@ -118,6 +112,17 @@ function node(
}
}
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
return {
kind: 'fallback',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
}
describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn((
@@ -137,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -188,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
matchCollections.add(context.matches)
return updates(context)
},
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -223,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -262,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => ({ settled: false }),
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
}
const assembler = new ConversationNodeAssembler(
@@ -295,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
@@ -316,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -330,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -359,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumer: ConversationNodeDefinition<number> = {
@@ -368,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -412,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -440,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -454,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -483,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const sourceX: ConversationNodeDefinition<number> = {
@@ -494,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const middle: ConversationNodeDefinition<number> = {
@@ -506,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
@@ -518,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-b')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -553,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: starts,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -624,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
value: { valueSeenFromStep: stepValue ?? -1 },
}
},
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
if (location?.kind !== 'step') return null
@@ -661,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length
: -1),
@@ -716,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
const data = location?.kind === 'step'
@@ -749,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind),
}
const assembler = new ConversationNodeAssembler(
@@ -775,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -807,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -841,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: seen,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -856,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
expect(seen).toHaveBeenCalledTimes(2)
})
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
it('invokes the fallback when only a State-only Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-state',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('invokes the fallback when only another target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-trajectory',
target: 'trajectory',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('suppresses the fallback when the same target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed',
target: 'chat',
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const fallback: ConversationNodeDefinition<string> = {
kind: 'fallback',
match: event => ({ id: String(event.seq), role: 'start' }),
start: fallbackStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallback),
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
@@ -893,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => false,
target: 'chat',
buildViewNode: context => context.state === true ? node(context, true) : null,
}
const assembler = new ConversationNodeAssembler(
@@ -915,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const startAssembler = new ConversationNodeAssembler(
@@ -934,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => undefined as never,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const updateAssembler = new ConversationNodeAssembler(
@@ -954,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(

View File

@@ -72,6 +72,40 @@ describe('Conversation registries', () => {
expect(events.fallbackEntry()).toBeUndefined()
})
it('rejects rendering Definitions that omit either target or builder', async () => {
const { events } = await bootRegistries()
const targetOnly: ConversationNodeDefinition<null> = {
kind: 'target-only',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
}
const builderOnly: ConversationNodeDefinition<null> = {
kind: 'builder-only',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
})
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
const { events } = await bootRegistries()
const fallback: ConversationNodeDefinition<null> = {
kind: 'state-only-fallback',
match: () => null,
start: () => null,
update: context => context.state,
}
expect(() => events.registerFallback(fallback))
.toThrow('conversation fallback Definition must declare a target')
})
it('rejects duplicate view targets and disposes a view registration once', async () => {
const { views } = await bootRegistries()
const definition = viewDefinition('chat')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 75bd9ddf452634460be01e1b89cd5a1a14a1593f
README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble context lineage and cancellation-frozen Assistant and Tool records from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装上下文谱系,以及因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
## 模型体验

View File

@@ -48,19 +48,25 @@
"diff": "^9.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/cordis": "workspace:^",

View File

@@ -4,13 +4,9 @@ import { useCallback, useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
trajectoryNodeIdentity,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -190,10 +186,7 @@ export function TrajectoryView({
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -215,41 +208,8 @@ export function TrajectoryView({
const runningCalls = inspection.runningCalls
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node]))
for (const node of interruptedNodes) {
selected.set(trajectoryNodeIdentity(node), node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
@@ -345,24 +305,24 @@ export function TrajectoryView({
return numbered
}, [
contexts, nodes, requests,
nodes, requests,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
nodes,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
requests,
callSchemas,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas,
nodes, partialTurn, partialStep,
runningCalls, requests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
@@ -402,9 +362,7 @@ export function TrajectoryView({
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineRange = timelineSelection
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
@@ -420,11 +378,8 @@ export function TrajectoryView({
}
}, [timelineFocusIndexes])
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
setTimelineSelection(range)
}, [])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
@@ -547,7 +502,6 @@ export function TrajectoryView({
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={timelineTurns}
streamingCells={streamingCells}

View File

@@ -1,135 +0,0 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
/** Seq that opened this branch; earlier requests require retained cited surface events. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<string, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
/**
* Resolve the identity used while coalescing one trajectory branch.
* Synthetic tool interruptions share their closing boundary seq, so their
* call ids distinguish parallel roots without inventing false event order.
* @param node - projected conversation node.
* @returns branch-local semantic identity.
*/
export function trajectoryNodeIdentity(node: ConversationNode): string {
return node.kind === 'tool-result'
? `tool-result\u0000${String(node.seq)}\u0000${node.callId}`
: `seq\u0000${String(node.seq)}`
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
if (node.kind !== 'context') return false
const source = node.source
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
/**
* Join context generations across compaction/rewrite operations and split only at rewind.
* @param contexts - Append-only context generations from the runtime fold.
* @returns Rewind-delimited branches in creation order.
*/
export function deriveTrajectoryContextBranches(
contexts: readonly ConversationContext[],
): readonly TrajectoryContextBranch[] {
const mutable: MutableBranch[] = []
for (const context of contexts) {
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [trajectoryNodeIdentity(node), node]),
),
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
continue
}
const branch = mutable.at(-1)
if (branch === undefined) continue
branch.contexts.push(context)
branch.latest = context
for (const node of context.nodes) {
if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node)
}
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying the exact inherited surface event seqs.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
request: RequestView,
): boolean {
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.purpose === 'compaction'
&&
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}

View File

@@ -45,9 +45,9 @@ export function apply(ctx: Context): void {
return {
hooks: { duration },
loadOlder: async () => {
const hadMore = session.getSnapshot().hasMore
const before = session.getSnapshot().views.get('trajectory')
await session.loadOlder()
return hadMore
return session.getSnapshot().views.get('trajectory') !== before
},
setActualDuration: (value) => { duration.set(value) },
}

View File

@@ -9,6 +9,9 @@ import {
} from '@deepseek-ai/dsh-client-runtime/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface UsageValue {
readonly inputTokens: number
readonly outputTokens: number
@@ -389,6 +392,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
...(context.state.error === undefined ? {} : { error: context.state.error }),
}),
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Assistant lifecycle.

View File

@@ -1,5 +1,5 @@
import type {
AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode,
AssistantMessageNode, ConversationLocation, ConversationNode,
ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -59,10 +59,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode {
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
export interface TrajectorySnapshot {
readonly eventNodes: readonly ConversationNode[]
readonly contexts: readonly ConversationContext[]
readonly requests: readonly RequestView[]
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
readonly interruptedNodes: readonly ConversationNode[]
readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[]
}

View File

@@ -1,22 +1,8 @@
import type {
ConversationLocation, ConversationNodeContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {
TrajectoryContribution, TrajectoryConversationViewNode,
} from './trajectory-contract.ts'
/**
* Resolve the best loaded Location for one target-local Context.
*
* @param context - Context whose loaded matches provide the Location.
* @returns The start Location, first-match Location, or unresolved fallback.
*/
export function trajectoryContextLocation(
context: ConversationNodeContext,
): ConversationLocation {
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
}
/**
* Wrap one contribution in the Engine-owned target envelope.
*

View File

@@ -9,6 +9,9 @@ import {
import type {} from '@deepseek-ai/dsh-agent/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface InboxIdentity {
readonly id: string
}
@@ -106,6 +109,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
? null
: trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
}
/* jscpd:ignore-end */
/**
* Register Trajectory-owned inbox classification and message records.

View File

@@ -10,17 +10,14 @@ import type {
} from './trajectory-contract.ts'
const EMPTY_LIST: readonly never[] = []
const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }]
type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }>
type ToolSchema = ConversationPromptSnapshot['tools'][number]
/** Stable empty target used until a Session has assembled Trajectory records. */
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
eventNodes: EMPTY_LIST,
contexts: EMPTY_CONTEXTS,
requests: EMPTY_LIST,
callSchemas: new Map(),
interruptedNodes: EMPTY_LIST,
partial: null,
runningCalls: EMPTY_LIST,
}
@@ -249,10 +246,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const eventNodes = finalized
return {
eventNodes,
contexts: [{ id: 0, nodes: eventNodes }],
requests,
callSchemas,
interruptedNodes: EMPTY_LIST,
partial,
runningCalls,
}

View File

@@ -6,6 +6,9 @@ import type {
import type {} from '@deepseek-ai/dsh-tools/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
const MAX_DEPTH = 256
interface ToolState {
@@ -109,11 +112,26 @@ function childResult(
function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
if (parent === child || state.parents.has(child)) return false
let cursor: string | undefined = parent
for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) {
if (cursor === child) return false
let parentDepth = 0
const ancestors = new Set<string>()
while (cursor !== undefined) {
if (cursor === child || ancestors.has(cursor)) return false
ancestors.add(cursor)
parentDepth++
cursor = state.parents.get(cursor)
}
return cursor === undefined
const pending = [{ callId: child, depth: 1 }]
const descendants = new Set<string>()
let subtreeDepth = 0
for (const candidate of pending) {
if (descendants.has(candidate.callId)) return false
descendants.add(candidate.callId)
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
for (const nested of state.children.get(candidate.callId) ?? []) {
pending.push({ callId: nested, depth: candidate.depth + 1 })
}
}
return parentDepth + subtreeDepth <= MAX_DEPTH
}
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
@@ -243,6 +261,7 @@ const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
},
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Tool lifecycle.

View File

@@ -84,9 +84,15 @@ describe('tsdown client artifact', () => {
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,129 +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 interruptedTool(callId: string): ConversationNode {
return {
kind: 'tool-result',
seq: 19.2,
time: 20,
callId,
call: { name: 'parallel', argsRaw: '{}' },
callTime: 10,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null,
resultView: null,
subCalls: [],
}
}
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)
})
it('retains parallel tool interruptions that share one closing boundary', () => {
const branch = deriveTrajectoryContextBranches([{
id: 0,
nodes: [interruptedTool('call-a'), interruptedTool('call-b')],
}])[0]
expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined))
.toEqual(['call-a', 'call-b'])
})
})

View File

@@ -0,0 +1,276 @@
import type { Context } from '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 current = snapshot(assembler([
at(1, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
}),
at(2, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
}),
at(3, 'user/message', {
id: 'm1',
role: 'user',
content: [{ type: 'text', text: 'steer here' }],
source: { kind: 'user' },
}),
at(4, 'turn/start', { turn: 1 }),
at(5, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'test', model: 'test' },
system: 'system prompt',
tools: [],
},
}),
at(6, 'step/start', { turn: 1, step: 1 }),
at(7, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-1', 'first'),
}),
at(8, 'step/end', { turn: 1, step: 1 }),
at(9, 'step/start', { turn: 1, step: 2 }),
at(10, 'assistant/message', {
turn: 1,
step: 2,
message: assistantMessage('assistant-2', 'second'),
}),
]))
expect(current.eventNodes.find(node => node.seq === 3)?.kind).toBe('steering')
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

@@ -75,10 +75,8 @@ function historySnapshot(
): ConversationSnapshot {
const trajectory: TrajectorySnapshot = {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [],
partial: null,
runningCalls: [],
...inspection,
@@ -191,7 +189,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadOlder }
return { ctx, slots, fiber, loadOlder, sessionStore }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -294,8 +292,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 () => {
@@ -315,6 +321,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', () => {
@@ -1083,7 +1106,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 = {
@@ -1116,109 +1139,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()}
useSession={bindSnapshotSelector(store)}
loadOlder={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()}
useSession={bindSnapshotSelector(store)}
loadOlder={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,
@@ -1249,46 +1169,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()}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
})
describe('node half', () => {

View File

@@ -20,6 +20,15 @@
{
"path": "../runtime"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compact/compact"
},
{
"path": "../../support/invariants"
}