Merge pull request #2346 from deepseek-harness/fix/web-max-tokens-turn-end-notice
fix(web): surface max-tokens turn ends as a localized truncation notice
This commit is contained in:
@@ -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<string, unknown>[] = []
|
||||
@@ -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).
|
||||
@@ -1444,7 +1459,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<SessionId, number>([[sid('fx-alpha'), 74]])
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 75]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
let attachedSessions = options.empty ? 0 : 1
|
||||
|
||||
@@ -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: 441f5462d98c8a9e92ebc9f08cdd7568e89fd427
|
||||
README.zh.md: 9e31d51410639cdd314ddd37c2c6287f26f8c484
|
||||
|
||||
@@ -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 and restore.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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` 到达调用方,避免重试创建重复的子会话。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -169,6 +169,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'
|
||||
@@ -274,6 +285,7 @@ export type ConversationNode =
|
||||
| ContextMessageNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
| TurnMaxTokensNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| CompactionSummaryNode
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.turnErrorRow} role="status">
|
||||
<StateDot state="warning" className={css.turnErrorDot} />
|
||||
<div className={css.turnErrorCopy}>
|
||||
<span className={css.maxTokensTitle}>{t('message.maxTokens')}</span>
|
||||
<span className={css.turnErrorMessage}>{t('message.maxTokens.hint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <TurnErrorItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Max-tokens turn-end notice keyed Chat renderer. */
|
||||
export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) {
|
||||
return <TurnMaxTokensItem t={t} />
|
||||
})
|
||||
|
||||
/** Explicit unknown-surface keyed Chat renderer. */
|
||||
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
|
||||
const data = node.data
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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<TurnMaxTokensState>): 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TurnMaxTokensState>, 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 }
|
||||
}
|
||||
|
||||
/** Notice Definition for a turn the provider ended at its output-token cap. */
|
||||
export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensState> = {
|
||||
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
|
||||
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', noticeAnchor(context, state.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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type {} from './conversation-nodes/message.ts'
|
||||
export type {} from './conversation-nodes/retry.ts'
|
||||
export type {} from './conversation-nodes/tool.ts'
|
||||
export type {} from './conversation-nodes/turn-error.ts'
|
||||
export type {} from './conversation-nodes/turn-max-tokens.ts'
|
||||
export type {} from './conversation-nodes/turn-tail.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
|
||||
@@ -122,6 +122,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',
|
||||
@@ -289,6 +291,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',
|
||||
|
||||
@@ -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<ConversationSnapshot>) {
|
||||
return <RetryNodeView {...nodeProps<'model-retry'>()} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
|
||||
case 'turn-max-tokens':
|
||||
return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
|
||||
case 'turn-tail':
|
||||
return (
|
||||
<TurnTailNodeView
|
||||
@@ -585,6 +590,16 @@ describe('ChatView', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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(<h.ChatView {...h.props} />)
|
||||
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')],
|
||||
|
||||
@@ -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,75 @@ 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()
|
||||
// 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 }),
|
||||
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<typeof turnMaxTokensDefinition.start>[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<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[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<typeof turnMaxTokensDefinition.update>[0],
|
||||
match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
)).toBe(state)
|
||||
expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
|
||||
})
|
||||
|
||||
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', {
|
||||
|
||||
Reference in New Issue
Block a user