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()
+ }
+})