Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix

This commit is contained in:
creatixchu
2026-07-31 17:32:52 +08:00
254 changed files with 6697 additions and 3883 deletions

View File

@@ -15,6 +15,7 @@ import type {
AssistantMessage,
ContentBlock,
MessageSource,
TokenUsage,
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
@@ -327,6 +328,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
inputTokens: 20 + turn % 5,
outputTokens: 8 + step,
cacheReadTokens: turn === 0 ? 0 : 80,
cacheWriteTokens: turn % 10 === 0 ? 4 : 0,
}
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
function buildAlphaLog(): SessionEvent[] {
@@ -334,7 +345,17 @@ function buildAlphaLog(): SessionEvent[] {
let time = Date.now() - 3_600_000
const push = (e: Record<string, unknown>): number => {
const seq = events.length
events.push({ seq, time: (time += 800), ...e })
const data = e['data'] as Record<string, unknown> | undefined
const authored = e['type'] === 'assistant/message' && data !== undefined
? {
...e,
data: {
...data,
usage: fixtureUsage(data['turn'] as number, data['step'] as number),
},
}
: e
events.push({ seq, time: (time += 800), ...authored })
return seq
}
for (let turn = 0; turn < 60; turn++) {
@@ -727,6 +748,113 @@ function permissionSelectOf(
}
}
interface FixtureTokenUsageProjection {
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
interface FixtureUsageSample {
turn: number
step: number
usage: TokenUsage
}
/** Read one provider usage sample from either durable carrier. */
function usageSampleOf(event: SessionEvent): FixtureUsageSample | undefined {
const item = event as unknown as {
type: string
data: {
turn?: number
step?: number
usage?: TokenUsage
chunk?: { type?: string; usage?: TokenUsage }
}
}
const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
? item.data.chunk.usage
: item.type === 'assistant/message'
? item.data.usage
: undefined
return usage === undefined || item.data.turn === undefined || item.data.step === undefined
? undefined
: { turn: item.data.turn, step: item.data.step, usage }
}
/** Fixture parallel of token-meter's last-sample-replacing usage projection. */
function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection {
const totals: FixtureTokenUsageProjection = {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
let last: {
turn: number
step: number
buckets: FixtureTokenUsageProjection
} | null = null
for (const event of log) {
const sample = usageSampleOf(event)
if (sample === undefined) continue
const buckets: FixtureTokenUsageProjection = {
uncachedInputTokens: sample.usage.inputTokens,
outputTokens: sample.usage.outputTokens,
cacheReadTokens: sample.usage.cacheReadTokens ?? 0,
cacheWriteTokens: sample.usage.cacheWriteTokens ?? 0,
}
const previous = last?.turn === sample.turn && last.step === sample.step
? last.buckets
: undefined
totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0)
totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0)
totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0)
totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0)
last = { turn: sample.turn, step: sample.step, buckets }
}
return totals
}
interface FixtureRequestContext {
provider: string
model: string
contextWindow?: number
}
/** Latest log-only route context, or undefined before any request ran. */
function lastRequestContext(
log: readonly SessionEvent[],
): FixtureRequestContext | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'request/context')
return event === undefined
? undefined
: (event as unknown as { data: FixtureRequestContext }).data
}
/**
* Fixture parallel of token-meter's request-pressure projection: the last
* provider-reported prompt size paired with the last recorded capacity. The
* two need not come from one request — see the token-meter README.
*/
function contextPressureOf(
log: readonly SessionEvent[],
): { pressureTokens?: number; contextWindow?: number } {
let pressureTokens: number | undefined
for (const event of log) {
const sample = usageSampleOf(event)
if (sample === undefined) continue
pressureTokens = sample.usage.inputTokens
+ (sample.usage.cacheReadTokens ?? 0)
+ (sample.usage.cacheWriteTokens ?? 0)
}
const contextWindow = lastRequestContext(log)?.contextWindow
return {
...pressureTokens === undefined ? {} : { pressureTokens },
...contextWindow === undefined ? {} : { contextWindow },
}
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
@@ -741,12 +869,32 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
values['goal'] = backscanGoal(log)
// Always present (token-meter composed): full-log provider billing.
values['tokenUsage'] = tokenUsageOf(log)
// Always present (token-meter composed): last request pressure and capacity.
values['contextPressure'] = contextPressureOf(log)
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
// One usage sample advances both token-meter units.
if (usageSampleOf(event) !== undefined) {
return [
{ type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
]
}
if (type === 'request/context') {
return [{
type: 'session/projection',
sessionId: id,
key: 'contextPressure',
value: contextPressureOf(log),
seq: event.seq,
}]
}
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
@@ -1443,7 +1591,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
append(id, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn,
step,
message: assistantMessage(text(aborted ? `${done}(已中断)` : done)),
usage: fixtureUsage(turn, step),
},
})
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
@@ -1712,6 +1869,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
if (lastRequestContext(logOf(id))?.model !== target.model) {
append(id, {
type: 'request/context',
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
})
}
startReply(
id,
turn,

View File

@@ -141,6 +141,14 @@ describe('createFixtureApi', () => {
},
plan: { active: false, pending: false },
goal: null,
tokenUsage: {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
// No request ran, so neither pressure nor capacity is known yet.
contextPressure: {},
} },
})
})
@@ -275,6 +283,17 @@ describe('createFixtureApi', () => {
expect(types).toContain('assistant/chunk')
expect(types).toContain('assistant/message')
expect(types.at(-1)).toBe('turn/end')
// Capacity is durable log state, not a transient frame: the prompt path
// records request/context and the projection carries it to the client.
expect(types).toContain('request/context')
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'tokenUsage'
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'contextPressure'
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -306,7 +325,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 8) abort.abort()
if (envelopes.length >= 10) abort.abort()
}
return envelopes
}
@@ -314,16 +333,18 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
// Projection baseline frames follow subscribed (domain units + token usage).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

View File

@@ -61,23 +61,9 @@ describe('connection node half', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
// The apply throw also escapes cordis as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it so the run
// stays clean, same pattern as the webserver bind-failure test.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
} finally {
process.off('unhandledRejection', onUnhandled)
}
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
})
it('registers the /api prefix route and removes it with the fiber', 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/client/runtime/README.md
README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2
README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2
README.md: e8ba80790307e7123406934c1ab11b86dfc0faf3
README.zh.md: 6eb69d9cf10959b007f3759378612dbc013a8904

View File

@@ -24,9 +24,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
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.
## 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 surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
`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.
## Session title projection

View File

@@ -24,9 +24,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering中途引导不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
## Session 标题投影
@@ -34,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose资源释放会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
## 会话 fork
@@ -50,10 +56,10 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
#### KV Cache 影响
更改目标可能改变提供方侧的缓存复用,或使其失效;该包package本身不会改变提示词前缀。
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR热模块替换项目落地。
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber dispose → 注册级联 → 样式移除)随 HMR热模块替换项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘postmortem所记录的问题。

View File

@@ -32,6 +32,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",

View File

@@ -44,8 +44,9 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {

View File

@@ -156,7 +156,32 @@ export interface ToolResultNode {
resultView: ToolResultView | null
}
/** Fallback for surface events this UI version does not know. */
/**
* One landed compaction, marked at the checkpoint's own log position. The
* conversation it shadowed on the model surface stays in the transcript above
* it: the marker reports where the model stopped seeing that history, it does
* not replace it. The framed checkpoint payload is an instruction envelope
* written for the model and never renders.
*/
export interface CompactionSummaryNode {
kind: 'compaction'
/** Seq of the replacement `user/message` that landed the checkpoint. */
seq: number
/** Unix epoch ms of the checkpoint event. */
time: number
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
summary: string | null
}
/**
* Fallback for surface events this UI version does not know: the documented
* default arm of `SessionEventMap`, which is merge-extensible, so the
* projection's switch cannot end in `assertNever`. No event produces this node
* today — `isAppendSurfaceEvent` admits only the four types in core's
* `SurfaceEventType`, and each has its own arm — and it exists so widening that
* set core-side degrades to a raw row instead of dropping the event silently.
*/
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
@@ -169,7 +194,7 @@ export interface UnknownSurfaceNode {
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* Log-only events are not surface events, so the TranscriptAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
@@ -200,6 +225,7 @@ export type ConversationNode =
| ModelRetryNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode
| UnknownSurfaceNode
/**
@@ -209,7 +235,7 @@ export type ConversationNode =
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the surface `nodes` flow — sub-calls live under their parent via
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
@@ -280,10 +306,8 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Finalized surface events and durable operational notices in event order. */
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**

View File

@@ -1,283 +0,0 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
// the degradation lives in one branch function in this file, zero scattered removal points).
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** Non-surface sentinel used to preserve paged-window sequence offsets.
* `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface behavior; this cast is the only synthetic event entry point.
*/
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
private baseSeq = 0
private surface = new SurfaceManager(this.padded)
private nodeCache = new Map<number, ConversationNode>()
private degraded = false
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so the surface fold never
* emits it; this index folds the pair (done settles its run's node in
* place) and nodes() merges the products into the flow by seq. Window cuts
* soft-fall like tool pairs: a done with no in-window run still builds a
* node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
}
/**
* Window rebuild (after open/resync/page prepend): new padded array, new
* SurfaceManager, cleared cache, rebuilt callIndex.
* @param events - the new window contents (seq-ascending).
* @param baseSeq - seq of the window head (sentinels pad below it).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
}
}
/**
* Tail append (live session/event): push into the same array (incremental
* lazy fold applies) + incremental callIndex upkeep.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}
/**
* Current node array + degradation flag. Same revision -> same array
* reference (memo boundary); node object references always come from the per-seq cache.
* @returns the fold projection for the current window revision.
*/
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
let seqs: readonly number[]
if (this.degraded) {
seqs = this.degradedSeqs()
} else {
try {
seqs = this.surface.nodes
} catch (error) {
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
this.degraded = true
seqs = this.degradedSeqs()
}
}
const out: ConversationNode[] = []
for (const seq of seqs) {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) {
out.push(cached)
continue
}
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
this.nodeCache.set(seq, node)
out.push(node)
}
// Command nodes fold outside the surface (log-only events); merge by seq.
// Both inputs are seq-ascending (surface order and run-index insertion
// order share the log order), so one linear merge keeps flow order.
let nodes = out
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of out) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
for (let i = this.baseSeq; i < this.padded.length; i++) {
const event = this.padded[i]
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
}
return seqs
}
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
private indexCommand(event: SessionEvent): void {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
if ((event.type as string) !== 'command/done') return
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -18,7 +18,7 @@ import type {
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
@@ -90,11 +90,12 @@ export class Session implements SessionFace {
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
* Derived from window events and rebuilt with partial/openCalls; the transcript is
* seq-monotonic, so a plain seq merge preserves event order. */
private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so
@@ -106,7 +107,7 @@ export class Session implements SessionFace {
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
@@ -336,7 +337,7 @@ export class Session implements SessionFace {
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
@@ -569,7 +570,7 @@ export class Session implements SessionFace {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
@@ -584,14 +585,15 @@ export class Session implements SessionFace {
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.foldAdapter.append(event, view)
this.transcript.append(event, view)
this.applyEventSideEffects(event, view)
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
* its continuity assertion into the degraded view). */
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its own provenance. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -833,18 +835,18 @@ export class Session implements SessionFace {
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
// The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
const projected = this.transcript.nodes()
// Derived interruption nodes ride fractional seqs while retry notices keep their event seq.
// The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the
// merge on (projected reference, derivedRev) to retain identity across unrelated swaps.
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.derivedNodes.length === 0
? folded
: [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
? projected
: [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -862,7 +864,6 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,

View File

@@ -0,0 +1,328 @@
// TranscriptAdapter: the human transcript projected from the raw event window
// in LOG order. The model-visible surface deliberately shadows replaced ranges,
// so it is the wrong source for conversation a reader already saw; this adapter
// keeps every append-origin event at its own log position and contributes one
// marker node per landed compaction checkpoint. Node order is therefore
// seq-monotonic by construction — no surface fold, no padding sentinels, no
// seq === index assertion to satisfy, and no degradation branch.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Cordis-free leaf subpath (the dsh-commands/brand shape): the seam's own
// declaration of the checkpoint source, reachable as a TYPE from this program.
// The package ROOT is not — it reaches dsh-session's root, whose Context merge
// declares the HOST `sessions: SessionStore` against this program's
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
* at COMPILE time: renaming it there fails this annotation (`TS2322`). The
* import stays type-only because a value import would fail the client purity
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
* forbidden in a browser bundle — while an erased type never reaches it.
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
*/
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
/** In-window tool/call index entry used to materialize result cards. */
interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node. A compaction
// checkpoint never reaches here (isCompactCheckpoint routes it away).
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
can be append-origin, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
* compaction seam's checkpoint plugin source, that REPLACED a surface range. A
* plugin-sourced `user/message` that appends is injected context (a
* session-reference card), not a compaction; a replacement `tool/result` is an
* in-place prune and a replacement `assistant/message` a generic rewrite, and
* both mark no boundary in the conversation.
* @param event - the raw window event.
* @returns true when the event compacted a surface range.
*/
function isCompactCheckpoint(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN
&& isReplacementSurfaceEvent(event)
}
/** Whether an event contributes a node to the human transcript. */
function isTranscriptEvent(event: SessionEvent): boolean {
return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
}
/**
* Concatenated text of a `compact/summary` payload, or null when it carries no
* usable text. The payload is a `ContentBlock[]` whose union is
* merge-extensible, so a non-text block is skipped rather than discarding the
* text beside it; a payload with no text block at all falls to null through the
* empty check.
*/
function compactSummaryText(event: SessionEvent): string | null {
const summary = (event.data as unknown as { summary?: unknown }).summary
if (!Array.isArray(summary)) return null
let text = ''
for (const block of summary as readonly unknown[]) {
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
text += candidate.text
}
return text.trim() === '' ? null : text
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
* `compact/summary` event), never from the framed checkpoint payload, which is
* an instruction envelope written for the model. A window cut that left the
* provenance outside soft-falls to `summary: null` (a non-expandable marker),
* the same posture as a call-less tool result.
*/
function materializeCompaction(
checkpoint: SessionEvent,
eventIndex: ReadonlyMap<number, SessionEvent>,
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
summary = compactSummaryText(candidate)
break
}
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
export class TranscriptAdapter {
/** Window events by seq: provenance lookup for a checkpoint's summary. */
private eventIndex = new Map<number, SessionEvent>()
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
* event and never joins the transcript projection; this index folds the pair
* (done settles its run's node in place) and nodes() merges the products in
* by seq. Window cuts soft-fall like tool pairs: a done with no in-window
* run still builds a node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Projection revision, bumped only when a transcript node or a command node actually
* changed, keying the nodes() result cache: an unchanged projection returns the previous
* ARRAY reference, not just cached elements — the snapshot's reference-stability contract
* (§A.9.4) starts here, and a chunk storm bumps nothing at all. */
private rev = 0
private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
/**
* Window rebuild (after open/resync/page prepend): re-index the raw window
* and re-project the transcript.
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event === undefined) continue
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
}
this.projected = projected
}
/**
* Tail append (live session/event): index the event and, when it belongs to
* the transcript, extend the projection by one copy-on-write node so a
* published array never mutates. An event that changes no node (a chunk
* storm) bumps no revision, so nodes() keeps returning the same array
* reference.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
this.rev++
}
/**
* The current transcript node array. Same revision -> same array reference
* (memo boundary); node objects are materialized once, so an unchanged node
* keeps its identity across appends.
* @returns transcript nodes in log order, command nodes merged in by seq.
*/
nodes(): readonly ConversationNode[] {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
// Command nodes fold outside the transcript (log-only events); merge by
// seq. Both inputs are seq-ascending (log order and run-index insertion
// order are the same order), so one linear merge keeps flow order.
let nodes = this.projected
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of this.projected) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
this.nodesResult = { rev: this.rev, value: nodes }
return nodes
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
}
/**
* Fold one command lifecycle event into its node (run mints, done settles in
* place; done-only soft-falls).
* @returns whether the command index changed, so callers can bump the revision.
*/
private indexCommand(event: SessionEvent): boolean {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return true
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -0,0 +1,49 @@
/**
* Behavioral half of the compaction-checkpoint drift trap.
*
* `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
* compile time through a type-only import of `dsh-compact/checkpoint`, so
* renaming the seam's plugin already fails `tsc`. This spec covers the same
* drift from the other side — end to end through the adapter, driving it with a
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
* checking the seam's own predicate agrees. Both values come from the
* cordis-free checkpoint leaf, so the client test program never loads the host
* package root or its `Context` merges.
*/
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
/** A replacement user message stamped with the seam's own canonical source. */
function canonicalCheckpoint(seq: number): SessionEvent {
return {
type: 'user/message',
seq,
time: 1_700_000_000_000 + seq,
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}),
} as unknown as SessionEvent
}
describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {
// Both sides answer the same question about the same value: if the seam
// renames its plugin, this equality is what breaks.
const checkpoint = canonicalCheckpoint(1)
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
})
})

View File

@@ -87,6 +87,27 @@ export const ev = {
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
/** A compaction's log-only `compact/summary` provenance record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {
summary: text(summary),
shadowedRange: { start, end },
shadowedSeqs: [start, end],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
} }),
/** The replacement user message a compaction backend lands (the checkpoint). */
compactCheckpoint: (seq: number, summarySeq: number, start: number, end: number): SessionEvent =>
at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [summarySeq, start, end],
data: createUserMessage({
content: text('<context_checkpoint>model only</context_checkpoint>'),
source: { kind: 'plugin', plugin: 'compact' },
}),
}),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

View File

@@ -1,399 +0,0 @@
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
* materialization, call-index backfill, and the degraded linear-scan branch.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
describe('FoldAdapter', () => {
it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
const adapter = new FoldAdapter()
const window = plainTurn(100, 5, '偏移问', '偏移答')
adapter.reset(window, 100)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(false)
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (cache identity)', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
expect(second.nodes[0]).toBe(first.nodes[0])
expect(second.nodes[1]).toBe(first.nodes[1])
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
adapter.reset(events, 0)
const { nodes } = adapter.nodes()
const kinds = nodes.map(n => n.kind)
expect(kinds).toContain('user')
expect(kinds).toContain('assistant')
expect(kinds).toContain('steering')
expect(kinds).toContain('context')
const result = nodes.find(n => n.kind === 'tool-result')
expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
const { nodes } = adapter.nodes()
expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes surface-eligible types it does not know as unknown nodes', () => {
const adapter = new FoldAdapter()
adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
const { nodes } = adapter.nodes()
// Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
// shapes; what matters is no throw and no misclassification into a known kind.
for (const node of nodes) expect(node.kind).toBe('unknown')
})
it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
const adapter = new FoldAdapter()
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: {
kind: 'model',
...{ provider: 'x', model: 'y' },
},
}),
} }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset(window, 10)
const first = adapter.nodes()
expect(first.degraded).toBe(true)
expect(errorSpy).toHaveBeenCalled()
expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
const second = adapter.nodes()
expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: true,
}),
error: { name: 'Boom', code: 'boom' },
} }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
expect(adapter.callIndex.size).toBe(2)
})
it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
const adapter = new FoldAdapter()
const events = [
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
]
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset(events, 0, [callView, resultView] as never)
expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new FoldAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
describe('command lifecycle nodes', () => {
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 0)
const { nodes } = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
// Settlement replaced the node object rather than mutating the published one.
expect(settled).not.toBe(running)
})
it('tails command nodes whose seq is past every surface node', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
it('command nodes survive the degraded linear-scan branch', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0,
step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: { kind: 'model', provider: 'x', model: 'y' },
}),
} }),
], 0)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(true)
expect(nodes.some(n => n.kind === 'command')).toBe(true)
} finally {
errorSpy.mockRestore()
}
})
})
})

View File

@@ -0,0 +1,94 @@
import { createMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
})

View File

@@ -404,6 +404,45 @@ describe('live event path', () => {
})
})
it('keeps compacted history and adds one marker, live and on replay alike', async () => {
// A landed compaction must not erase conversation the reader already saw:
// the shadowed messages stay at their own log positions and the checkpoint
// contributes one marker after them.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
feed(ev.compactCheckpoint(7, 6, 1, 3))
const live = session.getSnapshot().nodes
expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' })
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
ev.compactSummary(6, '压缩摘要', 1, 3),
ev.compactCheckpoint(7, 6, 1, 3),
])
expect(replayed.session.getSnapshot().nodes).toEqual(live)
})
it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => {
// The transcript array is seq-monotonic, so the frozen node's fractional
// seq lands it exactly where it happened — including after a compaction
// checkpoint whose own seq is higher than the range it shadowed.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
feed(ev.compactCheckpoint(7, 6, 1, 3))
feed(ev.turnStart(8, 1))
feed(ev.user(9, '压缩后的提问'))
feed(ev.chunkStart(10, 1))
feed(ev.chunkText(11, 1, '说到一半'))
feed(ev.turnEnd(12, 1, 'aborted'))
expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([
'user', 'assistant', 'compaction', 'user', 'assistant',
])
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true })
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
@@ -435,6 +474,30 @@ describe('paging', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => {
// Pagination no longer spends maxMessages quota on replacement copies, so a
// page can carry a compaction checkpoint whose surfaceOp.start lies outside
// the window. The old surface fold rejected that range and degraded with a
// console error; the log-ordered transcript has no range to resolve.
const { api, session } = makeSession()
api.onHistory = () => histResponse([
ev.compactSummary(80, '窗外范围的摘要', 3, 40),
ev.compactCheckpoint(81, 80, 3, 40),
ev.user(82, '压缩后的新问题'),
], true)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.open()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' })
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -41,8 +41,8 @@ interface Bench {
async function boot(): Promise<Bench> {
const ctx = new Context()
ctx.plugin(SlotsService)
await ctx.fiber.await()
const fiber = ctx.plugin(SlotsService)
await fiber
// Service accessor (ctx.get reads the reflect store, which Service-class
// plugins do not write; the accessor is the product path).
const svc = ctx.slots

View File

@@ -0,0 +1,418 @@
/**
* TranscriptAdapter over the raw append-only window: log-ordered projection of
* append-origin events, one marker per landed compaction, replacement copies
* hidden, command-lifecycle folding, node/array identity, call pairing, and
* host-provided wire views.
*/
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
/** A `compact/summary` provenance event (log-only, no surfaceOp). */
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
return at(seq, {
type: 'compact/summary',
data: {
summary,
shadowedRange: { start: 1, end: 3 },
shadowedSeqs: [1, 3],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
},
})
}
/** The replacement user message a compaction backend lands (the checkpoint). */
function checkpoint(
seq: number,
summarySeq: number,
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
start?: number
end?: number
sourceEventSeqs?: number[]
} = {},
): SessionEvent {
return at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs,
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})
}
describe('TranscriptAdapter', () => {
it('projects a window starting past seq 0 at its own log positions', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (materialize-once identity)', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const first = adapter.nodes()
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second).toHaveLength(3)
expect(second[0]).toBe(first[0])
expect(second[1]).toBe(first[1])
expect(second).not.toBe(first) // a real change swaps the array
})
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const settled = adapter.nodes()
adapter.append(ev.chunkStart(6, 1))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.chunkText(7, 1, '流式'))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.assistant(8, 1, '流式完成'))
const finalized = adapter.nodes()
expect(finalized).not.toBe(settled)
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
})
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
// has not admitted contribute no node.
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 1),
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
compactSummary(2),
ev.user(3, '唯一的一条'),
ev.turnEnd(4, 1),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
})
describe('compaction markers', () => {
it('keeps the original messages and full tool output, hiding replacement copies', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '原始问题'),
ev.assistant(1, 0, '原始回答'),
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
ev.toolResult(5, 0, 'c1', '完整工具输出'),
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
turn: 0, step: 0,
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
} }),
compactSummary(7),
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
// A regenerated assistant/message: also a silent model-only rewrite.
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '通用 replacement 副本' }],
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
}),
} }),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
])
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
})
it('adds one marker per landed compaction, in log order', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, 'a'),
compactSummary(1, [{ type: 'text', text: 'first' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
ev.user(3, 'b'),
compactSummary(4, [{ type: 'text', text: 'second' }]),
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
])
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
])
})
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
// The pagination hole A1 left open: quota is no longer spent on
// replacement copies, so a page can carry a checkpoint whose
// surfaceOp.start lies below the window head. The old surface fold threw
// on the missing range and degraded with a console error; a log-ordered
// projection has no range to resolve.
const adapter = new TranscriptAdapter()
const noise = { error: console.error, warn: console.warn }
const logged: unknown[] = []
console.error = (...args: unknown[]) => logged.push(args)
console.warn = (...args: unknown[]) => logged.push(args)
try {
adapter.reset([
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
ev.user(82, '压缩后的新问题'),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
} finally {
console.error = noise.error
console.warn = noise.warn
}
expect(logged).toEqual([])
})
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
// A session-reference card carries the same plugin source shape; only the
// replacement marker makes an event a checkpoint.
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
})
it('ignores a foreign plugin s replacement user/message', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '保留'),
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
content: [{ type: 'text', text: '别的插件重写' }],
source: { kind: 'plugin', plugin: 'not-compact' },
}) }),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
})
it.each([
['absent provenance', undefined],
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
['an empty summary array', compactSummary(1, [])],
['a non-array summary', compactSummary(1, 'plain string')],
])('degrades %s to a non-expandable marker', (_label, summary) => {
const adapter = new TranscriptAdapter()
adapter.reset([
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
// so a non-text block must not discard recoverable text beside it.
const adapter = new TranscriptAdapter()
adapter.reset([
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
])
})
it('leaves the summary null when the checkpoint records no provenance at all', () => {
const adapter = new TranscriptAdapter()
adapter.reset([at(2, {
type: 'user/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '被压缩的问题'),
at(1, { type: 'compact/start', data: { turn: 0 } }),
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
])
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
})
it('resolves the summary once an older page supplies the provenance', () => {
const adapter = new TranscriptAdapter()
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
adapter.reset([landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
})
it('creates the marker on the live append path', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
const nodes = adapter.nodes()
// The compacted history is still there; the marker is one more row after it.
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
})
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes a tool-result error field when present', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }),
error: { name: 'Boom', code: 'boom' },
} }),
])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('attaches wire views to the materialized result node', () => {
const adapter = new TranscriptAdapter()
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset([
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
], [callView, resultView] as never)
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
})
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { title: '回声' }, resultView: null,
})
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
expect(adapter.nodes()[0]).toMatchObject({
kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' },
})
})
describe('command lifecycle nodes', () => {
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
expect(adapter.nodes()[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'))
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().find(n => n.kind === 'command')
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
// Settlement replaced the node object rather than mutating the published one.
expect(settled).not.toBe(running)
})
it('tails command nodes whose seq is past every transcript node', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('renders the /compact row alongside the marker its own command produced', () => {
// The row that reports the compaction is a command node; dropping command
// folding would delete it together with every other slash-command row.
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '压缩前的问题'),
ev.commandRun(1, 'cmd-compact', 'compact'),
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
})
})
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../compact/compact"
},
{
"path": "../../session-projection/session-projection"
},

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/test-runtime/README.md
README.md: 883d71224139dc409229fdfb35362d040e810cc7
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe
README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031
README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e

View File

@@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce.

View File

@@ -21,4 +21,4 @@
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件可接受Node 兼容的运行时入口待出现仓外消费方再补。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。

View File

@@ -46,7 +46,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
return {
sessionId,
nodes: [],
foldDegraded: false,
partial: null,
runningCalls: [],
codeDispatches: new Map(),

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: dea051fa608119ec1386299d48a1be8aef23aaec
README.zh.md: ae4537b899f44badba5d7e2051434bbbddb410c3
README.md: 66120f222de4b4d4707430a1ec310c6fe801c0c5
README.zh.md: f5e953f363733341400a292a1946b4e298858b77

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
@@ -34,6 +36,8 @@ Per-session UI state for selection and the active view lives in the declared cha
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -46,6 +50,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.

View File

@@ -4,9 +4,11 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`)投影而来。聊天视图是该包package自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
@@ -22,9 +24,9 @@
声明 `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` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
@@ -34,6 +36,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI模型选择器不增加圆环或附属控件。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
## 模型体验
@@ -46,7 +50,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行的耗时只覆盖窗口内消息流**LLM大语言模型与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
- **已发送的 user 消息无法编辑**user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
@@ -54,4 +59,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**包含非文本块的行仍显示扁平化预览但由于内联编辑器无法保留这些块其编辑控件会被禁用。文本行进入编辑模式后删除会替换为保存和取消Enter 保存Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:在 steering中途引导拥有专用交互之前Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
- **Web 仅暴露待处理 Queue**:在 steering中途引导拥有专用交互之前Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript 中,因此从外部提交的 steering 在回放时仍能如实呈现。

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
@@ -60,6 +61,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -0,0 +1,56 @@
// CompactionItem: the one row a landed compaction contributes to the flow.
// The conversation it shadowed on the model surface stays above it, so this
// marker reports where the model stopped seeing that history — it never
// replaces it. The framed checkpoint payload is written for the model and is
// not rendered; the disclosure shows the summary from the checkpoint's own
// provenance, and a window cut that left that provenance outside makes the row
// non-expandable rather than empty.
import { memo, useState } from 'react'
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14,
IconChevronRightOutline14,
MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './MessageItem.module.css'
interface CompactionItemProps {
node: CompactionSummaryNode
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
/**
* The collapsed-by-default compaction marker.
* @param props - the marker node off the snapshot cache.
* @returns the marker row, with the summary disclosure when one is available.
*/
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
const [expanded, setExpanded] = useState(false)
const expandable = node.summary !== null
const open = expandable && expanded
return (
<div className={css.compactionRow}>
<button
type="button"
className={css.compactionButton}
disabled={!expandable}
aria-expanded={expandable ? open : undefined}
onClick={() => { setExpanded(value => !value) }}
>
<span className={css.compactionLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
<span className={css.compactionTitle}>{t('message.compaction')}</span>
<span className={css.compactionSep} aria-hidden />
<span className={css.compactionSummary}>
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
</span>
</button>
{open && node.summary !== null
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
</div>
)
})

View File

@@ -34,6 +34,81 @@
padding: 2px 0;
}
/* Compaction marker: one dim 24px row with a chevron disclosure for the
summary body. Dimmed title (not label-primary) — the row is a boundary
notice, not conversation content. */
.compactionRow {
padding: 2px 0;
}
.compactionButton {
display: flex;
align-items: center;
width: 100%;
height: 24px;
min-width: 0;
padding: 0;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.compactionButton:not(:disabled) {
cursor: pointer;
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.compactionLeading {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 6px;
color: var(--dsw-alias-label-secondary);
}
.compactionTitle {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.compactionSep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.compactionSummary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactionBody {
padding: 4px 0 4px 22px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;

View File

@@ -1,20 +1,22 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context
// injection, retry disclosure, and unknown-surface JSON rows.
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
@@ -179,6 +181,8 @@ export const MessageItem = memo(function MessageItem({
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
default:

View File

@@ -3,43 +3,35 @@
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import css from './StatsLine.module.css'
interface UsageTotals {
interface WindowStats {
turns: number
steps: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
interface UsageLike {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}
/**
* Fold assistant and tool-result nodes into display totals.
* Fold assistant and tool-result nodes into the window-scoped display totals.
*
* Counts and wall times describe the loaded window on purpose — they answer
* "what is on screen". Token accounting deliberately does NOT come from here:
* the window is paged and compaction rewrites it, so billing rides the durable
* `tokenUsage` projection instead.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
* @returns visible counts and summed wall times.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
const turns = new Set<number>()
let steps = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -51,22 +43,8 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
return { turns: turns.size, steps, llmMs, toolMs }
}
/**
@@ -94,21 +72,82 @@ export function formatDuration(ms: number): string {
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
/**
* Cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns rounded integer percent, or null when no input was billed.
*/
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
const denominator = billedInputTokens(usage)
return denominator === 0
? null
: Math.round(usage.cacheReadTokens / denominator * 100)
}
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
contextWindow: pressure.contextWindow,
}
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
}
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
const groups: string[] = []
if (stats.steps > 0) {
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
if (usage !== undefined
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
groups.push(
`Input ${formatTokens(billedInputTokens(usage))} tok`
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
)
}
if (groups.length === 0) return null
return (
<div className={css.root}>
{groups.map((group, i) => (

View File

@@ -49,8 +49,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool results and retry notices reuse their first key.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
* @returns flow items; consecutive tool results group and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []

View File

@@ -49,6 +49,9 @@ export const zh = {
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
@@ -143,6 +146,9 @@ export const en = {
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',

View File

@@ -164,6 +164,34 @@ describe('MessageItem arms', () => {
expect(unknownView.getByText(/未知 surface 事件surface\/next/)).toBeTruthy()
})
it('a compaction marker discloses its summary and never shows the framed checkpoint', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'compaction', seq: 5, time: 1_000,
summary: '## 摘要标题\n\n保留的事实。',
}}
/>,
)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/保留的事实/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row).toHaveProperty('disabled', true)
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(view.getByText('压缩摘要不可用')).toBeTruthy()
fireEvent.click(row) // a disabled control stays collapsed
expect(row.getAttribute('aria-expanded')).toBeNull()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
@@ -417,14 +445,19 @@ describe('small branch tails', () => {
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})

View File

@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,

View File

@@ -33,7 +33,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -58,7 +58,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
describe('deriveStats', () => {
it('folds turns/steps/token split and cache hit percentage', () => {
it('counts turns and steps and never folds node usage into accounting', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -66,12 +66,12 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.inputTokens).toBe(1100)
expect(stats.outputTokens).toBe(100)
expect(stats.cacheHitPct).toBe(82)
// Window-scoped by design: the paged window is not an accounting source, so
// the fold exposes no token fields at all (billing rides the projection).
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
})
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
@@ -79,7 +79,6 @@ describe('deriveStats', () => {
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.toolMs).toBe(0)
expect(stats.cacheHitPct).toBeNull()
})
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
@@ -116,22 +115,105 @@ describe('formatters', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
return (key: string) => values[key]
}
it('renders the grouped stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
function props(
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
// No timing on the fixture: the duration group drops out whole.
// No timing on the fixture: the duration group drops out whole. Tokens come
// from the projection, so paging the window cannot change them.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
const emptyView = render(<StatsLine {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
contextPressure: {},
})} />)
expect(emptyView.container.textContent).toBe('')
})
it('keeps durable token and context groups after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent)
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const withCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
// Pressure without capacity has no denominator: the group drops out.
const noCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000 },
})} />)
expect(noCapacity.container.textContent).not.toContain('Context')
// Capacity arrives before usage in the log; no provider sample means there
// is no numerator yet, rather than a synthetic 0%.
const noPressure = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { contextWindow: 128_000 },
})} />)
expect(noPressure.container.textContent).not.toContain('Context')
})
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
// Capacity and pressure are independent last-wins fields, so a model switch
// can pair a smaller new window with the previous route's larger prompt.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent).toContain('Context 100% of 128K')
})
it('drops every token group when no projection is composed', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps')
})
it('omits cache hit when nothing was billed on the input side', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
})
it('includes cache writes in billed input and the cache-hit denominator', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: {
uncachedInputTokens: 10,
outputTokens: 7,
cacheReadTokens: 90,
cacheWriteTokens: 100,
},
})} />)
expect(view.container.textContent)
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0

View File

@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}

View File

@@ -309,7 +309,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -43,20 +43,24 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
it('StatsLine counts window nodes but drops every token group without a projection', () => {
// Node `usage` is deliberately ignored: billing rides the durable
// tokenUsage projection, so an absent projection leaves counts only.
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
// outputTokens absent: the tokens sum's ?? 0 arm for output.
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
expect(view.container.textContent).toBe('2 turns · 3 steps')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

View File

@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,

View File

@@ -28,7 +28,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}

View File

@@ -253,7 +253,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,

View File

@@ -366,7 +366,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,

View File

@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,

View File

@@ -451,7 +451,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,

View File

@@ -212,7 +212,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,

View File

@@ -26,6 +26,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../plan/plan-mode"
},

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-settings-general/README.md
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa
README.md: 4dbd339c93171b330895ab66366e76fd06013704
README.zh.md: 8ad6de99ce78d3bdb1e7b35e872e5bfe6790e758

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
## Model Experience

View File

@@ -4,7 +4,7 @@
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后无需重新加载即可推进。版本不同时系统会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后无需重新加载即可推进。版本不同时系统会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
## 模型体验

View File

@@ -27,7 +27,6 @@
}
.opening,
.status,
.reflection,
.feedback,
.error {
@@ -38,10 +37,6 @@
margin-top: 30px;
}
.status {
margin-top: 18px;
}
.reflection {
margin-top: 36px;
padding: 0;
@@ -52,7 +47,6 @@
}
.opening,
.status,
.reflection,
.feedback {
font-size: 16px;
@@ -90,7 +84,6 @@
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
@@ -99,10 +92,9 @@
.title { animation-delay: 40ms; }
.opening { animation-delay: 80ms; }
.status { animation-delay: 120ms; }
.reflection { animation-delay: 160ms; }
.feedback { animation-delay: 200ms; }
.footer { animation-delay: 240ms; }
.reflection { animation-delay: 120ms; }
.feedback { animation-delay: 160ms; }
.footer { animation-delay: 200ms; }
@keyframes welcome-enter {
from {
@@ -120,7 +112,6 @@
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {

View File

@@ -66,10 +66,9 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<p className={css.status}>{t('welcome.paragraph.1')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>

View File

@@ -11,7 +11,6 @@ export const zh = {
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
@@ -30,7 +29,6 @@ export const en = {
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',

View File

@@ -8,30 +8,28 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.5'
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
title: '内测声明',
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
},
en: {
title: 'Internal Testing Notice',
title: '内测声明',
paragraphs: [
'Thank you for taking the time to try DeepSeek Harness.',
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.',
'We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议请在企业微信群中留言告诉我们。每一条反馈都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
continueLabel: 'Continue',
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
},
} as const

View File

@@ -52,6 +52,10 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
}
describe('WelcomeNotice', () => {
it('uses the same Chinese owner copy in both GUI locales', () => {
expect(WELCOME_NOTICE_COPY.en).toEqual(WELCOME_NOTICE_COPY.zh)
})
it('renders the owner copy with one primary action and no dismissal control', async () => {
const h = mount()
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })

View File

@@ -357,6 +357,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'compaction') {
// Chat owns the human-facing compaction marker. It contributes no
// duplicate trajectory cell, but still advances the duration cursor.
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name

View File

@@ -195,7 +195,7 @@ describe('deriveTrajectoryLayout', () => {
expect(message?.thinkingDetail).toBe(thinking)
})
it('advances the duration cursor over context nodes', () => {
it('advances the duration cursor over context and compaction nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
{
@@ -211,17 +211,21 @@ describe('deriveTrajectoryLayout', () => {
kind: 'context', seq: 4, time: 9_000,
content: [{ type: 'text', text: 'extra' }], source: null,
},
// A landed compaction renders no cell, but is still a real log position,
// so it moves the cursor after the visible context row.
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
{
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap(g => g.cells)
.find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
expect(message?.timeSeconds).toBe(0.5)
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
})
it('uses the recorded step start for assistant duration when timing exists', () => {

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: 17a5420ae9fa23ce4021b4d4927ae5b95962f979
README.zh.md: 56624efad3c4ffacb61c1a2e199e0461afc57242
README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2

View File

@@ -59,6 +59,10 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
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.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../ui/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
## Model Experience
### Conversation history, when a backend is invoked

View File

@@ -59,6 +59,10 @@
继承 `CompactService`,实现 `compactIfNeeded``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`
`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强即 [`dsh-commands/brand`](../../ui/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout)。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
## 模型体验
### 调用后端时的会话历史

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./checkpoint": {
"types": "./lib/types/checkpoint.d.ts",
"default": "./lib/types/checkpoint.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -0,0 +1,27 @@
/**
* The compaction seam's canonical checkpoint source: the plugin marker every
* backend stamps on the replacement user message that lands a checkpoint, plus
* the predicate that recognizes it.
*
* The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of
* these; this module is a pure value/predicate outlet (no cordis imports, no
* module augmentation) so client and wire programs can name the checkpoint
* source without loading the host plugin's Context merges — the
* `dsh-commands/brand` shape.
*
* @module @deepseek-ai/dsh-compact/checkpoint
*/
import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
}

View File

@@ -8,24 +8,15 @@
*/
import { Context, Service } from 'cordis'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
}
// The checkpoint source and its predicate are declared on the cordis-free
// `./checkpoint` leaf so client and wire programs can name them without this
// root's Context merge; the root stays the host-side entry point for both.
export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts'
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'

View File

@@ -2093,7 +2093,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PreparedLlmCall',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'PreparedReferencedMessage',
@@ -2243,6 +2243,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'RedactedSecret',
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
},
{
name: 'RequestContext',
declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n}',
},
{
name: 'RequestHeaderReason',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',
@@ -2329,7 +2333,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAvailability',
@@ -2341,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/end-seed\': Record<string, never>;\n}',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}',
},
{
name: 'SessionEventMetadataFilter',

View File

@@ -45,7 +45,7 @@ import {
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -725,6 +725,24 @@ export class ReactLoopAgent implements Agent {
session.append('request/header', { header, reason: 'change' })
}
// TODO: This looks like code smell.
// Context metadata for the route this request resolved to, recorded from the same
// registration-bound lookup that prepared the call (no second resolve).
// A route with unknown capacity is still recorded so it clears any older
// denominator; an unchanged route logs nothing.
const contextWindow = preparedCall?.context?.contextWindow
const requestContext: RequestContext = {
provider: config.provider,
model: config.model,
...contextWindow === undefined ? {} : { contextWindow },
}
const previous = session.requestContext()
if (previous?.provider !== requestContext.provider
|| previous.model !== requestContext.model
|| previous.contextWindow !== requestContext.contextWindow) {
session.append('request/context', requestContext)
}
const request = markAgentLoopRequest(deepFreeze({
...header.config,
messages: boundaryMessages,

View File

@@ -8,7 +8,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -614,3 +614,95 @@ describe('request stability across the loop', () => {
})
})
})
describe('request/context capacity records', () => {
/** Adapter advertising a per-model capacity, keyed by model id. */
function capacityAdapter(windows: Record<string, number>, script: StreamChunk[][]): MockAdapter {
return new class extends MockAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const contextWindow = windows[model]
return Promise.resolve({
provider,
id: model,
name: model,
...contextWindow === undefined ? {} : { context: { contextWindow } },
})
}
}(script)
}
it('records capacity once and skips it while the route is unchanged', async () => {
const adapter = capacityAdapter({ mock: 128_000 }, [textResponse('a'), textResponse('b')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
const records = agent.session.events.filter(event => event.type === 'request/context')
expect(records).toHaveLength(1)
expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 })
// Log-only: not a SurfaceEventType, so it can never reach a model request
// (the type system rejects a surfaceOp here; the session invariant also
// requires the record to sit inside its open turn).
expect(agent.session.surface.nodes).not.toContain(records[0]?.seq)
})
it('records a second capacity when the route changes mid-session', async () => {
const adapter = capacityAdapter(
{ small: 64_000, large: 256_000 },
[textResponse('a'), textResponse('b')],
)
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' })
send(agent, 'first')
await waitForIdle(ctx, agent)
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
? Promise.resolve({ provider: 'mock', model: 'large' })
: next())
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(agent.session.events
.filter(event => event.type === 'request/context')
.map(event => event.data.contextWindow)).toEqual([64_000, 256_000])
})
it('records and deduplicates a route whose adapter advertises no capacity', async () => {
const ctx = await harness(new MockAdapter([textResponse('a'), textResponse('b')]))
const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(agent.session.events
.filter(event => event.type === 'request/context')
.map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }])
})
it('clears a previous capacity when the next route advertises none', async () => {
const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
let model = 'known'
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
? Promise.resolve({ provider: 'mock', model })
: next())
send(agent, 'first')
await waitForIdle(ctx, agent)
model = 'unknown'
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(agent.session.events
.filter(event => event.type === 'request/context')
.map(event => event.data)).toEqual([
{ provider: 'mock', model: 'known', contextWindow: 64_000 },
{ provider: 'mock', model: 'unknown' },
])
})
})

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/session/README.md
README.md: 132e627387aff54381d10e87a52dc1440d0a5b56
README.zh.md: edf94f765b81a81dfe39662e967cd6dfdca140fa
README.md: 9c7d41901e6fb0133fff0e210260e5310a025f75
README.zh.md: ca1292289901a09b83f9b0a794fa4edc9754b1da

View File

@@ -66,6 +66,8 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`request/context` records registration-bound metadata for the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity is still recorded with `contextWindow` absent, clearing any older known capacity.
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.

View File

@@ -66,6 +66,8 @@
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial``resume``change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort``maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`request/context` 记录请求所解析到的路由的、绑定注册项的元数据,在其所属步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由仍会被记录,但 `contextWindow` 字段缺失,从而清除较早的已知容量。
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message``tool/result` 和 steering中途引导对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start``turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -606,6 +606,30 @@ export class Session {
return this.headerFold
}
/** Cached fold of the request-context events — see {@link requestContext}. */
private contextFold: RequestContext | undefined
/** Log position (events consumed) the context fold has reached. */
private contextFoldSeq = 0
/**
* The route metadata in force after the log's last `request/context` event —
* what the NEXT request deduplicates against — or undefined before any such
* record. Maintained incrementally like {@link requestHeader}, so a per-step
* read costs O(new events).
* @returns the folded context record, or undefined when none exists yet.
*/
requestContext(): RequestContext | undefined {
if (this.contextFoldSeq < this.log.length) {
for (const event of this.log.slice(this.contextFoldSeq)) {
// Frozen for the same reason as the header fold: it is session state
// exposed by reference and every later dedup compares against it.
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
}
this.contextFoldSeq = this.log.length
}
return this.contextFold
}
/** The derived-message cache: frozen projections, extended per unseen node. */
private derived: Message[] = []
/** Surface position (nodes projected) the cache has reached. */

View File

@@ -149,6 +149,7 @@ function validateEvent(
break
case 'steering/message':
case 'todo/write':
case 'request/context':
case 'request/header': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)

View File

@@ -172,6 +172,20 @@ export interface EpochHeader {
tools?: ToolSchema[]
}
/**
* Registration-bound context metadata of one resolved model route. Adapter
* metadata about a route rather than a request input, which is why it lives
* outside {@link EpochHeader}.
*/
export interface RequestContext {
/** Registered provider route the metadata was resolved through. */
provider: string
/** Provider-owned model id the metadata belongs to. */
model: string
/** Maximum combined request and response context in tokens; absent when the adapter advertises none. */
contextWindow?: number
}
/**
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
@@ -253,6 +267,16 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Registration-bound context metadata for the route a request resolved to,
* appended inside its step beside `request/header` and only when the route
* or capacity differs from the last record. It is log-only and deliberately
* NOT part of {@link EpochHeader}: capacity is adapter metadata about a
* route, not an input the request was built from, so it must not participate
* in request reconstruction or header equality. `contextWindow` is absent
* when the route's adapter advertises no capacity.
*/
'request/context': RequestContext
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle

View File

@@ -144,6 +144,12 @@ describe('session-log invariants', () => {
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Route capacity is core execution state like the header beside it.
expect(() => outside.append('request/context', {
provider: 'mock',
model: 'm',
contextWindow: 128_000,
})).toThrow(/outside any open turn/)
// The owning plugin decides whether a merge-extensible event is log-only.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()

View File

@@ -114,3 +114,60 @@ describe('legacy request-header format', () => {
expect(session.events).toHaveLength(0)
})
})
describe('Session.requestContext', () => {
const CAPACITY = { provider: 'mock', model: 'm', contextWindow: 128_000 }
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
const events: SessionEvent[] = [{
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}]
for (const data of records) {
events.push({ type: 'request/context', seq: events.length, time: 1, data })
}
return events
}
it('reads undefined before any record exists', () => {
expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined()
})
it('folds a seeded log on first read, taking the last record', () => {
// The fold watermark starts at 0 with the seed already in the log, so the
// first read must consume the whole seed rather than skip it.
const session = new Session(SessionId('seeded-capacity'), seedWith(
CAPACITY,
{ ...CAPACITY, model: 'later', contextWindow: 256_000 },
))
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'later', contextWindow: 256_000 })
})
it('advances incrementally across appends and skips unrelated events', () => {
const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('todo/write', { todos: [] })
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
session.append('request/context', { provider: 'mock', model: 'unknown' })
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'unknown' })
})
it('folds a batch appended between two reads', () => {
const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
session.append('todo/write', { todos: [] })
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
expect(session.requestContext()?.contextWindow).toBe(300_000)
})
it('exposes a frozen record so a reader cannot desync later comparisons', () => {
const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY))
const held = session.requestContext()
if (held === undefined) throw new Error('expected a folded capacity record')
expect(Object.isFrozen(held)).toBe(true)
expect(() => { (held as { contextWindow?: number }).contextWindow = 1 }).toThrow()
})
})

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/llm/llm/README.md
README.md: f4be9b298c730b7ec0a0faa4470890fe5e3f5af8
README.zh.md: 9aa22ba861ee368523b03a5472ea783bbcbbd765
README.md: 21f428fb22c9a59a67d86f446ea866c1629b964a
README.zh.md: f6421f0625de7e63432a4863db6fb2d96ecd1b3f

View File

@@ -18,7 +18,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize adapter-configured call defaults without clamping.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
@@ -29,7 +29,7 @@ Every topology commit point — adapter routes registering or disposing, directo
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`.
`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults` and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes detached context metadata from the same lookup, reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults`, and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
### Events

View File

@@ -18,7 +18,7 @@
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份以及可用上下文、输出默认值和推理reasoning元数据异步适配器可选地支持取消。
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始分片token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure``llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`
@@ -29,7 +29,7 @@
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context``defaultMaxTokens``reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO``INVALID_MODEL_CONTEXT``INVALID_MODEL_MAX_TOKENS``INVALID_MODEL_REASONING` 失败。
`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal并且必须在取消后迅速结束。`prepareCall()` 还会通过 `adapterDefaults` 报告它填入了哪些 `maxTokens``reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR热模块替换不会将一个适配器的能力结果与另一个适配器的请求混用复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal并且必须在取消后迅速结束。`prepareCall()` 还会公开同一次查询得到的脱耦上下文元数据,通过 `adapterDefaults` 报告它填入了哪些 `maxTokens``reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR热模块替换不会将一个适配器的能力结果与另一个适配器的请求混用复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
### 事件

View File

@@ -11,6 +11,7 @@ import type {
GenerateOptions,
LlmConfigurableProvider,
LlmFailure,
LlmModelContext,
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
@@ -125,6 +126,8 @@ export class LlmError extends HarnessError {
export interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/** Detached context metadata resolved with the registration-bound call. */
readonly context?: LlmModelContext
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
readonly adapterDefaults: LlmCallConfigAdapterDefaults
/**
@@ -564,20 +567,21 @@ export class LlmService extends Service {
* @returns a detached config only when a default must be materialized.
*/
async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig> {
return this.resolveCallConfigFor(this.registration(config.provider), config, signal)
return (await this.resolveCallFor(this.registration(config.provider), config, signal)).config
}
private async resolveCallConfigFor(
private async resolveCallFor(
registration: AdapterRegistration,
config: LlmCallConfig,
signal?: AbortSignal,
): Promise<LlmCallConfig> {
): Promise<{ config: LlmCallConfig; context?: LlmModelContext }> {
const info = await this.resolveModelInfoFor(registration, config.model, signal)
const defaulted = config.maxTokens === undefined && info.defaultMaxTokens !== undefined
? { ...config, maxTokens: info.defaultMaxTokens }
: config
const reasoning = info.reasoning
const requested = defaulted.reasoningEffort
let resolvedConfig = defaulted
if (reasoning === undefined) {
if (requested !== undefined) {
throw new LlmError(
@@ -585,17 +589,22 @@ export class LlmService extends Service {
'UNSUPPORTED_REASONING_EFFORT',
)
}
return defaulted
} else {
const effective = requested ?? reasoning.defaultEffort
if (effective !== undefined) {
if (!reasoning.efforts.some(effort => effort.id === effective)) {
throw new LlmError(
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
if (requested !== effective) resolvedConfig = { ...defaulted, reasoningEffort: effective }
}
}
const effective = requested ?? reasoning.defaultEffort
if (effective === undefined) return defaulted
if (!reasoning.efforts.some(effort => effort.id === effective)) {
throw new LlmError(
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
return {
config: resolvedConfig,
...info.context === undefined ? {} : { context: info.context },
}
return requested === effective ? defaulted : { ...defaulted, reasoningEffort: effective }
}
/**
@@ -608,13 +617,16 @@ export class LlmService extends Service {
*/
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
const registration = this.registration(config.provider)
const resolved = await this.resolveCallConfigFor(registration, config, signal)
const resolvedConfig = deepFreeze(structuredClone(resolved))
const resolved = await this.resolveCallFor(registration, config, signal)
const resolvedConfig = deepFreeze(structuredClone(resolved.config))
const context = resolved.context === undefined
? undefined
: deepFreeze(structuredClone(resolved.context))
const adapterDefaults = deepFreeze<LlmCallConfigAdapterDefaults>({
...config.reasoningEffort === undefined && resolved.reasoningEffort !== undefined
...config.reasoningEffort === undefined && resolvedConfig.reasoningEffort !== undefined
? { reasoningEffort: true }
: {},
...config.maxTokens === undefined && resolved.maxTokens !== undefined
...config.maxTokens === undefined && resolvedConfig.maxTokens !== undefined
? { maxTokens: true }
: {},
})
@@ -622,6 +634,7 @@ export class LlmService extends Service {
return Object.freeze({
config: resolvedConfig,
adapterDefaults,
...context === undefined ? {} : { context },
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
if (dispatched) {
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
@@ -672,7 +685,7 @@ export class LlmService extends Service {
const registration = prepared?.registration ?? this.registration(options.provider)
failures.retryPolicy = registration.retryPolicy
const resolvedConfig = prepared === undefined
? await this.resolveCallConfigFor(registration, options, options.signal)
? (await this.resolveCallFor(registration, options, options.signal)).config
: prepared.config
if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
throw new LlmError(
@@ -680,7 +693,7 @@ export class LlmService extends Service {
'INVALID_PREPARED_CALL',
)
}
const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig)
const resolvedOptions = callConfigEquals(options, resolvedConfig)
? options
: Object.isFrozen(options)
? deepFreeze({ ...options, ...resolvedConfig })

View File

@@ -1134,6 +1134,48 @@ describe('LlmService', () => {
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
})
it('reuses one exact-model lookup for prepared config and context metadata', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
let resolutions = 0
const source = { contextWindow: 128_000 }
const adapter = new class extends ScriptedAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
resolutions += 1
return Promise.resolve({
provider,
id: model,
name: model,
description: 'Resolved model',
context: source,
reasoning: model === 'no-default'
? { efforts: [{ id: ReasoningEffortId('high'), name: 'High' }] }
: {
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
},
})
}
}(SCRIPT)
ctx.llm.registerAdapter(['route'], adapter)
const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
source.contextWindow = 64_000
expect(prepared.config.reasoningEffort).toBe(ReasoningEffortId('high'))
expect(prepared.context).toEqual({ contextWindow: 128_000 })
expect(Object.isFrozen(prepared.context)).toBe(true)
for await (const _chunk of prepared.stream({
...prepared.config,
messages: [],
})) { /* drain */ }
expect(resolutions).toBe(1)
const noDefault = await ctx.llm.prepareCall({ provider: 'route', model: 'no-default' })
expect(noDefault.config).toEqual({ provider: 'route', model: 'no-default' })
expect(noDefault.context).toEqual({ contextWindow: 64_000 })
expect(resolutions).toBe(2)
})
it('passes cancellation through exact-model resolution', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

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/llm/token-meter/README.md
README.md: 578728ded9cf51a12abcd70d541404e995028f26
README.zh.md: 9d54ddb4792e6c897af7d57a6f8ae98204caa11d
README.md: 701893b342f9a93a75bec175634b1054f3d17151
README.zh.md: a5844e8788422bba669632ed587fb87e1e2a1e58

View File

@@ -21,6 +21,24 @@ The fold tracks full request-header snapshots, step boundaries, surface appends
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
## Session projections
When the composition provides `ctx.sessionProjections`, token-meter registers two units through an optional child fiber.
`tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again.
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage.
Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior.
### Context occupancy is an approximation, by design
`pressureTokens` and `contextWindow` are independent last-wins fields and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's pressure until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now.
This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. The TUI status line has always computed occupancy the same way, dividing a `measure()` total by a separately-resolved capacity for the selected model.
Making the pair atomic was tried and rejected: it required a transient non-replayable wire frame, which needed lifecycle fencing against cross-stream reordering and left occupancy blank after every reconnect. The [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) records that comparison. Consumers that need an exact same-boundary figure should call `measure()` at their own request boundary rather than read this projection.
## Composition
```yaml
@@ -44,3 +62,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
- **The TUI and browser fixture retain parallel folds** — `tokenUsage` owns durable session-projection semantics; the TUI keeps its live per-step map because its composition does not mount the generic projection seam, while the browser fixture mirrors the unit for standalone demo data.

View File

@@ -21,6 +21,24 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket不会再次添加推理reasoning。每次成功调用都会记录一个 assistant 锚点包括无内容调用。显式空溯源列表表示已知空提供方流而遗留溯源缺失时fold 会保守地将持久 assistant 输出视为提供方输出。
## 会话投影
当组合提供 `ctx.sessionProjections`token-meter 会通过一个可选子 fiber 注册两个单元。
`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens``outputTokens``cacheReadTokens``cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。
两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。
### 上下文占用率是刻意为之的近似值
`pressureTokens``contextWindow` 是两个各自后者胜的独立字段,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层。
这是刻意的选择。占用率百分比是面向用户的参考数字既不是计费记录也不是门控输入harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。TUI 状态行一直以同样的方式计算占用率,即用 `measure()` 总量除以为所选模型单独解析出的容量。
让这对值保持原子已经尝试过并被否决:它需要一个临时且不可回放的协议帧,进而需要针对跨流重排序的生命周期栅栏,还会让占用率在每次重连后变为空白。[Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md)记录了这项对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。
## 组合
```yaml
@@ -44,3 +62,4 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
- **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。
- **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。
- **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。
- **TUI 与浏览器 fixture 仍保留并行 fold**`tokenUsage` 拥有持久会话投影语义TUI 的组合未挂载通用投影 seam因此继续维护实时的逐步骤 map而浏览器 fixture 会为独立 demo 数据镜像该单元。

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -30,15 +35,18 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,7 @@
/**
* Client-namespace projection of token-meter's browser-safe types.
*
* @module @deepseek-ai/dsh-token-meter/client
*/
export type * from './projection.ts'

View File

@@ -10,12 +10,15 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
// Type-only: resolves the optional projection registry Context seam.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {
TokenMeasurement,
TokenMeasurementBaseline,
TokenMeterConfig,
TokenSurfaceNode,
} from './types.ts'
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
export type * from './types.ts'
@@ -90,6 +93,13 @@ export class TokenMeterService extends Service {
super(ctx, 'tokenMeter')
validateConfigKeys(config)
// Projection registration is an optional child: headless and TUI
// compositions without the generic registry keep the meter's old shape.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
projectionCtx.sessionProjections.register(contextPressureProjectionDefinition)
})
// Readers catch up independently, while eager observation bounds ordinary
// read latency without creating state for sessions no consumer has read.
ctx.on('session/event', (session) => {

View File

@@ -15,8 +15,11 @@ export const name = 'token-meter-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: token estimates are per-call outputs and the private session cache is
* invalidated at its event mutation boundary; neither exposes an independent observation stream.
* No runtime invariant: token estimates are per-call outputs and the private
* session cache is invalidated at its event mutation boundary. The package's
* projection does expose an observation stream, but its schema fixes the JSON
* payload and its pure fold replaces same-step samples; totals need not be
* monotone when a final usage sample corrects an earlier chunk.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,50 @@
/**
* Pure client-safe token-projection vocabulary.
*
* @module @deepseek-ai/dsh-token-meter/projection
*/
/**
* Durable cumulative provider usage for a complete session log.
*
* The four buckets are disjoint. In particular, reasoning tokens are already
* included in `outputTokens` and are not accumulated again.
*/
export interface TokenUsageProjection {
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
/**
* Approximate context occupancy for a status display.
*
* The two fields, when present, are deliberately NOT one atomic request
* observation: `pressureTokens` is the newest provider-reported prompt size,
* `contextWindow` the newest recorded route capacity. Switching models can
* therefore pair a fresh capacity with the previous route's pressure until the
* next request reports usage. This is an intentional trade — the value is a
* user-facing reference, not a billing or gating input — and it matches how
* the TUI status line has always computed occupancy. See the token-meter
* README for the full rationale.
*/
export interface ContextPressureProjection {
/**
* Provider-reported prompt size of the most recent request: uncached input
* plus cache reads and writes. Response output is excluded, so this does not
* grow as the current turn streams. Absent until a provider reports usage.
*/
pressureTokens?: number
/** Newest recorded route capacity; absent when no adapter advertised one. */
contextWindow?: number
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Provider-reported usage accumulated across the complete durable log. */
tokenUsage: TokenUsageProjection
/** Newest request pressure paired with the newest known route capacity. */
contextPressure: ContextPressureProjection
}
}

View File

@@ -6,6 +6,8 @@
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
export type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
/** Token-meter plugin configuration; the fixed estimator has no settings. */
export type TokenMeterConfig = Record<string, never>

View File

@@ -0,0 +1,153 @@
/**
* Pure folds for durable provider-reported token usage and context occupancy.
*/
import { z } from 'zod'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
interface UsageSample {
turn: number
step: number
buckets: TokenUsageProjection
}
interface TokenUsageState {
totals: TokenUsageProjection
last: UsageSample | null
}
const zeroBuckets = (): TokenUsageProjection => ({
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
const bucketsFrom = (usage: TokenUsage): TokenUsageProjection => ({
uncachedInputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
})
const bucketsEqual = (left: TokenUsageProjection, right: TokenUsageProjection): boolean =>
left.uncachedInputTokens === right.uncachedInputTokens
&& left.outputTokens === right.outputTokens
&& left.cacheReadTokens === right.cacheReadTokens
&& left.cacheWriteTokens === right.cacheWriteTokens
const addReplacing = (
totals: TokenUsageProjection,
previous: TokenUsageProjection | undefined,
next: TokenUsageProjection,
): TokenUsageProjection => ({
uncachedInputTokens: totals.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) + next.uncachedInputTokens,
outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
})
const projectionSchema = z.object({
uncachedInputTokens: z.number().int().nonnegative(),
outputTokens: z.number().int().nonnegative(),
cacheReadTokens: z.number().int().nonnegative(),
cacheWriteTokens: z.number().int().nonnegative(),
}).strict()
// Cast for the optional values: under exactOptionalPropertyTypes zod infers
// `number | undefined` where the interface declares absent-or-number fields.
const pressureSchema = z.object({
pressureTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}).strict() as unknown as z.ZodType<ContextPressureProjection>
/** Prompt-side pressure of one request: input plus cache traffic, no output. */
const pressureFrom = (usage: TokenUsage): number =>
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
/**
* Token-meter's session projection unit.
*
* Usage chunks provide an early sample that survives a later request failure;
* an assistant message provides the final sample for the same turn/step. A
* repeated sample replaces that step's earlier value instead of double
* counting it. The single `last` slot relies on the session-log invariant
* that usage reports for one turn/step are adjacent: once a later step begins,
* a legal log never reports usage for an earlier step again.
*/
export const tokenUsageProjectionDefinition:
ProjectionDefinition<'tokenUsage', TokenUsageState> = {
key: 'tokenUsage',
schema: projectionSchema,
init: () => ({ totals: zeroBuckets(), last: null }),
apply: (state, event) => {
let turn: number
let step: number
let usage: TokenUsage
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
;({ turn, step } = event.data)
usage = event.data.chunk.usage
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
;({ turn, step, usage } = event.data)
} else {
return state
}
const buckets = bucketsFrom(usage)
const previous = state.last !== null
&& state.last.turn === turn
&& state.last.step === step
? state.last.buckets
: undefined
if (previous !== undefined && bucketsEqual(previous, buckets)) return state
return {
totals: addReplacing(state.totals, previous, buckets),
last: { turn, step, buckets },
}
},
view: state => state.totals,
stateVersion: 1,
}
/**
* Token-meter's context-occupancy projection unit.
*
* Two independent last-wins slots: the newest usage sample supplies the
* numerator, the newest `request/context` record the denominator. Both are
* whole values, so replay order alone decides the result and no cross-field
* consistency is claimed — the pair is explicitly not one atomic request
* observation (see {@link ContextPressureProjection}).
*
* The numerator is prompt-side only, so it holds still while a turn streams
* and steps forward once the next request reports its usage.
*/
export const contextPressureProjectionDefinition:
ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
key: 'contextPressure',
schema: pressureSchema,
init: () => ({}),
apply: (state, event) => {
if (event.type === 'request/context') {
const contextWindow = event.data.contextWindow
if (contextWindow === state.contextWindow) return state
if (contextWindow !== undefined) return { ...state, contextWindow }
const { contextWindow: _removed, ...withoutContextWindow } = state
return withoutContextWindow
}
const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
? event.data.chunk.usage
: event.type === 'assistant/message'
? event.data.usage
: undefined
if (usage === undefined) return state
const pressureTokens = pressureFrom(usage)
return pressureTokens === state.pressureTokens
? state
: { ...state, pressureTokens }
},
view: state => state,
stateVersion: 2,
}

View File

@@ -0,0 +1,333 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
async function harness(): Promise<{
ctx: Context
session: Session
meterFiber: Awaited<ReturnType<Context['plugin']>>
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const meterFiber = await ctx.plugin(TokenMeterService)
return { ctx, session: ctx.sessions.create(), meterFiber }
}
function startStep(session: Session, turn: number, step: number): void {
session.append('step/start', { turn, step })
}
function usageChunk(
session: Session,
usage: TokenUsage,
turn: number,
step: number,
): number {
return session.append('assistant/chunk', {
turn,
step,
chunk: { type: 'usage', usage },
}).seq
}
function finalUsage(
session: Session,
usage: TokenUsage,
turn: number,
step: number,
sourceSeqs: number[],
): void {
session.append('assistant/message', {
turn,
step,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage,
}, { surfaceOp: 'append', sourceEventSeqs: sourceSeqs })
session.append('step/end', { turn, step })
}
const projected = (ctx: Context, session: Session): TokenUsageProjection => {
const value = ctx.sessionProjections.snapshot(session).values.tokenUsage
if (value === undefined) throw new Error('tokenUsage projection is not registered')
return value
}
describe('tokenUsage session projection', () => {
it('serves zero buckets for an empty log', async () => {
const { ctx, session } = await harness()
expect(projected(ctx, session)).toEqual(ZERO)
})
it('does not count a usage chunk and identical final usage twice', async () => {
const { ctx, session } = await harness()
const changes: unknown[] = []
ctx.sessionProjections.onChanged((_session, key, value) => {
if (key === 'tokenUsage') changes.push(value)
})
const usage = {
inputTokens: 10,
outputTokens: 4,
cacheReadTokens: 7,
cacheWriteTokens: 2,
reasoningTokens: 3,
}
startStep(session, 1, 1)
const source = usageChunk(session, usage, 1, 1)
finalUsage(session, usage, 1, 1, [source])
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 10,
outputTokens: 4,
cacheReadTokens: 7,
cacheWriteTokens: 2,
})
expect(changes).toHaveLength(1)
})
it('replaces an earlier same-step chunk sample with the final usage', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
const source = usageChunk(session, {
inputTokens: 10,
outputTokens: 2,
cacheReadTokens: 3,
}, 1, 1)
finalUsage(session, {
inputTokens: 14,
outputTokens: 5,
cacheReadTokens: 8,
cacheWriteTokens: 1,
}, 1, 1, [source])
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 14,
outputTokens: 5,
cacheReadTokens: 8,
cacheWriteTokens: 1,
})
})
it('accumulates disjoint buckets across steps without adding reasoning twice', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
const first = usageChunk(session, {
inputTokens: 10,
outputTokens: 6,
reasoningTokens: 5,
cacheReadTokens: 2,
}, 1, 1)
finalUsage(session, {
inputTokens: 10,
outputTokens: 6,
reasoningTokens: 5,
cacheReadTokens: 2,
}, 1, 1, [first])
startStep(session, 1, 2)
const second = usageChunk(session, {
inputTokens: 20,
outputTokens: 9,
reasoningTokens: 7,
cacheWriteTokens: 4,
}, 1, 2)
finalUsage(session, {
inputTokens: 20,
outputTokens: 9,
reasoningTokens: 7,
cacheWriteTokens: 4,
}, 1, 2, [second])
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 30,
outputTokens: 15,
cacheReadTokens: 2,
cacheWriteTokens: 4,
})
})
it('retains a usage chunk when the request produces no final assistant message', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 9, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 9,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
})
it('does not erase historical billing when the visible surface is replaced', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 12, outputTokens: 3 }, 1, 1)
finalUsage(session, { inputTokens: 12, outputTokens: 3 }, 1, 1, [source])
const before = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before compaction' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: before.seq, end: before.seq },
sourceEventSeqs: [before.seq],
})
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 12,
outputTokens: 3,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
})
it('unregisters with the token-meter fiber and restores from a JSON checkpoint', async () => {
const { ctx, session, meterFiber } = await harness()
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 }, 1, 1)
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('tokenUsage')
await ctx.plugin(TokenMeterService)
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).tokenUsage).toEqual({
uncachedInputTokens: 8,
outputTokens: 2,
cacheReadTokens: 5,
cacheWriteTokens: 0,
})
})
})
const pressure = (ctx: Context, session: Session): ContextPressureProjection => {
const value = ctx.sessionProjections.snapshot(session).values.contextPressure
if (value === undefined) throw new Error('contextPressure projection is not registered')
return value
}
function recordContext(session: Session, model: string, contextWindow?: number): void {
session.append('request/context', {
provider: 'mock',
model,
...contextWindow === undefined ? {} : { contextWindow },
})
}
describe('contextPressure session projection', () => {
it('serves no pressure or capacity for an empty log', async () => {
const { ctx, session } = await harness()
expect(pressure(ctx, session)).toEqual({})
})
it('does not synthesize zero pressure before a provider usage sample', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 })
})
it('sums prompt-side buckets and excludes response output', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
usageChunk(session, {
inputTokens: 100,
outputTokens: 4_000,
cacheReadTokens: 20,
cacheWriteTokens: 5,
}, 1, 1)
// Output is deliberately absent: occupancy describes the prompt that was
// sent, so it holds still while the response streams.
expect(pressure(ctx, session).pressureTokens).toBe(125)
})
it('replaces pressure with the newest request rather than accumulating', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
const first = usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
finalUsage(session, { inputTokens: 100, outputTokens: 10 }, 1, 1, [first])
startStep(session, 2, 1)
usageChunk(session, { inputTokens: 250, outputTokens: 10 }, 2, 1)
expect(pressure(ctx, session).pressureTokens).toBe(250)
})
it('carries the newest recorded capacity and replaces it on a model switch', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 })
recordContext(session, 'large', 256_000)
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 })
})
it('removes an older capacity when the newest route advertises none', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
recordContext(session, 'unknown')
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 })
})
it('pushes no change for unrelated events or a restated capacity', async () => {
// The registry gates its change feed on Object.is, so a unit that rebuilt
// state for an event it does not care about would push phantom updates.
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
const changed: string[] = []
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
session.append('todo/write', { todos: [] })
expect(changed).not.toContain('contextPressure')
// A repeated capacity record for the same window is also a no-op.
recordContext(session, 'small', 64_000)
expect(changed).not.toContain('contextPressure')
// A real capacity change still reports.
recordContext(session, 'large', 256_000)
expect(changed).toContain('contextPressure')
})
it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
const { ctx, session, meterFiber } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 42, outputTokens: 2 }, 1, 1)
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
expect(checkpoint.contextPressure?.ver).toBe(2)
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
await ctx.plugin(TokenMeterService)
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({
pressureTokens: 42,
contextWindow: 64_000,
})
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../support/invariants"
}

View File

@@ -85,7 +85,7 @@ describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash re
expect(crashed.markerText).toBe('request-dispatched')
const events = await load(crashed.root)
expect(events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'request/header', 'step/end', 'turn/end',
'turn/start', 'user/message', 'step/start', 'request/header', 'request/context', 'step/end', 'turn/end',
])
expect(events.at(-1)).toMatchObject({
type: 'turn/end', data: { reason: { kind: 'interrupted' } },

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/support/acp-snapshot/README.md
README.md: 948c33a91977f078d16842c285011bf8f83623bd
README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977
README.md: e7988733827ef1d4de67d6d49764a4836e33d17c
README.zh.md: e2466feb5e2025cb99f252b4206bfacb711bccda

View File

@@ -8,8 +8,8 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge.

View File

@@ -8,8 +8,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[
- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent或在普通 Node 下启动已构建 `lib` agent通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。
- **`runScenario`harness**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript``configPath``tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。
- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 `{{cwd}}`手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin由可独立共享的 `system-prompt.expected.md``tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin由可独立共享的 `system-prompt.expected.md``tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。

View File

@@ -197,7 +197,7 @@ function tokenizeFixtureString(value: string, ctx: NormalizeContext, basename: s
+ String.raw`(?=$|[\\/\s<>'"()\[\]{},;:!?=])`,
'g',
)
return exact.replace(absoluteCwd, CWD)
return exact.replace(absoluteCwd, CWD).split(`/private${CWD}`).join(CWD)
}
/** Recursively replace generated-cwd spellings while preserving every other JSON value. */

View File

@@ -1269,9 +1269,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
assertUniqueSnapshotContents('tool-schema', schemas)
})
it('every committed JSONL has valid tool results and canonical header storage', async () => {
it('every committed JSONL has valid tool results and canonical fixture storage', async () => {
// Prompts and schemas always leave JSONL. Header pins retain prefixes;
// every other fixture tokenizes those too. Fixed-point checks make both
// every other fixture tokenizes those too. Portable cwd tokens never
// retain a platform realpath prefix. Fixed-point checks make these
// storage rules fail loud.
for (const scenario of scenarios) {
const dir = join(snapshotsDir, scenario.name)
@@ -1280,6 +1281,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const fixture = await readFile(join(dir, file), 'utf8')
expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`)
.toEqual([])
expect(fixture, `${scenario.name}/${file} carries a non-canonical macOS cwd token`)
.not.toContain('/private{{cwd}}')
expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`)
.toEqual(fixture)
expect(scrubToolSchemas(fixture), `${scenario.name}/${file} carries unscrubbed tool schemas`)

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