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:
@@ -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