From 3121a4b5da9dfce7d64af4c224de8dd32c12a4b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 8 Oct 2026 11:05:17 +0800 Subject: [PATCH] fix(ui-chat): show a stopped notice for turns interrupted by a DSH stop --- .../ui-chat/src/client/chat/MessageItem.tsx | 3 +- .../client/conversation-nodes/turn-error.ts | 32 ++++++++++++++----- packages/client/ui-chat/src/client/locale.ts | 6 ++-- .../ui-chat/tests/chat-view.client.spec.tsx | 2 ++ ...nversation-node-definitions.client.spec.ts | 16 ++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 9005150af0..c45ca736cd 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -53,6 +53,7 @@ function failureMessage( t: ChatViewSlotProps['t'], ): string { if (code === 'ACCOUNT_SIGNED_OUT') return t('message.failure.accountSignedOut') + if (code === 'INTERRUPTED') return t('message.failure.interrupted') if (code === 'ACCOUNT_SIGN_IN_REQUIRED') return t('message.failure.accountSignInRequired') if (code === 'QUOTA' || code === 'ACCOUNT_QUOTA') return t('message.failure.quota') return code === 'AUTH' ? t('message.failure.auth') : message @@ -135,7 +136,7 @@ function TurnErrorItem({ node, t }: {
- {node.code === 'ACCOUNT_SIGNED_OUT' ? t('message.accountStopped') : t('message.turnError')} + {node.code === 'ACCOUNT_SIGNED_OUT' || node.code === 'INTERRUPTED' ? t('message.taskStopped') : t('message.turnError')} {failureMessage(node.message, node.code, t)}
{node.code !== undefined && {node.code}} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index 445d073639..d95b79ca05 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnEndReason } from '@deepseek-ai/dsh-session/types' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' @@ -28,13 +29,30 @@ function lastStep(context: ConversationNodeContext): number { return location.turn.steps.at(-1)?.step ?? 0 } +/** + * Failure shown for a turn ending: a recorded error, a sign-out cancellation, + * or a turn closed after DSH stopped running mid-turn. + */ +function endFailure(reason: TurnEndReason): { readonly message: string; readonly code?: string } | undefined { + switch (reason.kind) { + case 'error': + return reason.error + case 'aborted': + return reason.reason.kind === 'hook' && reason.reason.reason === 'deepseek-account/signed-out' + ? { message: 'Stopped because you signed out of DeepSeek.', code: 'ACCOUNT_SIGNED_OUT' } + : undefined + case 'interrupted': + return { message: 'DSH stopped running before this turn finished.', code: 'INTERRUPTED' } + default: + // Completed, blocked, max-tokens, forked, user/parent cancellation, and + // plugin-merged endings have no failure row. + return undefined + } +} + function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { if (match.event.type !== 'turn/end') return undefined - const reason = match.event.data.reason - const failure = reason.kind === 'error' ? reason.error - : reason.kind === 'aborted' && reason.reason.kind === 'hook' - && reason.reason.reason === 'deepseek-account/signed-out' - ? { message: 'Stopped because you signed out of DeepSeek.', code: 'ACCOUNT_SIGNED_OUT' } : undefined + const failure = endFailure(match.event.data.reason) if (failure === undefined) return undefined const display = displayFailure(failure) return { @@ -63,9 +81,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } - if (event.type === 'turn/end' && (event.data.reason.kind === 'error' - || (event.data.reason.kind === 'aborted' && event.data.reason.reason.kind === 'hook' - && event.data.reason.reason.reason === 'deepseek-account/signed-out'))) { + if (event.type === 'turn/end' && endFailure(event.data.reason) !== undefined) { return { id: String(event.data.turn), role: 'update' } } return null diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 4c4bfa7e12..7dc00f2b2b 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -161,8 +161,9 @@ export const zh = { 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', 'message.failure.auth': 'API 密钥无效', - 'message.accountStopped': '任务已停止', + 'message.taskStopped': '任务已停止', 'message.failure.accountSignedOut': '任务已因退出 DeepSeek 登录而停止。', + 'message.failure.interrupted': 'DSH 在本轮完成前停止运行,未完成的工具调用结果未知。', 'message.failure.accountSignInRequired': '请先登录 DeepSeek,并确认请求地址支持账号认证。', 'message.failure.quota': '当前请求的额度已用尽', 'message.turnError': '本轮运行失败', @@ -354,8 +355,9 @@ export const en = { 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', 'message.failure.auth': 'API key is invalid', - 'message.accountStopped': 'Task stopped', + 'message.taskStopped': 'Task stopped', 'message.failure.accountSignedOut': 'Stopped because you signed out of DeepSeek.', + 'message.failure.interrupted': 'DSH stopped running before this turn finished. Unfinished tool calls have unknown outcomes.', 'message.failure.accountSignInRequired': 'Sign in to DeepSeek and ensure the request destination supports account authentication.', 'message.failure.quota': 'Request quota exhausted.', 'message.turnError': 'This turn failed', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 47931e1f23..e664e2db5b 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2128,6 +2128,7 @@ describe('ChatView', () => { it('renders every terminal failure inline with neutral quota copy and no transient notice', () => { const h = makeHarness({ nodes: [ user(1, 'try'), turnError(2, 'AUTH'), turnError(3), turnError(4, 'QUOTA'), turnError(5, 'ACCOUNT_QUOTA'), + turnError(6, 'INTERRUPTED'), ] }) const view = render() const statuses = view.getAllByRole('status') @@ -2136,6 +2137,7 @@ describe('ChatView', () => { '本轮运行失败plugin exploded', '本轮运行失败当前请求的额度已用尽QUOTA', '本轮运行失败当前请求的额度已用尽ACCOUNT_QUOTA', + '任务已停止DSH 在本轮完成前停止运行,未完成的工具调用结果未知。INTERRUPTED', ]) // The transient notice is the frame-wide host's job; the failure row keeps // neither a recharge affordance nor a toast of its own. diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3823a0dbd5..040c686140 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -2800,3 +2800,19 @@ it('retains a sign-out cancellation notice when reopening a partial turn', () => code: 'ACCOUNT_SIGNED_OUT', message: 'Stopped because you signed out of DeepSeek.', }) }) + +it('reports a turn closed after its process exited as a stopped turn', () => { + const value = assembler([ + at(7, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }), + ], true) + expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({ + code: 'INTERRUPTED', message: 'DSH stopped running before this turn finished.', + }) +}) + +it('keeps user cancellations and forked endings free of a failure row', () => { + for (const reason of [{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'forked' }] as const) { + const value = assembler([at(7, 'turn/end', { turn: 1, reason })], true) + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + } +})