diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a5c8480e1f..431ba0c1b9 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1447,7 +1447,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], ]) let fixtureDefaultPreset = 'standard' - const nextTurn = new Map([[sid('fx-alpha'), 74]]) + const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index dbf10f1aa7..a15becc6a0 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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: d9dd7ce339172068e0229f321b4f87a3e17a133e -README.zh.md: a47104fc75f2f2adc94d36a1e849680ae19f9bab +README.md: 441f5462d98c8a9e92ebc9f08cdd7568e89fd427 +README.zh.md: 9e31d51410639cdd314ddd37c2c6287f26f8c484 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index d9dd7ce339..441f5462d9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -68,7 +68,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start 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. -A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh, restore, and history replay. +A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore. ## Session forking diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index a47104fc75..9e31d51410 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -68,7 +68,7 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别 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 节点。 -reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,所以刷新、恢复和历史回放后结束原因保持一致。 +reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。 ## 会话 fork diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts index b6bd01930b..1e2693bfff 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -7,11 +7,14 @@ import type { /** * Relative positions in one durable event's seq neighborhood: interrupted - * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. + * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The + * max-tokens notice sits between a closing Assistant and the turn-tail so the + * tail stays the turn's last node and keeps its branch action enabled. */ export const CHAT_SYNTHETIC_SEQ_OFFSETS = { interruptedAssistant: -0.9, interruptedFollowup: -0.8, + maxTokensNotice: 0.05, finalizedFollowup: 0.1, } as const diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts index 4b1cabf7d1..baf83a1b3c 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -23,6 +23,21 @@ function lastStep(context: ConversationNodeContext): number return location.turn.steps.at(-1)?.step ?? 0 } +/** + * Anchor the notice between the closing Assistant and the turn-tail so the + * tail stays the turn's last Chat node and keeps its branch action enabled. + * Without a closing text Assistant there is no branch action to protect, and + * the turn/end seq keeps the notice at the truncation point. + */ +function noticeAnchor(context: ConversationNodeContext, seq: number): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return seq + const closing = location.turn.data.get('turn-tail')?.closing + return closing === null || closing === undefined + ? seq + : closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice +} + function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined { if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time } @@ -46,7 +61,6 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition context.state, buildViewNode: (context) => { const state = context.state - ?? context.matches.map(stateFrom).find(candidate => candidate !== undefined) if (state === undefined) return null const node: TurnMaxTokensNode = { kind: 'turn-max-tokens', @@ -55,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition { const notice = node(snapshot(value), 'turn-max-tokens') expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 }) expect(node(snapshot(value), 'turn-error')).toBeUndefined() + // The tail stays the turn's last node so its branch action survives; the + // notice slots between the truncated closing Assistant and the tail. + const tail = node(snapshot(value), 'turn-tail') + expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY) + expect(notice?.anchorSeq).toBeGreaterThan(3) const completed = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -876,10 +881,6 @@ describe('built-in conversation node Definitions', () => { match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), )).toBe(state) expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull() - expect(turnMaxTokensDefinition.buildViewNode?.(context( - undefined, - [match(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } })], - ))).toMatchObject({ kind: 'turn-max-tokens' }) }) it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {