mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
round 2: address manual compaction review findings
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/runtime/README.md
|
||||
README.md: 63876e2f2c762c5eeff95e065338413017e0a333
|
||||
README.zh.md: efa5e841efad1e5ce0f8c3af63eba00bcae1a363
|
||||
README.md: fbb979ad410e520e01220519b57dd428bbda14f1
|
||||
README.zh.md: 29e3f0ef46e4b016679cf17dc58d8fe1a67fca6c
|
||||
|
||||
@@ -26,6 +26,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
## Request inspection
|
||||
|
||||
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
|
||||
@@ -26,6 +26,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
## 请求检查
|
||||
|
||||
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入对话记录 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
|
||||
@@ -35,40 +35,55 @@ export interface RequestPromptChange {
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Lifecycle fields shared by ordinary generation and compaction requests. */
|
||||
interface RequestViewBase {
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
}
|
||||
|
||||
/** One ordinary assistant generation reconstructed from durable request events. */
|
||||
interface AssistantRequestView extends RequestViewBase {
|
||||
purpose: 'assistant'
|
||||
turn: number
|
||||
/** Agent-loop step that issued this request. */
|
||||
step: number
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** One compaction provider request, either turn-owned or standalone between turns. */
|
||||
interface CompactionRequestView extends RequestViewBase {
|
||||
purpose: 'compaction'
|
||||
/** Owning turn, or `null` when manual compaction ran between turns. */
|
||||
turn: number | null
|
||||
/** Direct compaction requests do not consume an agent-loop step. */
|
||||
step: 0
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export type RequestView = AssistantRequestView | CompactionRequestView
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
@@ -110,7 +125,7 @@ interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
data: { turn: number | null }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
@@ -131,7 +146,7 @@ interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
data: { turn: number | null; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
@@ -228,10 +243,21 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
const updateAssistant = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
|
||||
}
|
||||
const updateCompaction = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
@@ -263,7 +289,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
@@ -278,8 +304,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
|
||||
updateAssistant(index, {
|
||||
usage: addTokenUsage(
|
||||
request?.purpose === 'assistant' ? request.usage : undefined,
|
||||
sourceEvent.data.chunk.usage,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -288,7 +317,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
@@ -296,7 +325,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
|
||||
...(request?.purpose === 'assistant'
|
||||
&& request.usage !== undefined
|
||||
|| sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
@@ -306,8 +337,8 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
if (request?.purpose === 'assistant' && request.status === 'running') {
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
@@ -317,7 +348,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
retry: event.data.retry,
|
||||
@@ -328,7 +359,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
@@ -352,7 +383,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
updateCompaction(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
@@ -375,12 +406,12 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
|
||||
@@ -85,6 +85,37 @@ describe('inspectRequests', () => {
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(2, 'compact/end', { turn: null }),
|
||||
at(3, 'step/start', { turn: 2, step: 1 }),
|
||||
]))
|
||||
|
||||
const [compaction, assistant] = snapshot.requests
|
||||
expect(compaction).toMatchObject({
|
||||
purpose: 'compaction',
|
||||
turn: null,
|
||||
step: 0,
|
||||
status: 'complete',
|
||||
})
|
||||
expect(assistant).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
status: 'running',
|
||||
})
|
||||
if (assistant?.purpose === 'assistant') {
|
||||
const turn: number = assistant.turn
|
||||
expect(turn).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
@@ -179,6 +210,7 @@ describe('inspectRequests', () => {
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
|
||||
const [request] = snapshot.requests
|
||||
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
|
||||
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
|
||||
README.zh.md: 853d1be9468f1b136452a2bb72f6be314f45d7ee
|
||||
|
||||
@@ -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 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
}
|
||||
|
||||
interface TableRecord {
|
||||
turn: number
|
||||
turn: number | null
|
||||
section: number
|
||||
group: string
|
||||
groupStart: boolean
|
||||
turnStart: boolean
|
||||
@@ -69,7 +70,7 @@ interface ToolCallTextParts {
|
||||
}
|
||||
|
||||
interface SelectedRequest {
|
||||
turn: number
|
||||
turn: number | null
|
||||
number: number
|
||||
group: string
|
||||
}
|
||||
@@ -237,15 +238,12 @@ export interface TrajectoryTableProps {
|
||||
onToggleAssistant: (index: number) => void
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -261,6 +259,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
|
||||
@@ -271,17 +283,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,
|
||||
@@ -305,18 +318,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
|
||||
}
|
||||
@@ -327,10 +340,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,
|
||||
@@ -372,12 +389,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 =>
|
||||
@@ -1486,7 +1504,7 @@ 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 selectedTabs = selectedRequest !== null
|
||||
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
|
||||
: selected === undefined ? [] : detailTabs(selected)
|
||||
@@ -1554,7 +1572,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]
|
||||
@@ -1600,7 +1618,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
|
||||
@@ -1641,13 +1659,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
|
||||
@@ -1661,6 +1680,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
|
||||
@@ -1673,8 +1693,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)
|
||||
@@ -1701,7 +1722,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 && (
|
||||
@@ -1715,7 +1738,7 @@ export function TrajectoryTable({
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
>
|
||||
Turn {record.turn}
|
||||
{sectionLabel(record.turn)}
|
||||
</span>
|
||||
)}
|
||||
<div className={css.eventInner}>
|
||||
@@ -1895,8 +1918,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>
|
||||
</>
|
||||
)
|
||||
@@ -1927,8 +1950,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -315,9 +315,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
)}
|
||||
<div className={css.turnBoundaries} aria-hidden="true">
|
||||
{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,
|
||||
@@ -380,13 +380,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)
|
||||
)
|
||||
|
||||
@@ -24,9 +24,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[]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
||||
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
@@ -81,18 +84,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 {
|
||||
@@ -136,6 +139,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
|
||||
@@ -191,13 +195,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,
|
||||
@@ -206,7 +213,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}`),
|
||||
)
|
||||
@@ -297,13 +305,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
|
||||
}
|
||||
@@ -446,15 +458,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(
|
||||
@@ -468,7 +481,7 @@ function attachToolSchema(
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
turn: number | null,
|
||||
entry: TurnBucket,
|
||||
): TrajectoryTurnModel {
|
||||
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||||
@@ -482,6 +495,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[] = []
|
||||
|
||||
@@ -86,10 +86,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,
|
||||
@@ -147,10 +149,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 = [
|
||||
{
|
||||
|
||||
@@ -277,6 +277,41 @@ 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('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
@@ -386,6 +421,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(
|
||||
|
||||
@@ -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/compact/compact/README.md
|
||||
README.md: 9c322db998a3179ac96e8fbee26727f3cedef7bb
|
||||
README.zh.md: 2318df4dc5e34d5d35910f957ba75b3eef1488eb
|
||||
README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52
|
||||
README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839
|
||||
|
||||
@@ -54,7 +54,7 @@ The marker pair names lock acquisition and release, not an exclusive event conta
|
||||
|
||||
## Blocking
|
||||
|
||||
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace.
|
||||
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. A live bracket cannot cross a `turn/start` or `turn/end`; during adoption, repair boundaries in the inherited prefix remain replayable when the later end-seed proves their open bracket stale.
|
||||
|
||||
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compact/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compact/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
## 阻塞
|
||||
|
||||
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。
|
||||
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。活动标记对不能跨越 `turn/start` 或 `turn/end`;在接管会话时,如果后续 end-seed 证明打开的标记对已经陈旧,则继承前缀中的修复边界仍可回放。
|
||||
|
||||
锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compact/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compact/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed` 或 `summary` 也会 flush,从而在释放轮次接纳预留前保留该记录。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export const name = 'compact-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
interface CompactionTrace {
|
||||
startSeq: number
|
||||
turn: number | null
|
||||
summarized: boolean
|
||||
}
|
||||
@@ -23,11 +24,59 @@ interface SessionTrace {
|
||||
}
|
||||
|
||||
type CompactionTransition =
|
||||
| { kind: 'start'; turn: number | null }
|
||||
| { kind: 'summary'; turn: number | null }
|
||||
| { kind: 'start'; startSeq: number; turn: number | null }
|
||||
| { kind: 'summary'; startSeq: number; turn: number | null }
|
||||
| { kind: 'end' }
|
||||
| { kind: 'end-seed' }
|
||||
|
||||
/** Compaction starts still unmatched when a later seed boundary made them stale. */
|
||||
function inheritedOrphanStartSeqs(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlySet<number> {
|
||||
const stale = new Set<number>()
|
||||
let openStartSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'compact/start') {
|
||||
openStartSeq = event.seq
|
||||
} else if (event.type === 'compact/end') {
|
||||
openStartSeq = undefined
|
||||
} else if (event.type === 'session/end-seed') {
|
||||
if (openStartSeq !== undefined) stale.add(openStartSeq)
|
||||
openStartSeq = undefined
|
||||
}
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
/** Keep every live compaction bracket on one side of each turn boundary. */
|
||||
function validateTurnBoundary(
|
||||
trace: SessionTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (
|
||||
(event.type !== 'turn/start' && event.type !== 'turn/end')
|
||||
|| trace.compaction === undefined
|
||||
) return
|
||||
const owner = trace.compaction.turn === null
|
||||
? 'standalone compaction'
|
||||
: `compaction for turn ${trace.compaction.turn}`
|
||||
fail(`${event.type} cannot cross an open ${owner}`)
|
||||
}
|
||||
|
||||
/** Advance the committed turn cursor after its boundary has been accepted. */
|
||||
function applyTurnBoundary(trace: SessionTrace, event: SessionEvent): boolean {
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return true
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
|
||||
function validateOwner(
|
||||
owner: number | null,
|
||||
@@ -40,12 +89,7 @@ function validateOwner(
|
||||
return
|
||||
}
|
||||
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
|
||||
if (owner !== openTurn) {
|
||||
if (eventType === 'compact/summary') {
|
||||
fail(`compact/summary belongs to turn ${owner} but open turn is ${openTurn}`)
|
||||
}
|
||||
fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
|
||||
}
|
||||
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
|
||||
}
|
||||
|
||||
/** Validate one compaction event without advancing committed trace state. */
|
||||
@@ -65,7 +109,7 @@ function validateCompactionEvent(
|
||||
fail(`compact/start while ${owner} is still compacting`)
|
||||
}
|
||||
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
|
||||
return { kind: 'start', turn: event.data.turn }
|
||||
return { kind: 'start', startSeq: event.seq, turn: event.data.turn }
|
||||
}
|
||||
if (event.type === 'compact/summary') {
|
||||
if (open === undefined) fail('compact/summary has no matching compact/start')
|
||||
@@ -79,7 +123,7 @@ function validateCompactionEvent(
|
||||
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
|
||||
fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
|
||||
}
|
||||
return { kind: 'summary', turn: open.turn }
|
||||
return { kind: 'summary', startSeq: open.startSeq, turn: open.turn }
|
||||
}
|
||||
if (open === undefined) fail('compact/end has no matching compact/start')
|
||||
if (event.data.turn !== open.turn) {
|
||||
@@ -96,8 +140,20 @@ function validateCompactionEvent(
|
||||
function applyCompactionTransition(
|
||||
transition: CompactionTransition,
|
||||
): CompactionTrace | undefined {
|
||||
if (transition.kind === 'start') return { turn: transition.turn, summarized: false }
|
||||
if (transition.kind === 'summary') return { turn: transition.turn, summarized: true }
|
||||
if (transition.kind === 'start') {
|
||||
return {
|
||||
startSeq: transition.startSeq,
|
||||
turn: transition.turn,
|
||||
summarized: false,
|
||||
}
|
||||
}
|
||||
if (transition.kind === 'summary') {
|
||||
return {
|
||||
startSeq: transition.startSeq,
|
||||
turn: transition.turn,
|
||||
summarized: true,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -110,11 +166,20 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
const seed = (session: Session): SessionTrace => {
|
||||
const trace: SessionTrace = { openTurn: null, compaction: undefined }
|
||||
traces.set(session, trace)
|
||||
const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events)
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'turn/start') trace.openTurn = event.data.turn
|
||||
else if (event.type === 'turn/end') trace.openTurn = null
|
||||
// Constructor-seed repair boundaries can precede the end-seed marker
|
||||
// that proves an inherited orphan stale. Replay that inherited prefix
|
||||
// without letting the soon-to-be-cleared bracket veto its repair.
|
||||
if (
|
||||
trace.compaction === undefined
|
||||
|| !staleOrphanStartSeqs.has(trace.compaction.startSeq)
|
||||
) {
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
}
|
||||
const transition = validateCompactionEvent(trace, event, fail)
|
||||
if (transition !== undefined) trace.compaction = applyCompactionTransition(transition)
|
||||
applyTurnBoundary(trace, event)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
@@ -124,14 +189,8 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const trace = traceFor(session)
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return
|
||||
}
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
if (applyTurnBoundary(trace, event)) return
|
||||
if (event.type !== 'session/end-seed'
|
||||
&& event.type !== 'compact/start'
|
||||
&& event.type !== 'compact/summary'
|
||||
@@ -145,7 +204,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateCompactionEvent(traceFor(session), event, fail)
|
||||
const trace = traceFor(session)
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
const transition = validateCompactionEvent(trace, event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
@@ -73,6 +73,71 @@ describe('compaction invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-numbered-compaction-source'))
|
||||
startTurn(source)
|
||||
source.append('compact/start', { turn: 1 })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type))
|
||||
.toEqual(['turn/start', 'compact/start', 'session/end-seed'])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => replayed.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'interrupted' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type)).toEqual([
|
||||
'compact/start',
|
||||
'turn/start',
|
||||
'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
|
||||
expect(() => {
|
||||
startTurn(replayed, 2)
|
||||
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
|
||||
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined))
|
||||
.rejects.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
})
|
||||
|
||||
it('rebuilds an open trace when the companion loads after the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -141,23 +206,30 @@ describe('compaction invariants', () => {
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an open compaction that crosses into another turn', async () => {
|
||||
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
|
||||
const ctx = await setup()
|
||||
const summarySession = ctx.sessions.create()
|
||||
startTurn(summarySession)
|
||||
summarySession.append('compact/start', { turn: 1 })
|
||||
summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(summarySession, 2)
|
||||
expect(() => summarySession.append('compact/summary', summary()))
|
||||
.toThrow(/belongs to turn 1 but open turn is 2/)
|
||||
const standalone = ctx.sessions.create()
|
||||
standalone.append('compact/start', { turn: null })
|
||||
expect(() => { startTurn(standalone) })
|
||||
.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
standalone.append('compact/end', { turn: null, error: 'cancelled' })
|
||||
expect(() => {
|
||||
startTurn(standalone)
|
||||
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const endSession = ctx.sessions.create()
|
||||
startTurn(endSession)
|
||||
endSession.append('compact/start', { turn: 1 })
|
||||
endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(endSession, 2)
|
||||
expect(() => endSession.append('compact/end', { turn: 1, error: 'late' }))
|
||||
.toThrow(/names turn 1 but open turn is 2/)
|
||||
const numbered = ctx.sessions.create()
|
||||
startTurn(numbered)
|
||||
numbered.append('compact/start', { turn: 1 })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
|
||||
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
Reference in New Issue
Block a user