fix(client): address conversation assembly review

This commit is contained in:
imccyu
2026-08-09 20:08:37 +08:00
parent 126ad5bb02
commit fcbc97a88d
19 changed files with 102 additions and 23 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: df3689176cf059f46004fa7ecb31ab7c326ea0bc
README.zh.md: 9afe9773ff2a6a320bdf66978877678428c46118
README.md: d4031c2e3b7730bdb0075ac84c50ad3e46ce63a2
README.zh.md: ca6ac9f5e8efec3472c03b044739a9221e98183c
+1 -1
View File
@@ -58,7 +58,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.
## Session forking
+1 -1
View File
@@ -58,7 +58,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段约定,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久重试提示。该提示在后续重试轮次开始前为 `scheduled`源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry``llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
## 会话 fork
@@ -2,9 +2,7 @@ import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-agent/types'
type InboxTarget = 'next-turn' | 'next-step'
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
interface InboxIdentity {
readonly id: string
@@ -31,6 +31,7 @@ function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]
}
}
/** A scheduled attempt is cancelled once either owning boundary closes. */
function isClosed(location: ConversationLocation): boolean {
return (location.kind === 'step' && location.step.status === 'closed')
|| ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed')
@@ -465,6 +465,38 @@ describe('built-in conversation node Definitions', () => {
expect(node(snapshot(value), 'user')).toBeUndefined()
})
it('orders claimed steering after the finalized Turn tail', () => {
const steering = textMessage('steer-after-answer', 'change direction')
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-before-steering', 'initial answer'),
}, { surfaceOp: 'append' }),
at(4, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
inserted: [steering],
}),
at(5, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
removedCount: 1,
inserted: [],
}),
at(6, 'user/message', steering, { surfaceOp: 'append' }),
at(7, 'step/end', { turn: 1, step: 1 }),
at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
const current = snapshot(value)
const steeringNode = node(current, 'steering')
expect(steeringNode).toBeDefined()
expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
})
it('classifies appended producer context from durable source metadata', () => {
const value = assembler([
at(1, 'user/message', {
@@ -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-deliverables/README.md
README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2
README.zh.md: ba493549bd0bc3f8a2adbda5989448e497f0af93
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost.
`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row.
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
+1 -1
View File
@@ -4,7 +4,7 @@
产出文件功能的属主:把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot 中。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该界面,属主视图无需额外开销即可渲染空 slot。
`producedForClosing` 根据 tail slot 属主提供的当前数据,即定稿快照节点和收尾助手的 seq,推导一个轮次产出的文件。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind``edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一内按首见顺序只出现一次;累积在轮次边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一的行里。
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData``producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind``edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,不会溢进下一个 Turn 的行里。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* ui-deliverables browser half: the derivation contract of
* `producedForClosing` over finalized snapshot nodes, the row's rendering
* `producedForClosing` over engine-published Turn data, the row's rendering
* and opener wiring, and the plugin registrations' fiber-teardown removal
* (HMR safety) against the real SlotsService.
*/
@@ -12,7 +12,7 @@ import {
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition,
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, ToolResultNode, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -107,6 +107,10 @@ function at(
}
}
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
}
function call(
seq: number,
callId: string,
@@ -190,6 +194,50 @@ describe('produced-file Turn data', () => {
])
})
it('ignores calls without mutation locations, orphan results, and replacement results', () => {
const replacement = result(8, 'replacement')
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
result(3, 'no-view'),
call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
result(5, 'locationless-edit'),
result(6, 'orphan'),
call(7, 'replacement', diff('replaced.txt')),
{
...replacement,
event: {
...replacement.event,
surfaceOp: { op: 'replace', start: 1, end: 1 },
} as ConversationEventInput['event'],
},
at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
expect(producedForClosing(deliverablesOf(value))).toEqual([])
})
it('rejects an invalid start match and preserves state for an unrelated update', () => {
const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start')
const emptyContext: Parameters<typeof deliverablesDefinition.start>[0] = {
key: 'deliverables:1',
kind: 'deliverables',
id: '1',
matches: [startMatch],
start: startMatch,
state: undefined,
current: new Map(),
}
const reader: Parameters<typeof deliverablesDefinition.start>[2] = { previous: () => undefined }
const state = deliverablesDefinition.start(emptyContext, startMatch, reader)
const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update')
const context: Parameters<typeof deliverablesDefinition.update>[0] = { ...emptyContext, state }
expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader))
.toThrow('deliverables start requires turn/start')
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
})
it('replays a tail page once prepend supplies its missing Turn start', () => {
const value = assembler([
call(10, 'late', diff('history.txt')),
+2 -2
View File
@@ -9,9 +9,9 @@
import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { CompactionId } from './brand.ts'
import type { CompactionId } from './brand.ts'
export { CompactionId }
export type { CompactionId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { LlmFailure } from '@deepseek-ai/dsh-llm/types'
import { RetryId } from './brand.ts'
import type { RetryId } from './brand.ts'
export { RetryId }
export type { RetryId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
@@ -5,7 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
import { RetryId } from '@deepseek-ai/dsh-llm-retry/types'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import { providerForOpenStep } from '../src/history.ts'
async function setup(): Promise<Context> {
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import { RetryId } from '@deepseek-ai/dsh-llm-retry/types'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import type {} from '../src/index.ts'
const dirs: string[] = []
@@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compact/types'
import { CompactionId } from '@deepseek-ai/dsh-compact'
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
import {
estimateContent,
@@ -7,7 +7,7 @@ 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'
import { CompactionId } from '@deepseek-ai/dsh-compact/types'
import { CompactionId } from '@deepseek-ai/dsh-compact'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CompactionId } from '@deepseek-ai/dsh-compact/types'
import { CompactionId } from '@deepseek-ai/dsh-compact'
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
import {
type ReplayEntry,
+1
View File
@@ -79,6 +79,7 @@
"@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"],
"@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"],
"@deepseek-ai/dsh-user-interaction/types": ["./packages/interaction/user-interaction/src/types.ts"],
"@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"],
"@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"],
"@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"],
"@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"],
-1
View File
@@ -211,7 +211,6 @@ export default defineConfig({
'packages/client/ui-workspace/src/client/index.ts',
'packages/client/test-runtime/src/translate.ts',
'packages/client/ui-primitives/src/JsonTree.tsx',
'packages/client/ui-deliverables/src/client/turn-deliverables.ts',
// Typert generator: correctness is pinned by its fixture suites and
// the byte-for-byte catalog reproduction test; per-file coverage
// would put whole-workspace compiler analysis under v8