fix(web): surface max-tokens turn ends as a localized truncation notice

A turn the provider ends at its per-request output cap previously produced
no visible sign in the Web chat flow: only error-kind turn/end events built
a conversation node, so the truncated answer read as a normal completion.

Add a turn-max-tokens conversation node Definition over the durable
turn/end event, a warning-styled localized notice row distinct from
turn-error, a fixture max-tokens sample turn, and an assembled keyless
snapshot pinning the dot state, title, and hint.

Closes #1522
This commit is contained in:
creatixchu
2026-08-12 15:36:27 +08:00
parent 3784607f3d
commit e4ca03cb85
22 changed files with 356 additions and 17 deletions

View File

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

View File

@@ -68,6 +68,8 @@ 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.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.

View File

@@ -68,6 +68,8 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别
Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry``llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、TurnStep 事件一起折叠:失败步骤的流式输出片段会被移除,并在 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 重建该节点,所以刷新、恢复和历史回放后结束原因保持一致。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。

View File

@@ -77,7 +77,8 @@ export type {
CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,

View File

@@ -163,6 +163,17 @@ export interface TurnErrorNode {
code?: string
}
/** Durable notice for a turn ended by the per-request output-token cap. */
export interface TurnMaxTokensNode {
kind: 'turn-max-tokens'
/** Seq of the owning turn/end event. */
seq: number
/** Unix epoch ms from the turn/end event. */
time: number
turn: number
step: number
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -268,6 +279,7 @@ export type ConversationNode =
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
| TurnMaxTokensNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode