fix(ui-chat): show a stopped notice for turns interrupted by a DSH stop

This commit is contained in:
Turtle authored and turtle1999 committed 2026-10-08 11:14:35 +08:00
1 parent d83cdfafd6
commit 3121a4b5da
5 files changed
+48 -11

No files matched your search

@@ -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 }: {
<div className={css.turnErrorRow} role="status">
<StateDot state="error" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.turnErrorTitle}>{node.code === 'ACCOUNT_SIGNED_OUT' ? t('message.accountStopped') : t('message.turnError')}</span>
<span className={css.turnErrorTitle}>{node.code === 'ACCOUNT_SIGNED_OUT' || node.code === 'INTERRUPTED' ? t('message.taskStopped') : t('message.turnError')}</span>
<span className={css.turnErrorMessage}>{failureMessage(node.message, node.code, t)}</span>
</div>
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
@@ -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<TurnErrorState>): 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<TurnErrorState> = {
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
+4 -2
View File
@@ -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',
@@ -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(<h.ChatView {...h.props} />)
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.
@@ -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()
}
})