Merge latest master into PR 555
# Conflicts: # docs/architecture.i18n.yaml # docs/core-data-structures/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -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: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
|
||||
README.zh.md: 6ddc32f2f27c93f8ccc80b3d9b31d56d3cf4dd94
|
||||
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
|
||||
README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1
|
||||
|
||||
@@ -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. A fixed Overview above the ledger projects real record start/duration timing from left to right; 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. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). 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. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; 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. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -104,7 +104,8 @@ const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
|
||||
}
|
||||
|
||||
interface TableRecord {
|
||||
turn: number
|
||||
turn: number | null
|
||||
section: number
|
||||
group: string
|
||||
groupStart: boolean
|
||||
turnStart: boolean
|
||||
@@ -146,7 +147,8 @@ interface ToolCallTextParts {
|
||||
}
|
||||
|
||||
interface SelectedRequest {
|
||||
turn: number
|
||||
turn: number | null
|
||||
section: number
|
||||
number: number
|
||||
group: string
|
||||
}
|
||||
@@ -320,15 +322,12 @@ export interface TrajectoryTableProps {
|
||||
onInspectApplied?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** One request identity paired with its session-global number. */
|
||||
export interface TrajectoryRequestNumber {
|
||||
/** Request-inspector fields shared by ordinary generation and compaction. */
|
||||
interface TrajectoryRequestNumberBase {
|
||||
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
|
||||
seq?: number
|
||||
turn: number
|
||||
step: number
|
||||
group: string
|
||||
number: number
|
||||
purpose?: 'compaction'
|
||||
status?: 'complete' | 'running' | 'error'
|
||||
startedAt?: number
|
||||
completedAt?: number | null
|
||||
@@ -344,6 +343,20 @@ export interface TrajectoryRequestNumber {
|
||||
cumulativeUsage?: TrajectoryUsage
|
||||
}
|
||||
|
||||
/** One purpose-discriminated request identity paired with its session-global number. */
|
||||
export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & (
|
||||
| {
|
||||
purpose?: 'assistant'
|
||||
turn: number
|
||||
step: number
|
||||
}
|
||||
| {
|
||||
purpose: 'compaction'
|
||||
turn: number | null
|
||||
step: 0
|
||||
}
|
||||
)
|
||||
|
||||
/** Disjoint provider token buckets for one request or a session prefix. */
|
||||
export interface TrajectoryUsage {
|
||||
input?: number
|
||||
@@ -354,17 +367,18 @@ export interface TrajectoryUsage {
|
||||
}
|
||||
|
||||
function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
return turns.flatMap((turn) => {
|
||||
let firstInTurn = true
|
||||
return turns.flatMap((turn, section) => {
|
||||
let firstInSection = true
|
||||
const records = turn.groups.flatMap((group) => {
|
||||
return group.cells.map((cell, index) => {
|
||||
const turnStart = firstInTurn
|
||||
const turnStart = firstInSection
|
||||
&& cell.requestOnly !== true
|
||||
&& cell.kind !== 'system'
|
||||
&& cell.kind !== 'compacted'
|
||||
if (turnStart) firstInTurn = false
|
||||
&& (cell.kind !== 'compacted' || turn.turn === null)
|
||||
if (turnStart) firstInSection = false
|
||||
return {
|
||||
turn: turn.turn,
|
||||
section,
|
||||
group: group.title,
|
||||
groupStart: index === 0,
|
||||
turnStart,
|
||||
@@ -388,18 +402,18 @@ function filterRecords(
|
||||
record.cell.requestOnly !== true && matches.has(record.cell.index),
|
||||
)
|
||||
.map(record => ({ ...record, groupStart: false, turnStart: false, turnEnd: false }))
|
||||
const startedTurns = new Set<number>()
|
||||
const startedSections = new Set<number>()
|
||||
for (const [index, record] of filtered.entries()) {
|
||||
const previous = filtered[index - 1]
|
||||
const next = filtered[index + 1]
|
||||
record.groupStart = previous === undefined
|
||||
|| previous.turn !== record.turn
|
||||
|| previous.section !== record.section
|
||||
|| previous.group !== record.group
|
||||
record.turnStart = !startedTurns.has(record.turn)
|
||||
record.turnStart = !startedSections.has(record.section)
|
||||
&& record.cell.kind !== 'system'
|
||||
&& record.cell.kind !== 'compacted'
|
||||
if (record.turnStart) startedTurns.add(record.turn)
|
||||
record.turnEnd = next === undefined || next.turn !== record.turn
|
||||
&& (record.cell.kind !== 'compacted' || record.turn === null)
|
||||
if (record.turnStart) startedSections.add(record.section)
|
||||
record.turnEnd = next === undefined || next.section !== record.section
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -410,10 +424,14 @@ function requestStep(group: string): number | undefined {
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function requestKey(turn: number, group: string): string {
|
||||
function requestKey(turn: number | null, group: string): string {
|
||||
return `${turn}\u0000${group}`
|
||||
}
|
||||
|
||||
function sectionLabel(turn: number | null): string {
|
||||
return turn === null ? 'Between turns' : `Turn ${turn}`
|
||||
}
|
||||
|
||||
function indexRequestNumbers(
|
||||
records: readonly TableRecord[],
|
||||
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
|
||||
@@ -455,12 +473,13 @@ function collapseTurnRecords(
|
||||
if (collapsedTurns.size === 0) return [...records]
|
||||
const recordsByTurn = new Map<number, TableRecord[]>()
|
||||
for (const record of records) {
|
||||
if (record.turn === null) continue
|
||||
const turnRecords = recordsByTurn.get(record.turn) ?? []
|
||||
turnRecords.push(record)
|
||||
recordsByTurn.set(record.turn, turnRecords)
|
||||
}
|
||||
return records.flatMap((record) => {
|
||||
if (!collapsedTurns.has(record.turn)) return [record]
|
||||
if (record.turn === null || !collapsedTurns.has(record.turn)) return [record]
|
||||
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
|
||||
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
|
||||
const contentRecords = turnRecords.filter(candidate =>
|
||||
@@ -1537,6 +1556,7 @@ export function TrajectoryTable({
|
||||
? []
|
||||
: allRecords.filter(record =>
|
||||
record.turn === selectedRequest.turn
|
||||
&& record.section === selectedRequest.section
|
||||
&& record.group === selectedRequest.group,
|
||||
)
|
||||
const selectedRequestAssistant = selectedRequestRecords.find(
|
||||
@@ -1588,7 +1608,8 @@ export function TrajectoryTable({
|
||||
const selectedRequestCumulativeUsage =
|
||||
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
|
||||
const selectedRequestOptions = selectedRequestInfo?.requestConfig
|
||||
const activeTurn = selectedRequest?.turn ?? selected?.turn
|
||||
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
|
||||
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
|
||||
const selectedTabs = selectedRequest !== null
|
||||
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
|
||||
: selected === undefined ? [] : detailTabs(selected)
|
||||
@@ -1604,6 +1625,7 @@ export function TrajectoryTable({
|
||||
selected !== undefined && selectedAssistantRequest !== undefined
|
||||
? {
|
||||
turn: selected.turn,
|
||||
section: selected.section,
|
||||
number: selectedAssistantRequest,
|
||||
group: selected.group,
|
||||
}
|
||||
@@ -1664,7 +1686,7 @@ export function TrajectoryTable({
|
||||
|
||||
const openRecordSummary = (target: TableRecord) => {
|
||||
const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
|
||||
if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
|
||||
if (target.turn !== null && collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
|
||||
if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') {
|
||||
for (let i = targetAt - 1; i >= 0; i--) {
|
||||
const candidate = allRecords[i]
|
||||
@@ -1739,7 +1761,7 @@ export function TrajectoryTable({
|
||||
&& record.cell.index === allRecords[0]?.cell.index
|
||||
const request = record.groupStart
|
||||
&& !isCollapsedSummary
|
||||
&& !collapsedTurns.has(record.turn)
|
||||
&& (record.turn === null || !collapsedTurns.has(record.turn))
|
||||
? requestNumbers.get(requestKey(record.turn, record.group))
|
||||
: undefined
|
||||
const requestInfo = request === undefined
|
||||
@@ -1750,7 +1772,11 @@ export function TrajectoryTable({
|
||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
||||
const requestSelected = request !== undefined
|
||||
&& selectedRequest?.turn === record.turn
|
||||
&& selectedRequest.section === record.section
|
||||
&& selectedRequest.number === request
|
||||
const sectionActive = record.turn === null
|
||||
? activeSection === record.section
|
||||
: activeTurn === record.turn
|
||||
return (
|
||||
<tr
|
||||
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
|
||||
@@ -1780,13 +1806,14 @@ export function TrajectoryTable({
|
||||
? undefined
|
||||
: isCollapsedSummary
|
||||
? () => {
|
||||
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
|
||||
else onToggleAssistant(record.cell.index)
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(record.cell.index)
|
||||
}
|
||||
: () => { selectRecord(record.cell.index) }}
|
||||
onDoubleClick={(event) => {
|
||||
if (isCollapsedSummary || isRequestOnly) return
|
||||
if (collapsedTurns.has(record.turn)) {
|
||||
if (record.turn !== null && collapsedTurns.has(record.turn)) {
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
return
|
||||
@@ -1800,6 +1827,7 @@ export function TrajectoryTable({
|
||||
return
|
||||
}
|
||||
if (!record.turnStart) return
|
||||
if (record.turn === null) return
|
||||
if (allRecords.filter(candidate =>
|
||||
candidate.turn === record.turn
|
||||
&& candidate.cell.requestOnly !== true
|
||||
@@ -1812,8 +1840,9 @@ export function TrajectoryTable({
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (isCollapsedSummary) {
|
||||
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
|
||||
else onToggleAssistant(record.cell.index)
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(record.cell.index)
|
||||
return
|
||||
}
|
||||
selectRecord(record.cell.index)
|
||||
@@ -1833,6 +1862,7 @@ export function TrajectoryTable({
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
turn: record.turn,
|
||||
section: record.section,
|
||||
number: request,
|
||||
group: record.group,
|
||||
})
|
||||
@@ -1840,7 +1870,9 @@ export function TrajectoryTable({
|
||||
onDoubleClick={(event) => { event.stopPropagation() }}
|
||||
/>
|
||||
)}
|
||||
{activeTurn === record.turn && !isInitialSystem && (
|
||||
{record.turn !== null
|
||||
&& activeTurn === record.turn
|
||||
&& !isInitialSystem && (
|
||||
<span className={css.turnRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary && selectedIndex === record.cell.index && (
|
||||
@@ -1850,17 +1882,23 @@ export function TrajectoryTable({
|
||||
&& !isRequestOnly
|
||||
&& record.turnStart && (
|
||||
<span
|
||||
className={activeTurn === record.turn
|
||||
className={sectionActive
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
aria-label={`Turn ${record.turn}`}
|
||||
aria-label={sectionLabel(record.turn)}
|
||||
>
|
||||
<span className={css.turnLabelFull} aria-hidden="true">
|
||||
Turn {record.turn}
|
||||
</span>
|
||||
<span className={css.turnLabelCompact} aria-hidden="true">
|
||||
#{record.turn}
|
||||
</span>
|
||||
{record.turn === null
|
||||
? sectionLabel(record.turn)
|
||||
: (
|
||||
<>
|
||||
<span className={css.turnLabelFull} aria-hidden="true">
|
||||
{sectionLabel(record.turn)}
|
||||
</span>
|
||||
<span className={css.turnLabelCompact} aria-hidden="true">
|
||||
#{record.turn}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className={css.eventInner}>
|
||||
@@ -2049,8 +2087,8 @@ export function TrajectoryTable({
|
||||
</span>
|
||||
<span className={css.detailsLocation}>
|
||||
{selectedRequestInfo?.purpose === 'compaction'
|
||||
? `Compaction · Turn ${selectedRequest.turn}`
|
||||
: `Turn ${selectedRequest.turn}`}
|
||||
? `Compaction · ${sectionLabel(selectedRequest.turn)}`
|
||||
: sectionLabel(selectedRequest.turn)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
@@ -2081,8 +2119,8 @@ export function TrajectoryTable({
|
||||
</span>
|
||||
<span className={css.detailsLocation}>
|
||||
{selected.cell.kind === 'compacted'
|
||||
? `Turn ${selected.turn}`
|
||||
: `Turn ${selected.turn} · ${selected.group}`}
|
||||
? sectionLabel(selected.turn)
|
||||
: `${sectionLabel(selected.turn)} · ${selected.group}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -431,9 +431,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
style={projectedDomainStyle}
|
||||
>
|
||||
{model.turnBoundaries
|
||||
.slice(1)
|
||||
.filter(boundary =>
|
||||
boundary.time >= domainStart
|
||||
boundary.time > model.start
|
||||
&& boundary.time >= domainStart
|
||||
&& boundary.time <= domainStart + domainDuration)
|
||||
.map(boundary => (
|
||||
<span
|
||||
|
||||
@@ -102,7 +102,7 @@ function searchMatches(
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
const text = [
|
||||
`turn ${turn.turn}`,
|
||||
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
|
||||
group.title,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : undefined,
|
||||
@@ -383,13 +383,15 @@ export function TrajectoryView({
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
.filter(turn =>
|
||||
turn.turn !== null
|
||||
&&
|
||||
turn.groups.reduce(
|
||||
(count, group) =>
|
||||
count + group.cells.filter(cell =>
|
||||
cell.requestOnly !== true && cell.kind !== 'system').length,
|
||||
0,
|
||||
) > 1)
|
||||
.map(turn => turn.turn),
|
||||
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
|
||||
[turns],
|
||||
)
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
|
||||
@@ -107,6 +107,8 @@ export function trajectoryBranchContainsRequest(
|
||||
request.resultSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
|
||||
) || (
|
||||
request.purpose === 'compaction'
|
||||
&&
|
||||
request.replacementSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
|
||||
)
|
||||
|
||||
@@ -25,9 +25,9 @@ export interface TrajectoryGroupModel {
|
||||
cells: readonly TrajectoryCellProps[]
|
||||
}
|
||||
|
||||
/** One sticky-turn section. */
|
||||
/** One sticky turn, or a standalone compaction section between turns. */
|
||||
export interface TrajectoryTurnModel {
|
||||
turn: number
|
||||
turn: number | null
|
||||
groups: readonly TrajectoryGroupModel[]
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
||||
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||||
|
||||
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
||||
const PREVIEW_OUTPUT_CHARACTERS = 512
|
||||
|
||||
@@ -85,18 +88,18 @@ type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: RequestView
|
||||
request: CompactionRequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
request: RequestView
|
||||
request: AssistantRequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: RequestView
|
||||
request: AssistantRequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
@@ -141,6 +144,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
if (startedAt !== null) callStartById.set(call.callId, startedAt)
|
||||
}
|
||||
const turns = new Map<number, TurnBucket>()
|
||||
const standaloneCompactions: TurnBucket[] = []
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
@@ -196,13 +200,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
nodeIndex,
|
||||
})),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'compaction')
|
||||
.filter((request): request is CompactionRequestView =>
|
||||
request.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
...requests.flatMap(request => request.purpose !== 'assistant'
|
||||
|| request.promptChange === undefined
|
||||
|| request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
@@ -211,7 +218,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.filter((request): request is AssistantRequestView =>
|
||||
request.purpose === 'assistant')
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
@@ -302,13 +310,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
}
|
||||
attachUsage(cell, request.usage as UsageLike | undefined)
|
||||
bucket(request.turn).groups.push({
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
const compaction: TurnBucket = {
|
||||
groups: [{
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
}],
|
||||
}],
|
||||
})
|
||||
}
|
||||
if (request.turn === null) standaloneCompactions.push(compaction)
|
||||
else bucket(request.turn).groups.push(...compaction.groups)
|
||||
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
@@ -451,15 +463,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
for (const entry of turns.values()) {
|
||||
for (const entry of [...turns.values(), ...standaloneCompactions]) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||||
}
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
return [
|
||||
...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)),
|
||||
...standaloneCompactions.map(entry => toTurnModel(null, entry)),
|
||||
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
|
||||
}
|
||||
|
||||
function attachToolSchema(
|
||||
@@ -473,7 +486,7 @@ function attachToolSchema(
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
turn: number | null,
|
||||
entry: TurnBucket,
|
||||
): TrajectoryTurnModel {
|
||||
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||||
@@ -487,6 +500,14 @@ function toTurnModel(
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Chronological section position from the fold's monotonically assigned cell indexes. */
|
||||
function firstCellIndex(turn: TrajectoryTurnModel): number {
|
||||
return Math.min(
|
||||
...turn.groups.flatMap(group => group.cells.map(cell => cell.index)),
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
|
||||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -87,10 +87,12 @@ export function deriveTrajectoryTimeline(
|
||||
group.cells.filter(cell => cell.requestOnly !== true),
|
||||
)
|
||||
if (cells.length === 0) continue
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: spans.length,
|
||||
})
|
||||
if (turn.turn !== null) {
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: spans.length,
|
||||
})
|
||||
}
|
||||
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
|
||||
start: spans.length + offset,
|
||||
end: spans.length + offset + 1,
|
||||
@@ -150,10 +152,12 @@ function deriveTimedTimeline(
|
||||
start: span.start - removedUserIdle,
|
||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
||||
})))
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: turnStart - removedUserIdle,
|
||||
})
|
||||
if (turn.turn !== null) {
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: turnStart - removedUserIdle,
|
||||
})
|
||||
}
|
||||
previousTurnEnd = previousTurnEnd === null
|
||||
? turnEnd
|
||||
: Math.max(previousTurnEnd, turnEnd)
|
||||
|
||||
@@ -38,17 +38,22 @@ function request(
|
||||
resultSeq?: number,
|
||||
replacementSeq?: number,
|
||||
): RequestView {
|
||||
return {
|
||||
purpose,
|
||||
const base = {
|
||||
startSeq,
|
||||
turn: 1,
|
||||
step: purpose === 'assistant' ? 1 : 0,
|
||||
startedAt: startSeq,
|
||||
completedAt: startSeq + 1,
|
||||
status: 'complete',
|
||||
status: 'complete' as const,
|
||||
...(resultSeq === undefined ? {} : { resultSeq }),
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
return purpose === 'assistant'
|
||||
? { ...base, purpose, turn: 1, step: 1 }
|
||||
: {
|
||||
...base,
|
||||
purpose,
|
||||
turn: 1,
|
||||
step: 0,
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory context branches', () => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
@@ -161,6 +163,49 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('places standalone compaction chronologically in its own between-turn section', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before compaction' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after compaction' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compaction: RequestView = {
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
}
|
||||
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes,
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
requests: [compaction],
|
||||
})
|
||||
|
||||
expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
|
||||
expect(turns[1]?.groups).toMatchObject([{
|
||||
title: 'Compaction 3',
|
||||
cells: [{
|
||||
kind: 'compacted',
|
||||
sourceSeq: 3,
|
||||
text: 'standalone summary',
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('keeps usage and a meaningful summary when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
|
||||
@@ -290,6 +290,105 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compaction: RequestView = {
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
}
|
||||
const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
|
||||
const view = mount(b.slots, nodes)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
expect(screen.getByText('Between turns')).toBeTruthy()
|
||||
expect(view.container.textContent).not.toContain('Turn null')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' }))
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
expect(view.container.textContent).not.toContain('Turn null')
|
||||
})
|
||||
|
||||
it('activates only the selected standalone compaction section', async () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before first compaction' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'between compactions' }],
|
||||
},
|
||||
{ kind: 'user', seq: 9, time: 9_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after second compaction' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compactions: RequestView[] = [
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'first standalone summary' }],
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 7,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 7_000,
|
||||
completedAt: 8_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'second standalone summary' }],
|
||||
},
|
||||
]
|
||||
const b = await bench(historySnapshot(nodes, { requests: compactions }))
|
||||
mount(b.slots, nodes)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' })
|
||||
const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' })
|
||||
const firstSection = firstRequest.closest('tr')?.querySelector('span')
|
||||
const secondSection = secondRequest.closest('tr')?.querySelector('span')
|
||||
expect(firstSection?.textContent).toBe('Between turns')
|
||||
expect(secondSection?.textContent).toBe('Between turns')
|
||||
|
||||
fireEvent.click(firstRequest)
|
||||
expect(firstSection?.className).toMatch(/turnLabelActive/)
|
||||
expect(secondSection?.className).not.toMatch(/turnLabelActive/)
|
||||
expect(screen.getByText('Request #2')).toBeTruthy()
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
|
||||
fireEvent.click(secondRequest)
|
||||
expect(firstSection?.className).not.toMatch(/turnLabelActive/)
|
||||
expect(secondSection?.className).toMatch(/turnLabelActive/)
|
||||
expect(screen.getByText('Request #4')).toBeTruthy()
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
@@ -563,6 +662,44 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects between-turn compaction without inventing a turn boundary', () => {
|
||||
const withStandaloneCompaction = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: null,
|
||||
groups: [{
|
||||
title: 'Compaction 3',
|
||||
cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
|
||||
spans: [
|
||||
{ index: 1, start: 0, end: 1 },
|
||||
{ index: 2, start: 1, end: 2 },
|
||||
{ index: 3, start: 2, end: 3 },
|
||||
],
|
||||
turnBoundaries: [
|
||||
{ turn: 1, time: 0 },
|
||||
{ turn: 2, time: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
|
||||
Reference in New Issue
Block a user