diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml new file mode 100644 index 0000000000..efda56abab --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md +2026-08-12-max-tokens-turn-end-notice.md: bdb34d67a6d8271089af04f6bcd0f046ef7cdea6 +2026-08-12-max-tokens-turn-end-notice.zh.md: 9b9c679972570e791b607330195810d68f04c803 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md new file mode 100644 index 0000000000..bdb34d67a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md @@ -0,0 +1,27 @@ +# Agent Note: The chat flow surfaces a max-tokens turn end + +Status: implemented + +English | [中文](2026-08-12-max-tokens-turn-end-notice.zh.md) + +## Problem + +The agent loop records `max-tokens` as its own `turn/end` reason, but no user surface consumed it. In the Web chat flow only `reason.kind === 'error'` built a conversation node, and the unknown-surface fallback claims append-surface events only, so a turn the provider cut at its output cap ended with no visible sign: the truncated answer read as a normal completion, and the user had no way to tell why the run stopped (issue #1522). + +## Decision + +A `turn-max-tokens` conversation node Definition matches `turn/end` with `reason.kind === 'max-tokens'` and materializes a persistent chat row at the turn position: a warning StateDot, a localized title, and guidance that the truncated output is preserved and sending "continue" resumes in a new turn. The node derives from the durable session event alone, so refresh, restore, and history replay rebuild it identically. It shows no token numbers: the event carries none, and the notice must not fabricate budget data the provider did not report. + +The renderer registers under the keyed `conversation.chat.node` seat like every chat row, and the legacy chat-snapshot contribution includes the node. The fixture history gained a max-tokens sample turn (72; the image and todo turns shifted to 73 and 74), and an assembled keyless snapshot pins the dot state, title, and hint, so a regression that routes max-tokens through the error presentation or silences it again changes a golden. + +## Alternatives considered + +**Extending `turn-error` with a max-tokens arm** — rejected: the acceptance for issue #1522 requires that max-tokens not read as a provider error; a shared node kind couples the two presentations, and the two reasons carry different data (an error payload versus nothing). + +**A turn-tail marker instead of a flow row** — rejected: the tail renders closing chrome for a finished turn and its actions collapse on later turns, while the truncation notice must stay at the turn that was cut and remain visible in history without interaction. + +**A continue or retry action button on the notice** — deferred: resuming has open semantics (new turn versus same-turn splice, old-output retention rules) that issue #1522 explicitly leaves out of scope; guidance text carries the safe next step without committing to an action contract. + +## Consequences + +Max-tokens turn ends are visible, localized, and distinct from both errors and normal completion across live streaming, reload, and replay. The fixture renumbering cost two comment updates in dependent snapshots, and anything pinning fixture turn numbers must count from the new layout. Surfaces other than the Web chat flow (ACP and SDK consumers) keep mapping the reason through their own presentations and are unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md new file mode 100644 index 0000000000..9b9c679972 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 聊天流展示 max-tokens 结束的轮次 + +Status: implemented + +[English](2026-08-12-max-tokens-turn-end-notice.md) | 中文 + +## Problem + +agent loop 已把 `max-tokens` 记录为独立的 `turn/end` 原因,但没有任何用户表面消费它。Web 聊天流中只有 `reason.kind === 'error'` 会生成会话节点,unknown-surface 兜底又只接管 append-surface 事件,于是被提供方在输出上限处截断的轮次没有任何可见迹象:被截断的回答看起来和正常完成一样,用户无从得知运行为何停止(issue #1522)。 + +## Decision + +新增 `turn-max-tokens` 会话节点 Definition,匹配 `reason.kind === 'max-tokens'` 的 `turn/end`,在该轮位置生成一条持久聊天行:warning 状态的 StateDot、本地化标题,以及说明已截断输出会保留、发送“继续”可在新一轮接着输出的指引。节点只从持久会话事件推导,因此刷新、恢复和历史回放会重建出完全一致的结果。提示不显示任何 token 数字:事件本身不携带数量,提示也不得伪造提供方未报告的预算数据。 + +渲染器与其他聊天行一样注册在按 kind 分发的 `conversation.chat.node` 槽位下,legacy chat-snapshot 投影也包含该节点。fixture 历史新增了一个 max-tokens 样本轮(72,图片轮和 todo 轮顺移为 73、74),并有一条 assembled keyless snapshot 钉住圆点状态、标题和指引文案,把 max-tokens 路由回错误样式或再次静默的回归都会改动 golden。 + +## Alternatives considered + +**在 `turn-error` 上加一个 max-tokens 分支** — 否决:issue #1522 的验收要求 max-tokens 不得呈现为普通 provider error;共用节点会耦合两种呈现,且两种原因携带的数据不同(一个有错误负载,一个没有)。 + +**用 turn-tail 标记代替独立聊天行** — 否决:turn-tail 渲染的是完成轮次的收尾信息,其操作会在后续轮次折叠,而截断提示必须停留在被截断的那一轮,并且在历史中无需交互即可看到。 + +**在提示上放继续或重试按钮** — 暂缓:恢复输出的语义尚未确定(新开一轮还是同轮续写、旧输出保留规则),issue #1522 明确把它排除在范围外;指引文字已给出安全的下一步,不必先固定一个操作契约。 + +## Consequences + +max-tokens 结束在实时流、刷新和回放中都可见、已本地化,并与错误和正常完成明确区分。fixture 重编号需要更新两处依赖 snapshot 的注释,之后钉 fixture 轮次号的改动要按新布局计数。Web 聊天流之外的表面(ACP 和 SDK 消费方)仍按各自的呈现映射该原因,本次不变。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 2d2dda42d6..71389fb908 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture // idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). -// Opens the fixture history session whose turn 72 carries an image in BOTH a +// Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the single-click ImageLightbox, and the composer diff --git a/apps/web/tests/max-tokens-notice.snapshot.ts b/apps/web/tests/max-tokens-notice.snapshot.ts new file mode 100644 index 0000000000..45432e702b --- /dev/null +++ b/apps/web/tests/max-tokens-notice.snapshot.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the +// keyless FixtureApiClient transport, opens the fixture session, and pins the +// surface its max-tokens turn (72) reaches — the turn-end notice row that a +// provider output-cap truncation must render instead of ending silently. +// +// The dot state is pinned beside the copy on purpose: `dot=warning` is what +// distinguishes this notice from the error row, so a regression that routes +// max-tokens through the turn-error presentation changes this file even when +// its own copy still renders. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt') + +installAssembledBootEnv() + +/** Normalize the notice row to stable fields: its dot state, title, and hint. */ +function noticeShape(row: Element): string { + const first = (name: string): string => + [...row.querySelectorAll('*')].filter(el => hasClass(el, name))[0]?.textContent?.trim() ?? '' + return [ + `dot=${row.querySelector('[data-state]')?.getAttribute('data-state') ?? ''}`, + `title=${first('maxTokensTitle')}`, + `hint=${first('turnErrorMessage')}`, + ].join('\n') +} + +describe('assembled max-tokens turn-end notice', () => { + it('renders the localized truncation notice after the cut-off answer instead of ending silently', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // The truncated answer itself stays in the flow: the notice supplements the + // partial output, it never replaces it. + await screen.findByText(/条目 3:这一条写到一半被/, undefined, { timeout: 10_000 }) + const row = await waitFor(() => { + const found = [...document.querySelectorAll('[role="status"]')] + .find(candidate => [...candidate.querySelectorAll('*')].some(el => hasClass(el, 'maxTokensTitle'))) + expect(found).not.toBeUndefined() + return found! + }, { timeout: 10_000 }) + + const shape = noticeShape(row) + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt b/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt new file mode 100644 index 0000000000..d6cc939648 --- /dev/null +++ b/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt @@ -0,0 +1,3 @@ +dot=warning +title=Output token limit reached +hint=The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume. \ No newline at end of file diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index 8186905f62..f39d1ba970 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8085c7d323..a5c8480e1f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -352,7 +352,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } } -/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), +/** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record[] = [] @@ -488,7 +488,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 74: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -547,20 +547,35 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') - // Turn 72: user and assistant images share one durable fixture object. - // The todo turn remains last so its standing projection stays visible. + // Turn 72: max-tokens sample — the provider ends the turn at its output cap + // mid-sentence, so the chat flow must render the turn-max-tokens notice + // instead of ending silently. Ordered before the todo turn for the same + // standing-plan reason the bash turn is. push({ type: 'turn/start', data: { turn: 72 } }) + push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text('问题 72:请完整列出全部一百条条目。')) }) + push({ type: 'step/start', data: { turn: 72, step: 0 } }) + push({ + type: 'assistant/message', + surfaceOp: 'append', + data: { turn: 72, step: 0, message: assistantMessage(text('条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被')) }, + }) + push({ type: 'step/end', data: { turn: 72, step: 0 } }) + push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'max-tokens' } } }) + + // Turn 73: user and assistant images share one durable fixture object. + // The todo turn remains last so its standing projection stays visible. + push({ type: 'turn/start', data: { turn: 73 } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), }) - push({ type: 'step/start', data: { turn: 72, step: 0 } }) + push({ type: 'step/start', data: { turn: 73, step: 0 } }) push({ type: 'assistant/message', surfaceOp: 'append', data: { - turn: 72, + turn: 73, step: 0, message: assistantMessage( [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], @@ -568,11 +583,11 @@ function buildAlphaLog(): SessionEvent[] { ), }, }) - push({ type: 'step/end', data: { turn: 72, step: 0 } }) - push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } }) + push({ type: 'step/end', data: { turn: 73, step: 0 } }) + push({ type: 'turn/end', data: { turn: 73, reason: { kind: 'completed' } } }) const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(74, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index bd5ce42dfe..dbf10f1aa7 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: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446 -README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294 +README.md: d9dd7ce339172068e0229f321b4f87a3e17a133e +README.zh.md: a47104fc75f2f2adc94d36a1e849680ae19f9bab diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 44fd9b84e4..d9dd7ce339 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 7c5a70ef5d..a47104fc75 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -68,6 +68,8 @@ 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 重建该节点,所以刷新、恢复和历史回放后结束原因保持一致。 + ## 会话 fork `ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 1b176254b9..7f6c06a2f4 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f39156baf2..300b2985ed 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index cc1ded9822..36824d228d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -244,6 +244,12 @@ font: var(--dsw-font-markdown-code-block-small); } +.maxTokensTitle { + margin-right: 6px; + color: var(--dsw-alias-state-warn-primary); + font-weight: 600; +} + @keyframes retry-shimmer { from { background-position: 100% 50%; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index d2bfe8868f..00b5110d68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: { ) } +/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */ +function TurnMaxTokensItem({ t }: { + t: ChatViewSlotProps['t'] +}) { + return ( +
+ +
+ {t('message.maxTokens')} + {t('message.maxTokens.hint')} +
+
+ ) +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch return }) +/** Max-tokens turn-end notice keyed Chat renderer. */ +export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) { + return +}) + /** Explicit unknown-surface keyed Chat renderer. */ export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) { const data = node.data diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 78aa36d136..20d42a9a82 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, - UnknownNodeView, UserMessageNodeView, + TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' import { TurnTailNodeView } from './TurnTailNodeView.tsx' @@ -35,6 +35,8 @@ export function registerChatNodeRenderers(ctx: Context): void { { name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'turn-tail', diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index 8b5a506030..be4e27111a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { case 'command': case 'compaction': case 'turn-error': + case 'turn-max-tokens': case 'unknown': return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null } case 'assistant-step': { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts index bc911ad5db..5086253e81 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts' import { registerRetryConversationNode } from './retry.ts' import { registerToolConversationNode } from './tool.ts' import { registerTurnErrorConversationNode } from './turn-error.ts' +import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts' import { registerTurnTailConversationNode } from './turn-tail.ts' /** @@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void { registerCompactionConversationNode(ctx) registerRetryConversationNode(ctx) registerTurnErrorConversationNode(ctx) + registerTurnMaxTokensConversationNode(ctx) registerTurnTailConversationNode(ctx) registerUnknownConversationFallback(ctx) registerChatConversationView(ctx) 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 new file mode 100644 index 0000000000..4b1cabf7d1 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts @@ -0,0 +1,68 @@ +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' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Turn ended by the per-request output-token cap. */ + 'turn-max-tokens': TurnMaxTokensNode + } +} + +interface TurnMaxTokensState { + readonly turn: number + readonly seq: number + readonly time: number +} + +function lastStep(context: ConversationNodeContext): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return 0 + return location.turn.steps.at(-1)?.step ?? 0 +} + +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 } +} + +/** Notice Definition for a turn the provider ended at its output-token cap. */ +export const turnMaxTokensDefinition: ConversationNodeDefinition = { + kind: 'turn-max-tokens', + target: 'chat', + match: (event) => { + if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') { + return { id: String(event.data.turn), role: 'start' } + } + return null + }, + start: (_context, match) => { + const state = stateFrom(match) + if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end') + return state + }, + update: context => 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', + seq: state.seq, + time: state.time, + turn: state.turn, + step: lastStep(context), + } + return chatNode(context, 'turn-max-tokens', node.seq, node) + }, +} + +/** + * Register the max-tokens turn-end notice contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnMaxTokensConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnMaxTokensDefinition) +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 080bb9b67b..be526787b0 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -114,6 +114,8 @@ export const zh = { 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', 'message.turnError': '本轮运行失败', + 'message.maxTokens': '已达到输出 token 上限', + 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', 'message.ranFor': '用时 {duration}', 'message.ttft': '首 token {seconds}秒', 'message.tokensPerSecond': '{tps} tok/s', @@ -273,6 +275,8 @@ export const en = { 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', 'message.turnError': 'This turn failed', + 'message.maxTokens': 'Output token limit reached', + 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', 'message.ranFor': 'Ran for {duration}', 'message.ttft': 'TTFT {seconds}s', 'message.tokensPerSecond': '{tps} tok/s', diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 1dad50cb68..bbb73610c0 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, - UserMessageNode, WorkspaceListState, + TurnMaxTokensNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { @@ -28,7 +28,7 @@ import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, - UnknownNodeView, UserMessageNodeView, + TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' @@ -108,6 +108,9 @@ const turnError = (seq: number, code?: string): TurnErrorNode => ({ message: seq === 2 ? 'API key is invalid' : 'plugin exploded', ...(code === undefined ? {} : { code }), }) +const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({ + kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0, +}) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, @@ -217,6 +220,8 @@ function makeHarness(init?: Partial) { return ()} /> case 'turn-error': return ()} /> + case 'turn-max-tokens': + return ()} /> case 'turn-tail': return ( { ]) }) + it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => { + const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] }) + const view = render() + const statuses = view.getAllByRole('status') + expect(statuses.map(status => status.textContent)).toEqual([ + '已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', + ]) + expect(view.queryByText('本轮运行失败')).toBeNull() + }) + it('hands the trajectory callback to the Tool seat', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')], diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index db9c0ccdd4..1c5f750763 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -14,6 +14,7 @@ import { messageDefinition } from '../src/client/conversation-nodes/message.ts' import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' +import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' import type { AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, @@ -29,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ compactionDefinition, retryDefinition, turnErrorDefinition, + turnMaxTokensDefinition, turnTailDefinition, ] @@ -812,6 +814,74 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'turn-error')).toBeUndefined() }) + it('materializes a max-tokens notice and keeps completed and error turns clean', () => { + 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('a1', 'truncated answer'), + }, { surfaceOp: 'append' }), + at(4, 'step/end', { turn: 1, step: 1 }), + at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }), + ]) + 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() + + const completed = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined() + + const failed = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ]) + expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined() + expect(node(snapshot(failed), 'turn-error')).toBeDefined() + }) + + it('keeps the max-tokens notice when the window starts after the owning turn/start', () => { + const value = assembler([ + at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }), + ], true) + const notice = node(snapshot(value), 'turn-max-tokens') + expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 }) + }) + + it('pins the max-tokens Definition edges the engine cannot reach', () => { + // The engine only hands start the single matched turn/end and never emits + // update Matches for this kind; these direct calls pin the declared + // behavior of both required Definition members anyway. + const match = (seq: number, type: string, data: unknown) => ({ + event: { seq, time: seq * 1_000, type, data }, + view: undefined, + role: 'start', + location: undefined, + }) as unknown as Parameters[1] + const context = (state: unknown, matches: unknown[] = []) => ({ + key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(), + }) as unknown as Parameters>[0] + const reader = { previous: () => undefined } + + expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader)) + .toThrow('turn-max-tokens start requires a max-tokens turn/end') + const state = { turn: 1, seq: 5, time: 5_000 } + expect(turnMaxTokensDefinition.update( + context(state) as Parameters[0], + 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', () => { const value = assembler([ at(12, 'tool/code-dispatch-start', {