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:
creatixchu
2026-07-31 19:39:22 +08:00
187 changed files with 5806 additions and 657 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 2af844e812aca7692c9bdd9c4ca131481a4c0db0
README.zh.md: ad18a97bde7ac1dabde2c4b67f25dd3eb60244be
README.md: e862b024367137a937c1995137fb98e682838cf9
README.zh.md: ae4ca7bc0be348feb719da10e26a17580338a170

View File

@@ -30,6 +30,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. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## 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.
@@ -40,7 +44,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
## Session forking

View File

@@ -30,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
@@ -40,7 +44,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose资源释放会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose资源释放会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
## 会话 fork

View File

@@ -47,7 +47,7 @@ export type {
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,

View File

@@ -138,6 +138,19 @@ export type ModelRetryNode = LlmRetryEventData & {
retryState: 'scheduled' | 'started' | 'cancelled'
}
/** Durable terminal failure for a turn that has no scheduled retry. */
export interface TurnErrorNode {
kind: 'turn-error'
/** Seq of the owning turn/end event. */
seq: number
/** Unix epoch ms from the turn/end event. */
time: number
turn: number
step: number
message: string
code?: string
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -226,6 +239,7 @@ export type ConversationNode =
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode

View File

@@ -0,0 +1,10 @@
/**
* Convert a durable failure into copy that is safe to expose in the GUI.
* @param failure - Structured failure preserved by the session event.
* @returns Display-safe copy for client projections.
*/
export function displayFailureMessage(failure: { code?: string; message: string }): string {
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
}

View File

@@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
import { displayFailureMessage } from './failure-display.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
@@ -35,40 +36,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 +126,7 @@ interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
data: { turn: number | null }
}
interface CompactionSummaryEvent {
@@ -131,7 +147,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 +244,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 +290,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 +305,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 +318,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 +326,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 +338,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,9 +349,9 @@ 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,
error: displayFailureMessage(event.data.failure),
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
@@ -328,14 +360,23 @@ 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,
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
})
continue
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
@@ -352,7 +393,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 +416,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 }),

View File

@@ -20,6 +20,7 @@ import type {
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
@@ -772,6 +773,22 @@ export class Session implements SessionFace {
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: event.data.reason.step,
message: displayFailureMessage(failure),
...(failure.code === undefined ? {} : { code: failure.code }),
})
this.derivedRev++
}
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node

View File

@@ -85,6 +85,68 @@ 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('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'session/end-seed', {}),
at(2, 'compact/start', { turn: null }),
at(3, 'compact/summary', {
summary: [{ type: 'text', text: 'replacement summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(4, 'compact/end', { turn: null }),
]))
expect(snapshot.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 0,
status: 'error',
completedAt: 1_700_000_000_001,
error: 'Compaction was interrupted before completion.',
},
{
purpose: 'compaction',
startSeq: 2,
status: 'complete',
completedAt: 1_700_000_000_004,
summary: [{ type: 'text', text: 'replacement summary' }],
},
])
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
@@ -159,6 +221,33 @@ describe('inspectRequests', () => {
})
})
it('keeps provider credential fragments out of projected request errors', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 1,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2,
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
}),
]))
expect(snapshot.requests).toMatchObject([
{ status: 'error', error: 'API key is invalid' },
{ status: 'error', error: 'plugin exploded' },
])
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
@@ -179,6 +268,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([])
})
})

View File

@@ -211,6 +211,7 @@ describe('live event path', () => {
for (const event of retryTurn.slice(7)) feed(event)
snapshot = session.getSnapshot()
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
@@ -221,6 +222,50 @@ describe('live event path', () => {
expect(replay.session.getSnapshot().partial).toBeNull()
})
it('projects unretried terminal failures at turn/end and reproduces them from history', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
const failedTurns = [
ev.turnStart(6, 1),
ev.user(7, '鉴权失败'),
at(8, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error',
step: 0,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
},
}),
ev.turnStart(9, 2),
ev.user(10, '内部失败'),
at(11, {
type: 'turn/end',
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
}),
]
for (const event of failedTurns) feed(event)
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
expect(errors).toMatchObject([
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
])
expect('code' in errors[1]!).toBe(false)
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes)
})
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 10c1fdcdfbe9d7bfdf5f19767d7898ffc5163f99
README.zh.md: db3a20bfd0b318e1f721e6d374f39206e7565adb
README.md: 2dafe3c69bb087550f6c0f7faab12bff1f1324ac
README.zh.md: 4201441c6c6397b7b35fe0fab99fc6fc6271719f

View File

@@ -24,7 +24,7 @@ A `read` call declaring the `read` render intent renders the returned file windo
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).

View File

@@ -22,7 +22,7 @@
声明 `diff` 渲染意图的工具调用(`write``edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView``resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff对任何其他 card 标签或 generic result viewwrite/edit 的执行错误)它返回 null落回通用路径。键控的 `FileMutationRow`(在 `write``edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`8面板为 16[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试Host 的 running 位只控制实时动画随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试Host 的 running 位只控制实时动画随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作AUTH 文案绝不会回显提供方给出的凭据片段。
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。

View File

@@ -200,6 +200,40 @@
color: var(--dsw-alias-label-secondary);
}
.turnErrorRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 2px 0;
font-size: 13px;
line-height: 20px;
}
.turnErrorDot {
margin-top: 5px;
}
.turnErrorCopy {
min-width: 0;
overflow-wrap: anywhere;
}
.turnErrorTitle {
margin-right: 6px;
color: var(--dsw-alias-state-error-primary);
font-weight: 600;
}
.turnErrorMessage {
color: var(--dsw-alias-label-secondary);
}
.turnErrorCode {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-markdown-code-block-small);
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;

View File

@@ -6,9 +6,9 @@ import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
UnknownSurfaceNode, UserMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
@@ -17,7 +17,14 @@ import css from './MessageItem.module.css'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
| TurnErrorNode
| UnknownSurfaceNode
loadImage?: ImageLoader
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
@@ -121,6 +128,24 @@ function ModelRetryItem({ node, active, t }: {
</details>
)
}
/** Persistent, turn-positioned feedback for a terminal failure. */
function TurnErrorItem({ node, t }: {
node: TurnErrorNode
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="error" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.turnErrorTitle}>{t('message.turnError')}</span>
<span className={css.turnErrorMessage}>{node.message}</span>
</div>
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -214,6 +239,8 @@ export const MessageItem = memo(function MessageItem({
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error':
return <TurnErrorItem node={node} t={t} />
default:
return (
<div className={css.contextRow}>

View File

@@ -77,6 +77,7 @@ export const zh = {
'message.retry.status': '{label}{retry}/{maximum} · {seconds}s',
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -191,6 +192,7 @@ export const en = {
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',

View File

@@ -22,6 +22,7 @@ export function ConversationRoot({
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -65,8 +66,16 @@ export function ConversationRoot({
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
// Exemption: a session the list summary already proves blank can only
// land on the hero, so hiding would blank the column for the whole
// history round-trip (the startup auto-selection flash) for nothing.
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
&& summaryBlank !== true
const hero = sessionId === undefined
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }

View File

@@ -8,7 +8,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -75,6 +75,11 @@ const retry = (seq: number): ModelRetryNode => ({
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
const turnError = (seq: number, code?: string): TurnErrorNode => ({
kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0,
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
...(code === undefined ? {} : { code }),
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -295,6 +300,16 @@ describe('ChatView', () => {
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
})
it('renders terminal turn failures inline with their durable message and optional code', () => {
const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] })
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'本轮运行失败API key is invalidAUTH',
'本轮运行失败plugin exploded',
])
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],

View File

@@ -80,16 +80,25 @@ function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover = false,
options: {
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover?: boolean
/** The session list summary's `blank` flag — independent of the snapshot's. */
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready',
})
@@ -171,7 +180,7 @@ function mount(
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
options.overlayTakeover === true
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
@@ -233,7 +242,7 @@ describe('ConversationRoot resident composer', () => {
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true })
const seat = b.view.container.querySelector('[data-composer-seat]')
const takeover = b.view.getByTestId('composer-takeover')
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
@@ -274,6 +283,39 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ omitSummaryRow: true },
)
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
})
it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ summaryBlank: true },
)
// The summary already proves the outcome, so the settling hide would only
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')

View File

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

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. 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

View File

@@ -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。
## 模型体验

View File

@@ -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>
</>
)}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
)

View File

@@ -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[] = []

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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 = [
{

View File

@@ -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(

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/README.md
README.md: 3c3644adce23c12db37241bf797ea614d273a0fb
README.zh.md: 260a92154ecd33cb127391af5ded399a2bc20038
README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28
README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a human command adapter. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
| `command-compact/` | Human `/compact` command over the backend-independent `compactNow()` seam | (registers on `ctx.commands`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, deterministic pruning at `compact/compact-tool-result-prune/`, and the command at `compact/command-compact/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, command, or automatic callers.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
一个压缩compaction能力家族见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及暂缓实现的面向模型消费方。这些全是**产品**包package
一个压缩compaction能力家族见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包package
| 包 | 职责 | ctx key |
|---|---|---|
| `compact/` | 抽象压缩 seam接口 + `compact/*` 事件 + `CompactionResult` | `ctx.compact` |
| `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + 按 token 预算保留内容 + `llm.stream()` 摘要生成 | (注册 `ctx.compact` |
| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` |
| `tool-compact/`(暂缓) | 面向模型`/compact` 工具,基于 `ctx.compact` | (注册到 `ctx.tools` |
| `command-compact/` | 面向用户`/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands` |
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`。与 bash seam 不同,该接口依赖 `dsh-session``dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM大语言模型家族服务基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器调用方。
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session``dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM大语言模型家族服务基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/command-compact/README.md
README.md: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4
README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb

View File

@@ -0,0 +1,66 @@
# @deepseek-ai/dsh-command-compact
English | [中文](README.zh.md)
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
## Command contract
| Input | Result |
|---|---|
| `/compact` | Summarize one useful balanced older span even below automatic pressure, then report the replaced history-item count and estimated tokens after the standalone bracket is flushed. |
| `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. |
| `/compact <anything>` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. |
The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history.
Expected `ManualCompactionError` codes become stable direct errors:
| Code | Direct result |
|---|---|
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error. Plugin disposal first unregisters `/compact`, then drains every handler that already started, so root teardown cannot pass an aborted command's close or flush boundary.
Prompts submitted while compaction runs remain accepted in the agent's ordinary FIFO with the same identity and wakeup facts. They start only after the compaction's explicit durability checkpoint and admission release. Idle injected context is not held: it may be logged between `compact/start` and `compact/end`, and positional replacement leaves it visible after the checkpoint.
## Composition
The producer injects `commands` and `compact`. Mount the command registry, one backend, and this plugin:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
```
The TUI example and CLI host mount it beside `compact-basic`. Automation surfaces that compose no command registry keep automatic compaction only.
## Model Experience
### Human `/compact` control
#### What the model sees
The slash input and direct result never enter a model request. An accepted compaction separately replaces an older span with the backend's user-role checkpoint inside a standalone `compact/* { turn: null }` bracket.
#### Token effect
The command lifecycle adds no model tokens. A successful compaction reduces later requests by replacing the selected span with one framed summary; summarization itself is one auxiliary request.
#### KV Cache effect
Discovery and command bookkeeping do not affect the cache. The accepted surface replacement invalidates reuse from the first shadowed history token.
## Known Limitations and Deferred Work
- **Idle-only** — `/compact` reports `busy` when a turn or already accepted waking prompt has right of way; the command itself is not queued.
- **No range or policy arguments** — the argument-free form keeps behavior stable across command adapters. Explicit ranges remain the programmatic `compactRegion()` path.
- **Command adapters only** — surfaces without `ctx.commands` cannot invoke it and rely on automatic pressure compaction.

View File

@@ -0,0 +1,66 @@
# @deepseek-ai/dsh-command-compact
[English](README.md) | 中文
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩compaction控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
## 命令契约
| 输入 | 结果 |
|---|---|
| `/compact` | 即使未达到自动压力,也摘要一段有效、平衡的较早范围;独立标记对 flush 后,报告被替换的历史项数量与估算 token 数。 |
| `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 |
| `/compact <anything>` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 |
该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent智能体就是操作的确切目标发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。
预期的 `ManualCompactionError` 代码会成为稳定的直接错误:
| 代码 | 直接结果 |
|---|---|
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权后端会完成必需的闭合flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。插件处置会先注销 `/compact`,再等待所有已开始的处理器结算,因此根级 teardown 不会越过已中止命令的闭合或 flush 边界。
压缩运行期间提交的提示词仍会按 agent 的普通 FIFO 获得接纳,保留相同的身份与唤醒信息。它们仅在压缩的显式持久性检查点和接纳预留释放后启动。空闲注入的上下文不受阻塞:它可以记录在 `compact/start``compact/end` 之间,位置替换会使其在检查点之后保持可见。
## 组合
生产方注入 `commands``compact`。挂载命令注册表、一个后端与本插件:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
```
TUI 示例与 CLI host 将它挂载在 `compact-basic` 旁。未组合命令注册表的自动化接口只保留自动压缩。
## 模型体验
### 用户 `/compact` 控制
#### 模型看到什么
斜杠输入与直接结果绝不会进入模型请求。已获接纳的压缩会另外在独立的 `compact/* { turn: null }` 标记对内,用后端的 user 角色检查点替换一段较早范围。
#### Token 影响
命令生命周期不会增加模型 token。成功压缩会用一份带框架的摘要替换所选范围从而减少后续请求摘要生成本身需要一次辅助请求。
#### KV Cache 影响
命令发现与簿记不会影响缓存。已获接纳的 surface 替换会从第一个被遮蔽的历史 token 起使复用失效。
## 已知限制与暂缓事项
- **仅限空闲状态**:当一个轮次或已获接纳的唤醒提示词拥有优先权时,`/compact` 会报告 `busy`;命令本身不会排队。
- **不接受范围或策略参数**:无参数形式使各命令适配器的行为保持稳定。显式范围仍由编程接口 `compactRegion()` 处理。
- **仅限命令适配器**:没有 `ctx.commands` 的接口无法调用该命令,只能依赖自动压力压缩。

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-command-compact",
"description": "Human-facing slash command for explicit session compaction",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,103 @@
/**
* Human-facing `/compact` command over the backend-independent compaction seam.
* @module @deepseek-ai/dsh-command-compact
*/
import type { Context } from 'cordis'
import { ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
export const name = 'command-compact'
export const inject = ['commands', 'compact']
const USAGE = 'Usage: /compact (no arguments)'
/** Fail loudly if a locally closed union gains an unhandled member. */
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
function assertNever(value: never): never {
throw new TypeError(`unknown manual compaction error code: ${String(value)}`)
}
/* v8 ignore stop */
/** Convert expected capability failures into concise human-only outcomes. */
function expectedFailure(error: ManualCompactionError): CommandResult {
switch (error.code) {
case 'busy':
return {
kind: 'error',
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
}
case 'changed':
return {
kind: 'error',
text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
}
case 'summary':
return {
kind: 'error',
text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
}
case 'commit':
return {
kind: 'error',
text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
}
case 'persistence':
return {
kind: 'error',
text: 'Compaction finished, but the session could not be saved.',
}
/* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */
default: return assertNever(error.code)
}
}
/** Execute one argument-free manual compaction request. */
async function executeCompact(
ctx: Context,
invocation: CommandInvocation,
): Promise<CommandResult> {
if (invocation.rawInput.trim().length > 0) {
return { kind: 'error', text: USAGE }
}
try {
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal)
if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
return {
kind: 'success',
text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
}
} catch (error: unknown) {
if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }
if (error instanceof ManualCompactionError) return expectedFailure(error)
throw error
}
}
/**
* Register `/compact` for every composed human-command adapter.
* @param ctx - context carrying the command registry and the compaction seam.
*/
export function apply(ctx: Context): void {
const active = new Set<Promise<CommandResult>>()
const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
const operation = executeCompact(ctx, invocation)
active.add(operation)
const retire = (): void => { active.delete(operation) }
// Both branches retire without rethrowing, so the derived observer promise
// cannot become an unhandled mirror of an expected handler rejection.
void operation.then(retire, retire)
return operation
}
ctx.effect(function* () {
// Yield drain before registration: composite teardown is LIFO, so no new
// invocation can enter while already-started handler promises quiesce.
yield async () => { await Promise.allSettled(active) }
yield ctx.commands.register({
name: 'compact',
description: 'Compact older conversation history',
handler,
})
}, 'command-compact lifecycle')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-command-compact`.
* @module @deepseek-ai/dsh-command-compact/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact'
/** Cordis companion plugin name. */
export const name = 'command-compact-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this command adapter owns no state or event stream; the compaction seam owns
* the balanced durable transaction and the command registry owns registration and dispatch lifecycle.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import {
CompactService,
ManualCompactionError,
type CompactAgentContext,
type CompactionResult,
type CompactionTrigger,
type ManualCompactAgentContext,
} from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
const RESULT: CompactionResult = {
startSeq: 10,
summarySeq: 11,
endSeq: 13,
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: 1, end: 7 },
shadowedSeqs: [1, 3, 7],
shadowedTokenCount: 42,
}
class StubCompactService extends CompactService {
result: CompactionResult | null = RESULT
failure: unknown
operation: (() => Promise<CompactionResult | null>) | undefined
calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = []
override compactIfNeeded(
_agent: CompactAgentContext,
_trigger: CompactionTrigger,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(null)
}
override compactRegion(): Promise<CompactionResult> {
return Promise.resolve(RESULT)
}
override compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.calls.push({ agent, signal })
if (this.operation !== undefined) return this.operation()
return this.failure === undefined
? Promise.resolve(this.result)
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
: Promise.reject(this.failure)
}
}
interface Harness {
readonly ctx: Context
readonly compact: StubCompactService
readonly agent: Agent
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
const compact = new StubCompactService(ctx)
const plugin = await ctx.plugin(commandCompact)
const session = new Session(SessionId('command-compact'))
const agent = {
session,
status: 'idle',
options: {},
reserveTurnAdmission: () => () => undefined,
} as unknown as Agent
return { ctx, compact, agent, plugin }
}
async function run(
test: Harness,
suffix = '',
controller = new AbortController(),
): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal)
if (execution === undefined) throw new Error('compact command was not registered')
return execution
}
/** Assert the executor-owned lifecycle pair and absence from model history. */
function expectLastLifecycle(
test: Harness,
args: string,
outcome: { readonly kind: 'success' | 'error'; readonly text?: string },
): string {
const lifecycle = test.agent.session.events.slice(-2)
const runEvent = lifecycle[0]
const doneEvent = lifecycle[1]
if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`)
}
expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([
{
type: 'command/run',
data: {
commandId: runEvent.data.commandId,
name: 'compact',
args,
source: { kind: 'user' },
},
},
{
type: 'command/done',
data: {
commandId: runEvent.data.commandId,
...outcome,
},
},
])
expect(doneEvent.data.commandId).toBe(runEvent.data.commandId)
expect(test.agent.session.surface.nodes).toEqual([])
expect(test.agent.session.deriveMessages()).toEqual([])
return runEvent.data.commandId
}
describe('@deepseek-ai/dsh-command-compact registration', () => {
it('registers one argument-free command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandCompact.name).toBe('command-compact')
expect(commandCompact.inject).toEqual(['commands', 'compact'])
expect('default' in commandCompact).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'compact',
description: 'Compact older conversation history',
})
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
})
})
describe('/compact human command', () => {
it('reports success with useful accounting and forwards the exact target and signal', async () => {
const test = await harness()
const controller = new AbortController()
const execution = await run(test, '', controller)
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~42 tokens).',
})
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])
})
it('returns direct no-history and argument-rejection results', async () => {
const test = await harness()
test.compact.result = null
const empty = await run(test)
expect(empty.result).toEqual({
kind: 'success',
text: 'No compactable history yet.',
})
expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result))
const rejected = await run(test, ' now')
expect(rejected.result).toEqual({
kind: 'error',
text: 'Usage: /compact (no arguments)',
})
expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result))
expect(test.compact.calls).toHaveLength(1)
})
it.each([
['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
['persistence', 'Compaction finished, but the session could not be saved.'],
] as const)('maps expected %s failures to direct errors', async (code, text) => {
const test = await harness()
test.compact.failure = new ManualCompactionError(code, 'backend detail')
const execution = await run(test)
expect(execution.result).toEqual({ kind: 'error', text })
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
})
it('preserves cancellation and unexpected implementation failures', async () => {
const cancelled = await harness()
const controller = new AbortController()
const abort = new Error('operator cancelled')
cancelled.compact.operation = () => {
controller.abort(abort)
return Promise.reject(new ManualCompactionError('summary', 'late failure'))
}
await expect(run(cancelled, '', controller)).rejects.toBe(abort)
expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message })
const unexpected = await harness()
const bug = new Error('unexpected backend bug')
unexpected.compact.failure = bug
await expect(run(unexpected)).rejects.toBe(bug)
expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
})
it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
const test = await harness()
const controller = new AbortController()
const abort = new Error('operator cancelled')
const started = Promise.withResolvers<undefined>()
const allowClose = Promise.withResolvers<undefined>()
const closed = Promise.withResolvers<undefined>()
const allowFlush = Promise.withResolvers<undefined>()
const flushed = Promise.withResolvers<undefined>()
test.compact.operation = async () => {
started.resolve(undefined)
await allowClose.promise
closed.resolve(undefined)
await allowFlush.promise
flushed.resolve(undefined)
throw abort
}
const execution = run(test, '', controller)
await started.promise
controller.abort(abort)
await expect(execution).rejects.toBe(abort)
let disposed = false
const disposal = test.plugin.dispose()
void disposal.then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
expect(disposed).toBe(false)
allowClose.resolve(undefined)
await closed.promise
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
allowFlush.resolve(undefined)
await flushed.promise
await disposal
expect(disposed).toBe(true)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import * as invariant from '@deepseek-ai/dsh-command-compact/invariant'
describe('command-compact invariant companion', () => {
it('registers the package-owned no-op installer', async () => {
const register = vi.fn().mockReturnValue(() => {})
const ctx = { invariants: { register } } as never
const dispose = await invariant.apply(ctx)
expect(invariant.name).toBe('command-compact-invariant')
expect(invariant.inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-command-compact', expect.any(Function))
expect(() => {
const install = register.mock.calls[0]![1] as () => void
install()
}).not.toThrow()
expect(dispose).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,134 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import {
CompactService,
type CompactAgentContext,
type CompactionResult,
type CompactionTrigger,
type ManualCompactAgentContext,
} from '@deepseek-ai/dsh-compact'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
const RESULT: CompactionResult = {
startSeq: 1,
summarySeq: 2,
endSeq: 4,
summary: [{ type: 'text', text: 'loader summary' }],
shadowedRange: { start: 3, end: 8 },
shadowedSeqs: [3, 5, 8],
shadowedTokenCount: 99,
}
class LoaderCompactService extends CompactService {
override compactIfNeeded(
_agent: CompactAgentContext,
_trigger: CompactionTrigger,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(null)
}
override compactRegion(): Promise<CompactionResult> {
return Promise.resolve(RESULT)
}
override compactNow(
_agent: ManualCompactAgentContext,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(RESULT)
}
}
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('command-compact real Loader composition', () => {
it('discovers and executes /compact through the assembled command plane', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-command-compact-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@test/compact-backend'",
"- name: '@deepseek-ai/dsh-command-compact'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-commands', CommandService],
['@test/compact-backend', LoaderCompactService],
['@deepseek-ai/dsh-command-compact', commandCompact],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const session = new Session(SessionId('loader-command-compact'))
const agent = {
session,
status: 'idle',
options: {},
reserveTurnAdmission: () => () => undefined,
} as unknown as Agent
expect(context.commands.list(agent)).toContainEqual({
name: 'compact',
description: 'Compact older conversation history',
})
const execution = await context.commands.execute(agent, '/compact', new AbortController().signal)
if (execution === undefined) throw new Error('Loader composition did not resolve /compact')
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
})
expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([
{
type: 'command/run',
data: {
commandId: execution.commandId,
name: 'compact',
args: '',
source: { kind: 'user' },
},
},
{
type: 'command/done',
data: {
commandId: execution.commandId,
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
},
},
])
expect(session.surface.nodes).toEqual([])
expect(session.deriveMessages()).toEqual([])
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../ui/commands"
},
{
"path": "../compact"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
README.md: 397d984676add2dd504f22fbc532184e6542ac61
README.zh.md: 3e8a2192e22dd0ab36c7b2d827e600677d21e89e
README.md: 22ee4c00df8ab9f52ebe86bbda540b912dfe16e2
README.zh.md: 555d3ce7792e2978cf83ee44bea748578d3558d1

View File

@@ -17,11 +17,11 @@ This backend owns the compaction policy:
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress.
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)
@@ -46,21 +46,25 @@ An adapter may return no capacity for a valid dynamic route, and resolved capaci
## Usage
`BasicCompactService` requires `ctx.llm`, `ctx.tokenMeter`, and `ctx.sessions`. The composition below receives `ctx.llm` from its host and installs the other two services:
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
export const name = 'compact-basic'
export const inject = ['llm', 'tokenMeter']
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(SessionStore)
ctx.plugin(TokenMeterService)
ctx.plugin(BasicCompactService)
}
```
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure. The sibling [`dsh-command-compact`](../command-compact/README.md) calls `ctx.compact.compactNow(...)`; programmatic callers may also use any seam operation directly.
For example, the same compact plugin can safely serve models with different capacities and one target-specific policy:

View File

@@ -17,11 +17,11 @@
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息包括图片引用并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`但不会触碰模型可见的请求体。只有返回的文本会进入检查点推理reasoning和工具调用都会被排除以免泄露私有推理或产生遗留调用图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
- **生命周期**`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作
- **生命周期**所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文flush 每次已闭合尝试,并在 `finally` 中释放接纳预留
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**未配对的 `compact/start`不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权。
- **失败处理**活动的未匹配 `compact/start`持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子会将摘要块与它使用的调用 envelope 一并返回`{ summary, provider, model, maxTokens? }`,并记录`compact/summary` 上。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`;事务会`compact/summary`保留这些字段
## 配置(`BasicCompactConfig`
@@ -46,21 +46,25 @@
## 用法
`BasicCompactService` 需要 `ctx.llm``ctx.tokenMeter``ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务:
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
export const name = 'compact-basic'
export const inject = ['llm', 'tokenMeter']
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(SessionStore)
ctx.plugin(TokenMeterService)
ctx.plugin(BasicCompactService)
}
```
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)` `ctx.compact.compactRegion(...)`
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩。同级 [`dsh-command-compact`](../command-compact/README.md) 调用 `ctx.compact.compactNow(...)`;编程调用方也可以直接使用任一 seam 操作
例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略:

View File

@@ -6,11 +6,12 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { CompactService } from '@deepseek-ai/dsh-compact'
import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -20,9 +21,13 @@ import {
resolveTargetPolicy,
TargetPressureConfigError,
} from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import {
assertNoActiveCompaction,
compactSurfaceRegion,
selectCompactableRange,
} from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
import type { SummarizationInput } from './summarizer.ts'
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
import type {
BasicCompactConfig,
ModelCompactPolicyConfig,
@@ -39,6 +44,9 @@ export type {
ResolvedTargetPolicy,
} from './types.ts'
/** The region transaction's view of this service's dynamically dispatched summarizer. */
type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise<SummaryResult>
/** Resolve the exact provider/model durably routed for the latest request. */
function routedTarget(
session: Session,
@@ -92,7 +100,7 @@ const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
* token meter.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm', 'tokenMeter']
static inject = ['llm', 'tokenMeter', 'sessions']
static Config: z<BasicCompactConfig> = z.object({
thresholdRatio: thresholdRatioSchema,
@@ -235,7 +243,7 @@ export class BasicCompactService extends CompactService {
input: SummarizationInput,
agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
): Promise<SummaryResult> {
const target = conversationTarget(agent)
const config = target === undefined
? this.config
@@ -289,6 +297,7 @@ export class BasicCompactService extends CompactService {
}
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
assertNoActiveCompaction(agent.session, 'automatic pressure compaction')
const targetKey = `${target.provider}/${target.model}`
if (context === undefined) {
throw new TargetPressureConfigError(
@@ -343,11 +352,67 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
const session = agent.session
return compactSurfaceRegion({
return compactSurfaceRegion(
this.regionDependencies(),
agent.session,
start,
end,
agent,
{ owner: 'current-turn', stability: 'whole-surface' },
signal,
)
}
/**
* Force one useful idle-session compaction below the pressure threshold, and
* resolve only after its standalone marker pair is durably checkpointed.
* @param agent - idle agent whose next-turn admission this call reserves.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the committed result, or `null` when no safe useful range exists.
*/
override async compactNow(
agent: Agent,
signal: AbortSignal,
): Promise<CompactionResult | null> {
signal.throwIfAborted()
const releaseTurnAdmission = agent.reserveTurnAdmission()
if (releaseTurnAdmission === undefined) {
throw new ManualCompactionError(
'busy',
'manual compaction requires an idle agent with no waking queued work',
)
}
try {
const range = selectCompactableRange(
agent.session,
this.ctx.tokenMeter.measure(agent.session),
0,
)
if (range === null) return null
return await compactSurfaceRegion(
this.regionDependencies(),
agent.session,
range.start,
range.end,
agent,
{
owner: null,
stability: 'selected-span',
flush: () => this.ctx.sessions.flush(agent.session),
},
signal,
)
} finally {
releaseTurnAdmission()
}
}
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
private regionDependencies(): { meter: TokenMeterService; summarize: RegionSummarize } {
return {
meter: this.ctx.tokenMeter,
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
}, session, start, end, agent, signal)
}
}
}

View File

@@ -1,5 +1,6 @@
/**
* Surface retention selection and the log-recorded compaction transaction.
* Surface retention selection and the shared log-recorded compaction
* transaction for automatic open-turn and manual idle-session compaction.
*
* @module @deepseek-ai/dsh-compact-basic/region
*/
@@ -7,12 +8,13 @@
import { isDeepStrictEqual } from 'node:util'
import {
COMPACT_CHECKPOINT_SOURCE,
ManualCompactionError,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -24,6 +26,62 @@ interface RegionDependencies {
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
}
/** One validated inclusive span of current surface positions. */
interface SurfaceSelection {
readonly start: number
readonly end: number
readonly startIdx: number
readonly endIdx: number
readonly shadowedSeqs: readonly number[]
}
/** A selection with its priced snapshot and the replay input built from it. */
interface PreparedCompaction extends SurfaceSelection {
readonly measurement: TokenMeasurement
readonly selectedNodes: TokenMeasurement['nodes']
readonly shadowedTokenCount: number
readonly input: SummarizationInput
}
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
readonly checkpointMessage: UserMessage
}
interface CompactionTransactionOptions {
/** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
readonly owner: 'current-turn' | null
/** Surface relationship that must survive asynchronous summarization. */
readonly stability: 'whole-surface' | 'selected-span'
/** Optional durability checkpoint after a successfully closed bracket. */
readonly flush?: () => Promise<void>
}
interface CompactionEntryState {
readonly openTurn: number | null
readonly unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
readonly latestEndSeedSeq: number | undefined
}
/**
* Rejects a summary whose replacement boundaries are no longer the ones it was
* built from, distinguished from summarizer and shrink failures so a manual
* caller can report the two causes differently.
*/
class SurfaceChangedError extends Error {}
/** Whether the summary may still replace the span it was built from. */
type StabilityCheck = (
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
) => void
/** Failure captured after `compact/start` has committed. */
interface TransactionFailure {
readonly error: unknown
readonly stage: 'summary' | 'commit'
}
/**
* Resolve the next head-anchored range while retaining a priced recent tail
* and never splitting an assistant tool-call/result pair.
@@ -71,12 +129,18 @@ export function selectCompactableRange(
}
/**
* Validate and compact one positional surface span.
* Run the single compaction transaction over one selected positional span.
* Selection and validation are read-only. Idle/log validation and
* `compact/start` are synchronously adjacent, so the durable opening marker is
* the compaction lock before summarization yields. Every later failure makes
* exactly one `compact/end` attempt; a failed close deliberately leaves the
* unmatched start detectable.
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
* @param session - session whose surface is mutated.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer.
* @param options - bracket owner, stability rule, and optional durability checkpoint.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -86,8 +150,151 @@ export async function compactSurfaceRegion(
start: number,
end: number,
agent: Agent,
options: CompactionTransactionOptions,
signal?: AbortSignal,
): Promise<CompactionResult> {
if (options.owner === null) signal?.throwIfAborted()
const selection = validateSurfaceRegion(session, start, end)
const entryState = inspectCompactionEntryState(session.events)
assertCompactionInactive(
entryState.unmatchedCompactionStart,
entryState.latestEndSeedSeq,
'compaction',
)
let owner: number | null
if (options.owner === null) {
if (entryState.openTurn !== null) {
throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
}
owner = null
} else {
if (entryState.openTurn === null) {
throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
}
owner = entryState.openTurn
}
const startEvent = session.append('compact/start', { turn: owner })
const assertStable: StabilityCheck = options.stability === 'whole-surface'
? assertWholeSurfaceUnchanged
: assertSelectedSpanStable
let failure: TransactionFailure | undefined
let flushFailure: unknown
let result: CompactionResult | undefined
let closed = false
let closing = false
let stage: TransactionFailure['stage'] = 'summary'
try {
const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, signal)
if (options.owner === null) signal?.throwIfAborted()
assertStable(dependencies, session, summarized)
stage = 'commit'
const pending = commitCompactionBody(session, startEvent, summarized)
closing = true
const endEvent = session.append('compact/end', { turn: owner })
closed = true
result = completeCompaction(pending, endEvent)
} catch (error: unknown) {
failure = { error, stage: closing ? 'commit' : stage }
if (!closing) {
closing = true
try {
session.append('compact/end', { turn: owner, error: errorChain(error) })
closed = true
} catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' }
}
}
}
if (closed && options.flush !== undefined) {
try {
await options.flush()
} catch (error: unknown) {
flushFailure = error
}
}
if (options.owner === null) signal?.throwIfAborted()
if (failure !== undefined) {
if (options.owner === null) throwManualFailure(failure)
throw failure.error
}
if (flushFailure !== undefined) {
throw new ManualCompactionError(
'persistence',
'manual compaction durability checkpoint failed',
{ cause: flushFailure },
)
}
/* v8 ignore next -- every path without a result records and throws a failure above. */
if (result === undefined) throw new Error('compaction committed without a result')
return result
}
/** Classify one closed manual attempt without weakening cancellation precedence. */
function throwManualFailure(failure: TransactionFailure): never {
if (failure.stage === 'commit') {
throw new ManualCompactionError(
'commit',
'manual compaction did not commit cleanly',
{ cause: failure.error },
)
}
if (failure.error instanceof SurfaceChangedError) {
throw new ManualCompactionError(
'changed',
'the compacted history changed during manual compaction',
{ cause: failure.error },
)
}
throw new ManualCompactionError(
'summary',
'manual compaction could not produce a smaller summary',
{ cause: failure.error },
)
}
/**
* Reject a durable unmatched compaction marker unless a later constructor-seed
* boundary proves that its owner belongs to an earlier session lifecycle.
* @param unmatchedCompactionStart - latest unmatched opening marker, if any.
* @param latestEndSeedSeq - newest constructor-seed boundary, if any.
* @param stage - operation label included in the busy diagnostic.
*/
function assertCompactionInactive(
unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined,
latestEndSeedSeq: number | undefined,
stage: string,
): void {
if (unmatchedCompactionStart === undefined
|| (latestEndSeedSeq !== undefined
&& latestEndSeedSeq > unmatchedCompactionStart.seq)) return
throw new ManualCompactionError(
'busy',
`${stage}: compaction already in progress; the session compaction lock is already active`,
)
}
/**
* Recheck the durable compaction lock after an asynchronous policy decision.
* @param session - session whose latest marker state is inspected.
* @param stage - operation label included in the busy diagnostic.
*/
export function assertNoActiveCompaction(session: Session, stage: string): void {
const entryState = inspectCompactionEntryState(session.events)
assertCompactionInactive(
entryState.unmatchedCompactionStart,
entryState.latestEndSeedSeq,
stage,
)
}
/** Validate one requested surface-position span before asynchronous work begins. */
function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection {
const nodes = session.surface.nodes
const startIdx = nodes.indexOf(start)
const endIdx = nodes.indexOf(end)
@@ -107,75 +314,145 @@ export async function compactSurfaceRegion(
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
const tail = inspectTurnTail(session.events)
if (tail.compactionInProgress) throw new Error('compaction already in progress')
if (tail.turn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
}
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
const startEvent = session.append('compact/start', { turn: tail.turn })
/** Snapshot pricing and replay input for a validated surface range. */
function prepareCompaction(
dependencies: RegionDependencies,
session: Session,
selection: SurfaceSelection,
): PreparedCompaction {
const measurement = dependencies.meter.measure(session)
const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
if (selectedNodes.length !== selection.shadowedSeqs.length
|| selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
}
return {
...selection,
measurement,
selectedNodes,
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
input: buildSummarizationInput(session, selection.shadowedSeqs),
}
}
/** Run the summarizer and frame its replacement checkpoint. */
async function summarizeCompaction(
dependencies: RegionDependencies,
prepared: PreparedCompaction,
agent: Agent,
signal?: AbortSignal,
): Promise<SummarizedCompaction> {
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
const checkpointMessage = createUserMessage({
content: frameSummary(summaryResult.summary),
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
)
}
return {
...prepared,
...summaryResult,
checkpointMessage,
}
}
/** Reject a summary prepared against any earlier surface generation. */
function assertWholeSurfaceUnchanged(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
const current = dependencies.meter.measure(session)
if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
throw new SurfaceChangedError('compaction: session surface changed during summarization')
}
}
/**
* Require only that the selected span remain the same present, contiguous,
* equally priced, balanced replacement target. Nodes added outside it remain
* visible and do not invalidate the summary.
*/
function assertSelectedSpanStable(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
let current: SurfaceSelection
try {
// Capture after the lock event so a later surface mutation invalidates the
// async selection before replacement. Unrelated log-only facts may append.
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
throw new Error('compaction: selected surface changed before summarization began')
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
const {
summary, rawOutput, provider, model, maxTokens, usage,
} = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
throw new Error('compaction: session surface changed during summarization')
}
const framedSummary = frameSummary(summary)
const checkpointMessage = createUserMessage({
content: framedSummary,
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
)
}
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: tail.turn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
current = validateSurfaceRegion(session, prepared.start, prepared.end)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn: tail.turn, error: message })
throw error
throw new SurfaceChangedError(
'compaction: the selected span is no longer a valid replacement target',
{ cause: error },
)
}
if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
throw new SurfaceChangedError('compaction: the selected span changed during summarization')
}
const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
}
}
/** Append one already-summarized provenance and replacement body without yielding. */
function commitCompactionBody(
session: Session,
startEvent: SessionEvent<'compact/start'>,
summarized: SummarizedCompaction,
): Omit<CompactionResult, 'endSeq'> {
const {
start,
end,
shadowedSeqs,
shadowedTokenCount,
summary,
rawOutput,
provider,
model,
maxTokens,
usage,
checkpointMessage,
} = summarized
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
}
}
/** Attach the successfully appended close event to a pending result. */
function completeCompaction(
pending: Omit<CompactionResult, 'endSeq'>,
endEvent: SessionEvent<'compact/end'>,
): CompactionResult {
return { ...pending, endSeq: endEvent.seq }
}
/**
@@ -206,25 +483,38 @@ function buildSummarizationInput(
}
}
/** Inspect the current turn boundary and latest compaction bracket once. */
function inspectTurnTail(
events: readonly SessionEvent[],
): { turn: number | null; compactionInProgress: boolean } {
let compactionInProgress = false
let compactionStateKnown = false
/** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState {
let openTurn: number | null = null
let openTurnStateKnown = false
let unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
let compactionEntryStateKnown = false
let latestEndSeedSeq: number | undefined
for (let index = events.length - 1; index >= 0; index -= 1) {
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (!compactionStateKnown) {
if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
latestEndSeedSeq = event.seq
}
if (!compactionEntryStateKnown) {
if (event.type === 'compact/start') {
compactionInProgress = true
compactionStateKnown = true
unmatchedCompactionStart = event
compactionEntryStateKnown = true
} else if (event.type === 'compact/end') {
compactionStateKnown = true
compactionEntryStateKnown = true
}
}
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
if (!openTurnStateKnown) {
if (event.type === 'turn/start') {
openTurn = event.data.turn
openTurnStateKnown = true
} else if (event.type === 'turn/end') {
openTurnStateKnown = true
}
}
if (openTurnStateKnown
&& compactionEntryStateKnown
&& latestEndSeedSeq !== undefined) break
}
return { turn: null, compactionInProgress }
return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
}

View File

@@ -22,7 +22,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -1832,6 +1832,7 @@ describe('automatic listener and loader composition', () => {
it('loads and disposes the real zero-config service stack', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
const meterFiber = await ctx.plugin(TokenMeterService)
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
@@ -55,6 +57,7 @@ describe('real Loader composition', () => {
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-token-meter'",
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
' config:',
@@ -91,6 +94,7 @@ describe('real Loader composition', () => {
it('rejects stale compact-basic config after Schemastery normalization', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
models: { legacy: { thresholdRatio: 0.5 } },
@@ -100,6 +104,7 @@ describe('real Loader composition', () => {
it('rejects a capacity-independent merged ratio conflict during plugin load', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
retainRatio: 0.2,
@@ -114,6 +119,7 @@ describe('real Loader composition', () => {
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
summarizationProvider: 'default-provider',

View File

@@ -0,0 +1,831 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import {
createAssistantMessage,
createUserMessage,
LlmAdapter,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmResolvedModelInfo,
Message,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
SummarizationInput,
SummaryResult,
} from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
const MODEL = 'mock'
const SIGNAL = new AbortController().signal
const PROMPT = 'older conversation history '.repeat(60)
/** A summarizer under test control: it can block, fail, or mutate mid-call. */
class GatedCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'checkpoint' }]
rawOutput: ContentBlock[] | undefined
usage: TokenUsage | undefined
error: unknown
gate: Promise<undefined> | undefined
duringSummary: (() => void) | undefined
calls: SummarizationInput[] = []
override async summarize(
input: SummarizationInput,
_agent: Agent,
_signal?: AbortSignal,
): Promise<SummaryResult> {
this.calls.push(input)
this.duringSummary?.()
if (this.gate !== undefined) await this.gate
if (this.error !== undefined) throw this.error
return {
summary: this.summary,
...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
provider: 'summary-provider',
model: 'summary-model',
...this.usage === undefined ? {} : { usage: this.usage },
}
}
}
/** One text answer per request, with a context window large enough to avoid pressure. */
class TextAdapter extends LlmAdapter {
readonly requests: Message[][] = []
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 100_000 },
})
}
override async * stream(options: { messages: readonly Message[] }): AsyncIterable<StreamChunk> {
this.requests.push([...options.messages])
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
interface LoopHarness {
readonly ctx: Context
readonly agent: Agent
readonly compact: GatedCompactService
readonly adapter: TextAdapter
readonly log: string[]
}
/** Real loop, session store, and invariant companions around manual compaction. */
async function loopHarness(): Promise<LoopHarness> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
await ctx.plugin(CompactInvariant)
await ctx.plugin(CompactBasicInvariant)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
const adapter = new TextAdapter()
ctx.llm.registerAdapter([MODEL], adapter)
const compact = new GatedCompactService(ctx, { auto: false })
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
const log: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
if (event.type === 'turn/end') log.push('turn/end')
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
if (event.type === 'compact/summary') log.push('compact/summary')
if (event.type === 'compact/end') log.push(`compact/end:${String(event.data.turn)}`)
if (event.type === 'user/message') log.push('user/message')
})
ctx.on('session/flush', () => { log.push('flush') })
return { ctx, agent, compact, adapter, log }
}
/** Drive one real turn so the closed history holds a compactable older span. */
async function seedHistory(harness: LoopHarness): Promise<void> {
harness.agent.followup(createUserMessage({
content: [{ type: 'text', text: PROMPT }],
source: { kind: 'user' },
}))
await harness.agent.whenIdle()
harness.log.length = 0
}
/** Text of every derived model-visible message, in request order. */
function derivedText(session: Session): string[] {
return session.deriveMessages().map((message: Message) => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
}
/** Await one classified manual-compaction rejection. */
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
const caught: unknown = await operation.then(
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
(error: unknown) => error,
)
if (!(caught instanceof ManualCompactionError)) {
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
}
return caught
}
/** The Error a classified failure wraps. */
function causeOf(error: ManualCompactionError): Error {
const { cause } = error
if (!(cause instanceof Error)) throw new Error(`expected an Error cause, got ${String(cause)}`)
return cause
}
function deferred(): { promise: Promise<undefined>; resolve: () => void } {
const { promise, resolve } = Promise.withResolvers<undefined>()
return { promise, resolve: () => { resolve(undefined) } }
}
/** A closed-tail session with compactable exchanges and no live agent. */
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
for (let index = 1; index <= turns; index += 1) {
const turn = index === turns ? lastTurnNumber : index
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn, step: 1 })
if (index === 1) {
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: `answer ${turn}` }],
source: { provider: MODEL, model: MODEL },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return session
}
/** A fake idle agent whose admission reservation is scripted per test. */
function fakeAgent(
session: Session,
reserve: () => (() => void) | undefined,
): Agent {
return {
session,
options: { provider: MODEL, model: MODEL },
reserveTurnAdmission: reserve,
} as unknown as Agent
}
/** Service over a store-detached session for failure classification. */
function detachedService(): { ctx: Context; compact: GatedCompactService; flushes: () => number } {
const ctx = new Context()
void new LlmService(ctx)
void new SessionStore(ctx)
void new TokenMeterService(ctx)
ctx.llm.registerAdapter([MODEL], new TextAdapter())
let flushes = 0
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
flushes += 1
return Promise.resolve()
})
return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
}
function compactEvents(session: Session): Array<Session['events'][number]> {
return session.events.filter(event => event.type.startsWith('compact/'))
}
describe('compactNow through the real loop', () => {
it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => {
const harness = await loopHarness()
const { agent, compact, adapter, log } = harness
await seedHistory(harness)
const gate = deferred()
compact.gate = gate.promise
const running = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
expect(log).toEqual(['compact/start:null'])
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'after compaction' }],
source: { kind: 'user' },
}))
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(1)
expect(log).toEqual(['compact/start:null'])
gate.resolve()
const result = await running
expect(result).not.toBeNull()
await agent.whenIdle()
const start = log.indexOf('compact/start:null')
const summary = log.indexOf('compact/summary')
const end = log.indexOf('compact/end:null')
const flush = log.indexOf('flush')
const nextTurn = log.indexOf('turn/start:message')
expect(start).toBeLessThan(summary)
expect(summary).toBeLessThan(end)
expect(end).toBeLessThan(flush)
expect(flush).toBeLessThan(nextTurn)
expect(adapter.requests).toHaveLength(2)
const second = (adapter.requests[1] ?? []).map(message => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
expect(second[0]).toContain('checkpoint')
expect(second.at(-1)).toBe('after compaction')
expect(second.some(text => text.includes(PROMPT))).toBe(false)
})
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
const harness = await loopHarness()
const { agent, compact } = harness
await seedHistory(harness)
compact.duringSummary = () => {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'INJECTED CONTEXT' }],
source: { kind: 'plugin', plugin: 'test' },
}))
}
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
const start = agent.session.events.findLast(event => event.type === 'compact/start')
const injected = agent.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
const end = agent.session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(injected).toBeDefined()
expect(end).toBeDefined()
expect(start!.seq).toBeLessThan(injected!.seq)
expect(injected!.seq).toBeLessThan(end!.seq)
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
const messages = derivedText(agent.session)
expect(messages[0]).toContain('checkpoint')
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
})
it('keeps the marker order when listeners attempt a re-entrant injection', async () => {
const harness = await loopHarness()
const { ctx, agent, compact } = harness
await seedHistory(harness)
const attempts: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type !== 'compact/start' && event.type !== 'compact/summary') return
attempts.push(event.type)
agent.inject(createUserMessage({
content: [{ type: 'text', text: `from ${event.type}` }],
source: { kind: 'plugin', plugin: 'listener' },
}))
})
const result = await compact.compactNow(agent, SIGNAL)
expect(attempts).toEqual(['compact/start', 'compact/summary'])
expect(result).not.toBeNull()
expect(derivedText(agent.session)[0]).toContain('checkpoint')
expect(agent.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
const types = compactEvents(agent.session).map(event => event.type)
expect(types).toEqual(['compact/start', 'compact/summary', 'compact/end'])
})
it('reports busy without summarizing when a prompt already owns the next turn', async () => {
const harness = await loopHarness()
const { agent, compact, adapter } = harness
await seedHistory(harness)
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'first in line' }],
source: { kind: 'user' },
}))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'compact/start')).toBe(false)
})
it('releases turn admission after a summarizer failure and records the failed attempt', async () => {
const harness = await loopHarness()
const { agent, compact, adapter } = harness
await seedHistory(harness)
compact.error = new Error('summarizer unavailable')
const before = [...agent.session.surface.nodes]
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('summary')
expect(agent.session.surface.nodes).toEqual(before)
const markers = compactEvents(agent.session)
expect(markers.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(markers[1]?.type === 'compact/end' && markers[1].data.error)
.toContain('summarizer unavailable')
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'runs after the failure' }],
source: { kind: 'user' },
}))
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
})
})
describe('compactNow transaction and failure classification', () => {
it('returns null without writing a bracket for history that cannot be compacted', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('empty'))
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
expect(await compact.compactNow(agent, SIGNAL)).toBeNull()
expect(released).toBe(1)
expect(compact.calls).toHaveLength(0)
expect(compactEvents(session)).toEqual([])
})
it('commits a standalone bracket without consuming a turn number and checkpoints durability', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2, 7)
const agent = fakeAgent(session, () => () => undefined)
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
expect(flushes()).toBe(1)
expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
expect(session.events.findLast(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
})
it('reports a live unmatched bracket as busy without summarizing', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('compact/start', { turn: null })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('compaction lock is already active')
expect(compact.calls).toHaveLength(0)
})
it('ignores an unmatched bracket inherited before a later end-seed marker', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
const orphan = reloaded.events.find(event => event.type === 'compact/start')
const agent = fakeAgent(reloaded, () => () => undefined)
expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
expect(compact.calls).toHaveLength(1)
})
it('scans a stale orphan independently of later repaired turn state', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
const agent = fakeAgent(reloaded, () => () => undefined)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
expect(compact.calls).toHaveLength(1)
})
it('refuses an open turn in the log', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('already has an open turn')
})
it('reports busy and skips summarization when admission is unavailable', async () => {
const { compact } = detachedService()
const agent = fakeAgent(closedConversation(2), () => undefined)
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
})
it('rejects a selected span replaced during summarization and records an error close', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
compact.duringSummary = () => {
const [head] = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'competing replacement' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: head!, end: head! },
sourceEventSeqs: [head!],
})
}
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
expect(released).toBe(1)
expect(flushes()).toBe(1)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
})
it('rejects a selected span whose middle node was replaced during summarization', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
const agent = fakeAgent(session, () => () => undefined)
compact.duringSummary = () => {
const middle = session.surface.nodes[1]
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'rewritten middle node' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: middle!, end: middle! },
sourceEventSeqs: [middle!],
})
}
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('changed')
expect(causeOf(error).message).toContain('span changed during summarization')
})
it('revalidates the selected span after the summarizer continuation settles', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
const gate = deferred()
compact.gate = gate.promise
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const head = session.surface.nodes[0]!
const generation = session.surface.replaceGeneration
const running = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
expect(compact.calls).toHaveLength(1)
gate.resolve()
queueMicrotask(() => {
queueMicrotask(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late competing replacement' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: head, end: head },
sourceEventSeqs: [head],
})
})
})
const error = await rejection(running)
expect(error.code).toBe('changed')
expect(causeOf(error).message).toContain('selected span')
expect(released).toBe(1)
expect(flushes()).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation + 1)
expect(session.surface.nodes).not.toContain(head)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(session.events.some(event => event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source))).toBe(false)
})
it('classifies a failing compact/end as commit failure and leaves one orphan', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/end') throw new Error('boundary rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('boundary rejected')
vi.restoreAllMocks()
expect(flushes()).toBe(0)
expect(session.events.findLast(event => event.type.startsWith('compact/'))?.type)
.toBe('compact/summary')
expect(compactEvents(session).filter(event => event.type === 'compact/start')).toHaveLength(1)
const calls = compact.calls.length
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(calls)
})
it('keeps a failed error-close as the commit failure and does not flush', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
compact.error = new Error('summary rejected')
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/end') throw new Error('error boundary rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
vi.restoreAllMocks()
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('error boundary rejected')
expect(released).toBe(1)
expect(flushes()).toBe(0)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start'])
})
it('rejects a selected span whose pricing changed during summarization', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const meter = ctx.tokenMeter
const original = meter.measure.bind(meter)
compact.duringSummary = () => {
vi.spyOn(meter, 'measure').mockImplementationOnce((target) => {
const measurement = original(target)
return {
...measurement,
nodes: measurement.nodes.map((node, index) =>
index === 0 ? { ...node, tokens: node.tokens + 1 } : node),
}
})
}
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
vi.restoreAllMocks()
})
it('classifies a commit-body failure and still releases admission', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
vi.restoreAllMocks()
expect(error.code).toBe('commit')
expect(released).toBe(1)
const end = session.events.findLast(event => event.type === 'compact/end')
expect(end?.type === 'compact/end' && end.data.error).toContain('provenance rejected')
expect(end?.type === 'compact/end' && end.data.turn).toBeNull()
})
it('keeps a commit failure when the durability checkpoint also fails', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('provenance rejected')
vi.restoreAllMocks()
})
it('compacts a session with no durable turn boundary without creating one', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('turnless'))
for (const text of [PROMPT, 'recent tail']) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
const agent = fakeAgent(session, () => () => undefined)
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(session.events.find(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
})
it('classifies a durability failure after the standalone bracket committed', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
vi.restoreAllMocks()
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
})
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
const cases = [
{ name: 'busy', session: closedConversation(2), release: undefined },
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
] as const
for (const testCase of cases) {
const { ctx, compact } = detachedService()
const reserve = vi.fn(() => testCase.release)
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
const agent = fakeAgent(testCase.session, reserve)
const before = [...testCase.session.events]
const reason = Object.freeze({ kind: 'cancelled', case: testCase.name })
const controller = new AbortController()
controller.abort(reason)
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(reserve).not.toHaveBeenCalled()
expect(measure).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
expect(testCase.session.events).toEqual(before)
vi.restoreAllMocks()
}
})
it('preserves the exact cancellation reason when the summarizer also rejects', async () => {
const { compact, flushes } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled by the caller')
let released = 0
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => { released += 1 })
compact.duringSummary = () => { controller.abort(reason) }
compact.error = new Error('summarizer aborted')
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(released).toBe(1)
expect(flushes()).toBe(1)
const events = compactEvents(session)
expect(events.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(events[1]?.type === 'compact/end' && events[1].data.error)
.toContain('summarizer aborted')
})
it('aborts before committing when cancellation lands after summarization', async () => {
const { compact } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled by the caller')
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.duringSummary = () => { controller.abort(reason) }
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})
it('waits for the durability checkpoint before cancellation wins and admission releases', async () => {
const { ctx, compact } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled during flush')
const flushGate = Promise.withResolvers<undefined>()
const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const running = compact.compactNow(agent, controller.signal)
let settled = false
void running.then(
() => { settled = true },
() => { settled = true },
)
await vi.waitFor(() => {
expect(flush).toHaveBeenCalledWith(session)
})
controller.abort(reason)
await Promise.resolve()
expect(settled).toBe(false)
expect(released).toBe(0)
flushGate.resolve(undefined)
await expect(running).rejects.toBe(reason)
expect(released).toBe(1)
})
it('preserves raw output and usage in the manual summary event', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.rawOutput = [
{ type: 'text', text: 'checkpoint' },
{ type: 'reasoning', text: 'hidden reasoning' },
]
compact.usage = { inputTokens: 40, outputTokens: 5 }
await compact.compactNow(agent, SIGNAL)
const summary = session.events.find(event => event.type === 'compact/summary')
expect(summary?.type === 'compact/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
expect(summary?.type === 'compact/summary' && summary.data.usage).toEqual(compact.usage)
})
it('makes duration derivable from the opening and closing marker times', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.gate = new Promise<undefined>((resolve) => {
setTimeout(() => { resolve(undefined) }, 5)
})
await compact.compactNow(agent, SIGNAL)
const start = session.events.findLast(event => event.type === 'compact/start')
const end = session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(end).toBeDefined()
expect(end!.time - start!.time).toBeGreaterThan(0)
})
it('excludes concurrent automatic and manual compaction of one session', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise
const manual = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
const nodes = session.surface.nodes
await expect(compact.compactRegion(
nodes[0]!,
nodes[1]!,
agent,
)).rejects.toThrow('compaction lock is already active')
gate.resolve()
compact.gate = undefined
const result: CompactionResult | null = await manual
expect(result).not.toBeNull()
})
it('excludes a manual request while an explicit region compaction runs', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise
const nodes = session.surface.nodes
const region = compact.compactRegion(nodes[0]!, nodes[1]!, agent)
await Promise.resolve()
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
gate.resolve()
compact.gate = undefined
await expect(region).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 2) })
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact/README.md
README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2
README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52
README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839

View File

@@ -10,22 +10,25 @@ This package is the interface tier of the compaction capability, split so each c
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers |
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
| `@deepseek-ai/dsh-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compact`)
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
## Tool-pairing boundaries
@@ -45,11 +48,15 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
## Blocking
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
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.
## Events
@@ -57,7 +64,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
@@ -81,6 +88,6 @@ A successful backend replacement invalidates reuse from the first shadowed histo
## Known Limitations and Deferred Work
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.

View File

@@ -10,22 +10,25 @@
|---|---|
| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型`/compact` 工具,基于 `ctx.compact` 实现 |
| `@deepseek-ai/dsh-command-compact` | 面向用户`/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
## 服务 API`ctx.compact`
两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
| 成员 | 语义 |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 |
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq`agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start``end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
`compactIfNeeded` 必须传入 `signal``compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose资源释放 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前尚未结束的轮次恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值
`compactIfNeeded``compactNow` 必须传入 `signal``compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose资源释放会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`
`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence``changed``summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。
## 工具配对边界
@@ -45,11 +48,15 @@
表层变更(第 4 步)位于锁的起止范围**内**`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start``compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`
这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span而不要求整个表层相等位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。
`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。
## 阻塞
压缩通过日志记录锁串行化`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁由日志记录(而非内存 mutex因此回放后仍然有效持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会覆盖**整个**操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `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从而在释放轮次接纳预留前保留该记录。
## 事件
@@ -57,7 +64,7 @@
## 实现后端
继承 `CompactService`,实现 `compactIfNeeded``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
继承 `CompactService`,实现 `compactIfNeeded``compactNow``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`
@@ -81,6 +88,6 @@
## 已知限制与暂缓事项
- **尚无面向模型的消费方层**`@deepseek-ai/dsh-tool-compact``/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩
- **面向用户的命令,而非模型工具**`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具
- **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。

View File

@@ -21,12 +21,47 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Expected failure classes for an explicit idle-session compaction request. */
export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence'
/**
* Expected manual-compaction failure suitable for a direct human-command result.
* Shared durable-lock entry assertions may also throw the `busy` subtype from
* automatic compaction paths.
*/
export class ManualCompactionError extends Error {
override readonly name = 'ManualCompactionError'
/**
* Create one classified compaction failure.
* @param code - stable failure class; `busy` may originate from any compaction entry path.
* @param message - backend diagnostic retained as the Error message.
* @param options - optional original failure.
*/
constructor(
readonly code: ManualCompactionErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
session: Session
options: { provider?: string; model?: string }
}
/**
* Agent capability required to serialize an explicit idle-session compaction
* against driver turns. The durable `compact/start` marker separately excludes
* other compaction transactions.
*/
export interface ManualCompactAgentContext extends CompactAgentContext {
reserveTurnAdmission(): (() => void) | undefined
}
declare module 'cordis' {
interface Context {
compact: CompactService
@@ -65,6 +100,29 @@ export abstract class CompactService extends Service {
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Explicitly compact useful history even below automatic pressure thresholds.
* Implementations reserve idle turn admission synchronously before any
* asynchronous work, select a useful range without writing on a no-op, then
* append a standalone `compact/start` before summarization. That durable
* marker is the compaction lock until one `compact/end` attempt. Later waking
* prompts remain accepted in FIFO order and start only after the optional
* durability checkpoint and admission release. Context injected while the
* summary runs may sit between the marker pair; only the selected span must
* remain stable.
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, changed-span,
* summarization/shrink, commit-stage, or persistence failures, and the exact
* abort reason when cancelled. Failed attempts remain visible in the log.
*/
abstract compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq

View File

@@ -13,7 +13,8 @@ export const name = 'compact-invariant'
export const inject = ['invariants']
interface CompactionTrace {
turn: number
startSeq: number
turn: number | null
summarized: boolean
}
@@ -23,9 +24,73 @@ interface SessionTrace {
}
type CompactionTransition =
| { kind: 'start'; turn: number }
| { kind: 'summary'; turn: number }
| { 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,
openTurn: number | null,
eventType: 'compact/start' | 'compact/summary' | 'compact/end',
fail: InvariantFailure,
): void {
if (owner === null) {
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`)
return
}
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
}
/** Validate one compaction event without advancing committed trace state. */
function validateCompactionEvent(
@@ -33,23 +98,22 @@ function validateCompactionEvent(
event: SessionEvent,
fail: InvariantFailure,
): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
return undefined
}
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`)
const open = trace.compaction
if (event.type === 'compact/start') {
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
if (event.data.turn !== trace.openTurn) {
fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
if (open !== undefined) {
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
fail(`compact/start while ${owner} is still compacting`)
}
return { kind: 'start', turn: event.data.turn }
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
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')
if (open.turn !== trace.openTurn) {
fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compact/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
@@ -59,15 +123,13 @@ 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) {
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
}
if (event.data.turn !== trace.openTurn) {
fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (event.data.error === undefined && !open.summarized) {
fail('successful compact/end requires one compact/summary')
}
@@ -78,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
}
@@ -92,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
}
@@ -106,15 +189,12 @@ 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
}
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
validateTurnBoundary(trace, event, fail)
if (applyTurnBoundary(trace, event)) return
if (event.type !== 'session/end-seed'
&& event.type !== 'compact/start'
&& event.type !== 'compact/summary'
&& event.type !== 'compact/end') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every compaction event */
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
@@ -124,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'] })

View File

@@ -11,8 +11,12 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
'compact/start': { turn: number }
/**
* Marks the start of a compaction — log-only, holds the lock until
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compact/start': { turn: number | null }
/**
* Provenance record of a completed summarization — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
@@ -40,8 +44,11 @@ declare module '@deepseek-ai/dsh-session' {
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
}
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
'compact/end': { turn: number; error?: string }
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
*/
'compact/end': { turn: number | null; error?: string }
}
}

View File

@@ -9,6 +9,7 @@ import {
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
/**
* A trivial concrete CompactService implementing the abstract contract. The
@@ -29,6 +30,14 @@ class StubCompactService extends CompactService {
return null
}
override async compactNow(
_agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
return null
}
override async compactRegion(
start: number,
end: number,
@@ -98,6 +107,12 @@ describe('CompactService seam', () => {
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
const signal = new AbortController().signal
expect(await svc.compactNow({
...stubAgent(session),
reserveTurnAdmission: () => () => undefined,
}, signal)).toBeNull()
expect(svc.lastSignal).toBe(signal)
})
it('compact/* events merge into SessionEventMap and are log-only', async () => {

View File

@@ -41,6 +41,103 @@ describe('compaction invariants', () => {
failed.append('compact/end', { turn: 2, error: 'provider failed' })
})
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
const ctx = await setup()
const success = ctx.sessions.create()
success.append('compact/start', { turn: null })
success.append('compact/summary', summary())
success.append('compact/end', { turn: null })
const failed = ctx.sessions.create()
failed.append('compact/start', { turn: null })
failed.append('compact/end', { turn: null, error: 'provider failed' })
})
it('clears an inherited open compaction trace at end-seed during replay', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-compaction-source'))
source.append('compact/start', { turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['compact/start', 'session/end-seed'])
await ctx.plugin(InvariantService)
await ctx.plugin(CompactInvariant)
expect(() => {
replayed.append('compact/start', { turn: null })
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
}).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)
@@ -78,6 +175,26 @@ describe('compaction invariants', () => {
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
})
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
const ctx = await setup()
const open = ctx.sessions.create()
startTurn(open)
expect(() => open.append('compact/start', { turn: null }))
.toThrow(/standalone but turn 1 is open/)
const idle = ctx.sessions.create()
expect(() => idle.append('compact/start', { turn: 1 }))
.toThrow(/outside any open turn/)
})
it('attributes a nested standalone start to the standalone owner', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
session.append('compact/start', { turn: null })
expect(() => session.append('compact/start', { turn: null }))
.toThrow(/standalone compaction is still compacting/)
})
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -89,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([

View File

@@ -50,6 +50,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2
README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6
README.md: 053206797398aa952522298e82992a7320daf74c
README.zh.md: 439f3e7712b0803b07a9a7e9dd10d9e863876154

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. The shipped TUI mounts it; `dsh-agent-spine-demo` and the Web/headless surfaces do not. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
## Config

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo`随附示例均不挂载。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。已交付的 TUI 会挂载它;`dsh-agent-spine-demo` Web无头界面均不挂载。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
## 配置

View File

@@ -106,6 +106,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session.append('user/message', input, { surfaceOp: 'append' })
},
send: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -185,6 +185,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -276,6 +276,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise<CompactionResult | null>',
jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */',
},
{
signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>',
jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations reserve idle turn admission synchronously before any\n * asynchronous work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and admission release. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - command-owned cancellation forwarded to summarization.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, changed-span,\n * summarization/shrink, commit-stage, or persistence failures, and the exact\n * abort reason when cancelled. Failed attempts remain visible in the log.\n */',
},
{
signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */',
@@ -1579,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
},
{
name: 'AgentCancelCause',
@@ -2097,6 +2101,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmResolvedModelInfo',
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}',
},
{
name: 'ManualCompactAgentContext',
declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: afc00f1ecdd225f22da46b95827259a50c719766
README.zh.md: 63aaab3b5af32b9bad70d74fbd57c8bd62c2ef7d
README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c
README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3

View File

@@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.

View File

@@ -55,7 +55,7 @@ interface Config {
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`

View File

@@ -2,7 +2,8 @@
* Concrete Agent loop over two pending-input lists: queued prompts each open a
* turn that logs its admitted input after `turn/start` commits, while steering
* and injected context enter through the outbox at step boundaries. Every
* request is derived from the session log.
* request is derived from the session log. An idle turn-admission reservation
* can withhold the driver from the queue without touching its contents.
*
* @module dsh-agent-loop/agent
*/
@@ -119,6 +120,12 @@ export class ReactLoopAgent implements Agent {
private busy = false
/** Whether an idle waking send has deferred driver admission. */
private wakeScheduled = false
/**
* The live idle turn-admission reservation, holding the driver out of the
* queue until its owner releases. It settles idle waiters instead of
* {@link done} so lifecycle teardown never awaits the reserving operation.
*/
private admission: { readonly settled: Promise<void>; readonly settle: () => void } | undefined
/** Whether next-step input belongs to the current admission or open turn. */
acceptsNextStep = false
/** Abort owner for the current admission or turn. */
@@ -243,6 +250,32 @@ export class ReactLoopAgent implements Agent {
})
}
/**
* Hold the idle admission boundary so no queued prompt can open a turn until
* the returned release runs. Later sends keep their ordinary placement and
* `wakeup` facts; only the driver's claim waits.
* @returns the idempotent release, or `undefined` when the driver is active or already committed to waking work.
*/
reserveTurnAdmission(): (() => void) | undefined {
// `busy` covers every abort owner: kick() and run() mark the interval
// running before they install one. `wakeScheduled` is the same-tick state
// of an accepted waking prompt whose claim is still a pending microtask.
if (this.busy || this.wakeScheduled || this.admission !== undefined
|| this.queued.some(item => item.wakeup)) return undefined
const pending = Promise.withResolvers<void>()
const reservation = { settled: pending.promise, settle: pending.resolve }
this.admission = reservation
return () => {
// Idempotent, and inert once a later reservation owns the boundary.
if (this.admission !== reservation) return
this.admission = undefined
// Re-arm the ordinary path first, so an idle waiter released below
// re-reads live admission activity instead of settled state.
if (this.queued.some(item => item.wakeup)) this.scheduleKick()
reservation.settle()
}
}
/**
* Clear all pending work and abort the active turn; the first cause wins.
* The cause is signal payload for observers and the durable turn/end
@@ -276,19 +309,34 @@ export class ReactLoopAgent implements Agent {
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> {
// `done` is replaced per activity, so re-reading it follows chained turns.
// Every driver failure today is contained before it can reject `done`,
// but the waiter must not gamble quiescence on that: a future escape
// still counts as settled activity.
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) {
await this.done.catch(() => undefined)
while (true) {
// `done` is replaced per activity, so re-reading it follows chained turns.
// Every driver failure today is contained before it can reject `done`,
// but the waiter must not gamble quiescence on that: a future escape
// still counts as settled activity.
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.runnableWakingQueued) {
await this.done.catch(() => undefined)
}
// A reservation is unfinished activity even with an empty queue, and a
// prompt it withholds is not quiescent — but `done` never owns it, so
// waiting on the queue alone would spin on an already-settled promise.
const reservation = this.admission
if (reservation === undefined) return
await reservation.settled
}
}
/** Whether a queued waking prompt may claim the driver now. */
private get runnableWakingQueued(): boolean {
return this.admission === undefined && this.queued.some(item => item.wakeup)
}
/** Defer idle admission while keeping {@link done} as its quiescence owner. */
private scheduleKick(): void {
if (this.abort !== undefined || this.wakeScheduled) return
// A held reservation keeps the item queued with no scheduled claim; its
// release re-arms this path for whatever is queued by then.
if (this.abort !== undefined || this.wakeScheduled || this.admission !== undefined) return
this.wakeScheduled = true
const pending = Promise.withResolvers<void>()
const scheduled = pending.promise
@@ -310,7 +358,7 @@ export class ReactLoopAgent implements Agent {
/** Claim and admit the next queued prompt, then start its turn. */
private kick(): void {
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
if (this.abort !== undefined || !this.runnableWakingQueued) return
// The some() guard above proves the queue is non-empty; the non-null
// assertion expresses that invariant.
// oxlint-disable-next-line typescript/no-non-null-assertion
@@ -837,7 +885,7 @@ export class ReactLoopAgent implements Agent {
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.queued.some(item => item.wakeup)) {
if (this.runnableWakingQueued) {
this.kick()
} else {
// Every caller sits inside an admission or run whose install marked the

View File

@@ -0,0 +1,286 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function prompt(agent: Agent, text: string): void {
agent.followup(createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}))
}
function itemText(item: InboxItem): string {
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
}
interface InboxRecording {
readonly events: string[]
readonly enqueued: InboxItem['id'][]
readonly dequeued: InboxItem['id'][]
readonly discarded: InboxItem['id'][]
}
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
function recordInbox(ctx: Context): InboxRecording {
const events: string[] = []
const enqueued: InboxItem['id'][] = []
const dequeued: InboxItem['id'][] = []
const discarded: InboxItem['id'][] = []
ctx.on('agent/inbox/enqueue', (_agent, item) => {
events.push(`enqueue:${item.placement}:${itemText(item)}`)
enqueued.push(item.id)
})
ctx.on('agent/inbox/dequeue', (_agent, item) => {
events.push(`dequeue:${itemText(item)}`)
dequeued.push(item.id)
})
ctx.on('agent/inbox/discard', (_agent, items) => {
events.push(`discard:${items.map(itemText).join(',')}`)
discarded.push(...items.map(item => item.id))
})
return { events, enqueued, dequeued, discarded }
}
/** Text of every ordinary prompt the log admitted, in durable order. */
function promptTexts(agent: Agent): string[] {
return agent.session.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('idle turn admission reservation', () => {
it('holds later waking prompts in the FIFO until release', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
prompt(agent, 'first prompt')
prompt(agent, 'second prompt')
expect(agent.acceptsNextStep).toBe(false)
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
])
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
expect(agent.session.events.flatMap(event =>
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
'dequeue:first prompt',
'dequeue:second prompt',
])
expect(inbox.dequeued).toEqual(inbox.enqueued)
expect(inbox.discarded).toEqual([])
})
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
prompt(agent, 'accepted first')
expect(agent.status).toBe('idle')
expect(agent.reserveTurnAdmission()).toBeUndefined()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('refuses acquisition while a turn is running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reserved: unknown[] = []
ctx.on('agent/step', () => {
reserved.push(agent.reserveTurnAdmission())
})
prompt(agent, 'running')
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(reserved).toEqual([undefined])
})
it('refuses a second reservation and releases idempotently', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
expect(agent.reserveTurnAdmission()).toBeUndefined()
prompt(agent, 'queued behind the reservation')
release?.()
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
expect(adapter.requests).toHaveLength(1)
const second = agent.reserveTurnAdmission()
expect(second).toBeDefined()
second?.()
})
it('ignores a stale release once a later reservation owns the boundary', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const stale = agent.reserveTurnAdmission()
stale?.()
const live = agent.reserveTurnAdmission()
prompt(agent, 'held by the live reservation')
stale?.()
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(adapter.requests).toHaveLength(0)
live?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('acquires beside quiet queued work and leaves it queued', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({
content: [{ type: 'text', text: 'quiet' }],
source: { kind: 'user' },
}), {
target: 'next-turn',
wakeup: false,
})
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
})
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const machine = agent as Agent & { done: Promise<void> }
let backing = machine.done
let reads = 0
Object.defineProperty(agent, 'done', {
configurable: true,
get(): Promise<void> {
reads += 1
return backing
},
set(value: Promise<void>) {
backing = value
},
})
const release = agent.reserveTurnAdmission()
prompt(agent, 'waiting for the reservation')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
for (let tick = 0; tick < 5; tick += 1) {
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
}
expect(settled).toBe(false)
expect(reads).toBeLessThanOrEqual(2)
release?.()
await idle
expect(settled).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('resolves whenIdle() after release with nothing queued', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(settled).toBe(false)
release?.()
await idle
expect(agent.status).toBe('idle')
})
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded while held')
agent.cancel({ kind: 'user' })
expect(inbox.events).toEqual([
'enqueue:queued:discarded while held',
'discard:discarded while held',
])
expect(inbox.discarded).toEqual(inbox.enqueued)
expect(inbox.dequeued).toEqual([])
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
})
it('disposes the agent without waiting for the reservation to be released', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('a1'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded by disposal')
await handle.dispose()
expect(ctx.agents.list()).toEqual([])
expect(adapter.requests).toHaveLength(0)
release?.()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 0b55381ee484eb0044b1ebb88cfd137a987a9b3e
README.zh.md: 9fb0100fb2c212627b8c14dad0aada0f73d11c10
README.md: 8799bc3664b2137b386b752f905e1414fb770cb9
README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd

View File

@@ -61,6 +61,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.

View File

@@ -61,6 +61,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
每个插件面向的 handle
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target``wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId``agent/inbox/enqueue``update` 及终态 `dequeue``discard` 都会携带这一完整 `InboxItem``target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'``wakeup: true` 提交 steering中途引导`target: 'next-step'``wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩compaction等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId``InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`
- `agent.followup(input)``send()``next-turn`wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
- `agent.steer(input)``next-step`wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering以供重试或之后获准的提示词使用而取消或 dispose 可能丢弃它。

View File

@@ -178,6 +178,20 @@ export interface Agent {
*/
send(message: UserMessage, options: SendOptions): void
/**
* Reserve admission of the next ordinary turn while this agent is idle, so an
* operation can mutate durable history before any queued prompt derives a
* request from it. Already-accepted waking work has right of way, including a
* send whose wake is still a pending microtask. Later sends keep their
* ordinary placement, FIFO order, and `wakeup` facts, and
* {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes
* a queued follow-up rather than steering; cancellation and disposal may
* still discard them. {@link inject} is not withheld. {@link whenIdle} treats
* a live reservation as activity, while lifecycle teardown does not await it.
* @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work.
*/
reserveTurnAdmission(): (() => void) | undefined
/**
* Mutate one still-pending queued occurrence synchronously. Editing preserves
* the message identity and queue position; removal publishes its terminal

View File

@@ -28,6 +28,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}

View File

@@ -40,6 +40,7 @@ function agent(ctx: Context, cwd: string): Agent {
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -42,6 +42,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
followup: () => {},
steer: () => {},
inject(input) { appendInjection(session, input) },
reserveTurnAdmission: () => undefined,
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}

View File

@@ -55,6 +55,7 @@ function stubAgentForSession(session: Session): StubAgent {
if (shouldDefer) deferred.push(input)
else appendInjection(session, input)
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}

View File

@@ -45,6 +45,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
inject(input: UserMessage) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}

View File

@@ -39,6 +39,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}

View File

@@ -52,6 +52,7 @@ function stubAgent(session: Session): Agent {
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent {
options: {},
session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
@@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()

View File

@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
}

View File

@@ -33,6 +33,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -50,6 +50,7 @@ function agent(ctx: Context, cwd: string): Agent {
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -46,6 +46,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value

View File

@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md
README.md: d973daf1124c4be05f7335b18661d431d45be39f
README.zh.md: b27d79a905a029d3750f24573e3c32785a314015
README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c
README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default.
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not.
## Configuration

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包package只依赖统一接口并注册 `session_search``session_event_search``session_trace``session_event_trace``session_event_read`;已发布的宿主组合默认不挂载
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包package只依赖统一接口并注册 `session_search``session_event_search``session_trace``session_event_trace``session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACPAgent Client Protocol不挂载。
## 配置

View File

@@ -53,6 +53,7 @@ function agentForCwd(cwd: string): Agent {
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -73,6 +74,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -30,6 +30,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
inject: () => {},
send: () => {},
updateInbox: (): 'not-found' => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}

View File

@@ -172,9 +172,10 @@ describe('typert loader', () => {
expect(ctx.typert.get('@fixture/late#Late')).toBeUndefined()
await ctx.loader.create({ name: '@fixture/late' })
await ctx.loader.await()
// The microtask flush and the dynamic import need a turn to settle.
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.get('@fixture/late#Late')).toBeDefined()
// Contributor import settles after Loader's own await boundary.
await vi.waitFor(() => {
expect(ctx.typert.get('@fixture/late#Late')).toBeDefined()
}, { timeout: 10_000 })
})
it('drops an in-flight manifest when the loader is disposed before import settles', LOADER_TEST_TIMEOUT, async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 14d09f61062942d8b8783d8af184d72220b63b67
README.zh.md: 83a46f4cf1cc1a95c9d8edee1d7829f978f74ff5
README.md: 3496f263caad00f5abd8b3bab268e5469a51001b
README.zh.md: 9bf808f5b6b3e91b75e0dc5b2b7659ca3c38023a

View File

@@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.

View File

@@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`并在分派前准备所选快照。会话引用保持结构化因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()``agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help``/model``/clear``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具它显示该阶段已经过时间和运行中的步骤总数每秒刷新并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉而上下文卡片保持预览因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘Ctrl+D 在空闲时退出。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help``/model``/clear``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具它显示该阶段已经过时间和运行中的步骤总数每秒刷新并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩compaction标记对处于开启状态期间提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉而上下文卡片保持预览因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集并在高亮行仍通过过滤时保持其选中状态Up/Down 移动Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度Enter 选择模型和推理强度Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}``{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。

View File

@@ -1,8 +1,8 @@
/**
* Per-step timing model and running-status glyph animation for the terminal
* Per-step timing model and prompt-status glyph animation for the terminal
* front door. Timing buckets are replayed from the session event stream; the
* running glyph fades in on turn start, throbs while the turn runs, and fades
* out on turn end.
* active glyph fades in when work starts, throbs while work runs, and fades out
* when it ends.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
@@ -10,25 +10,25 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Palette } from '../components/theme.ts'
/**
* Render cadence of the running prompt while active, and while the glyph fades
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
* Render cadence of the status prompt while active, and while the glyph fades
* out after work ends. ~20 fps so the truecolor glyph fade reads smoothly;
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
* changed terminal cells are re-emitted, so the faster tick stays cheap.
*/
export const STATUS_ANIMATION_INTERVAL_MS = 50
/**
* Milliseconds over which the running glyph fades in when a turn starts and
* fades out after it ends. The fade is an envelope over the running pulse:
* Milliseconds over which the status glyph fades in when work starts and fades
* out after it ends. The fade is an envelope over the active pulse:
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
*/
export const STATUS_FADE_MS = 300
/** Milliseconds for one full brightness throb of the running glyph. */
/** Milliseconds for one full brightness throb of the active status glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* Brightness floor of the status throb, as a fraction of the settled gray. At
* 0 the pulse swells from the near-background trough up to full and back. The
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
* cosine breathes symmetrically bold→dim→bold.
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* Muted-gray foreground the truecolor status glyph fades through, from the
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
* appearing rather than a colored indicator. Foreground-only, matching the
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
tools: '⚙',
}
/** Status glyph for a live standalone compaction bracket. */
const COMPACTING_GLYPH = '⊙'
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
}
/**
* The running agent's phase glyph, or `undefined` when idle. A running turn
* with no open step falls back to the pre-first-token wait so a glyph is always
* available while the agent works; it fades in on turn start, throbs while the
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
* The active status glyph, or `undefined` when idle. A running turn takes
* precedence over standalone compaction and falls back to the pre-first-token
* wait when no step is open. The caller applies the shared fade and throb
* animation (see {@link fadeGlyph}).
* @param events - Session events to derive the phase from.
* @param running - Whether the agent is currently running.
* @returns The phase glyph, or `undefined` when idle.
* @param compacting - Whether a live standalone compaction bracket is open.
* @returns The active status glyph, or `undefined` when idle.
*/
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
if (!running) return undefined
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
export function runningPhaseGlyph(
events: readonly SessionEvent[],
running: boolean,
compacting: boolean,
): string | undefined {
if (running) {
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
return compacting ? COMPACTING_GLYPH : undefined
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* The status throb's brightness at continuous clock `nowMs`: a cosine between
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
* fade envelope, which alone drives appear/disappear at turn boundaries.
* fade envelope, which alone drives appear/disappear at work boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* One frame of the status glyph at fade `opacity` (0 = near-background trough
* gray, 1 = settled dim gray). The character and its width never change — only
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
* the caret dimly breathing, never a colored indicator.
*
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
* and the running throb render as a smooth, symmetric brightness swing with no
* and the status throb render as a smooth, symmetric brightness swing with no
* hard cutoff to clip the trough into a blank. Without truecolor there is no
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
* shows the glyph in the palette's muted role or leaves a blank column — a
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
* no throb-driven blink. With color off entirely a visible glyph is bare,
* holding the caret column on a monochrome terminal.
*
* @param glyph - The phase glyph to paint.
* @param glyph - The status glyph to paint.
* @param palette - Active palette supplying the muted (dim gray) role.
* @param colorEnabled - Whether ANSI is emitted at all.
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.

Some files were not shown because too many files have changed in this diff Show More