From 830e298a045cff1017c6f928dd54df0210ccc609 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 8 Oct 2026 11:37:51 +0800 Subject: [PATCH] fix(ui-chat): summarize interrupted turns as stopped and pin the notice in a web snapshot --- apps/web/tests/interrupted-turn.e2e.ts | 77 +++++++++++++++++++ apps/web/tsconfig.json | 1 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 2 +- .../src/client/chat/TurnProcessNodeView.tsx | 9 ++- .../src/client/contract/turn-process.ts | 2 +- .../client/conversation-nodes/turn-error.ts | 4 +- .../tests/chat-snapshot-fixture.client.ts | 5 +- .../ui-chat/tests/chat-view.client.spec.tsx | 17 ++++ ...nversation-node-definitions.client.spec.ts | 8 ++ .../src/client/contract/records.ts | 11 ++- snapshots/web/interrupted-turn/snapshot.yml | 9 +++ snapshots/web/interrupted-turn/ui.expected.md | 28 +++++++ 12 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 apps/web/tests/interrupted-turn.e2e.ts create mode 100644 snapshots/web/interrupted-turn/snapshot.yml create mode 100644 snapshots/web/interrupted-turn/ui.expected.md diff --git a/apps/web/tests/interrupted-turn.e2e.ts b/apps/web/tests/interrupted-turn.e2e.ts new file mode 100644 index 0000000000..ba78f2cfc9 --- /dev/null +++ b/apps/web/tests/interrupted-turn.e2e.ts @@ -0,0 +1,77 @@ +// Web e2e scenario: a turn closed because DSH stopped mid-turn. Borrow the +// seeded-history recording, end its turn with the `interrupted` reason that +// restart recovery writes, and pin the stopped summary and notice without any +// model call. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/interrupted-turn', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'interrupted-turn-web-e2e' +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const COMPLETED_END = '{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}' +const INTERRUPTED_END = '{"type":"turn/end","data":{"turn":1,"reason":{"kind":"interrupted"}}}' + +describe.skipIf(MODE === 'record')('web e2e: turn interrupted by a DSH stop', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const recorded = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(recorded)).toEqual([PROMPT]) + expect(recorded.trimEnd().endsWith(COMPLETED_END)).toBe(true) + const fixture = `${recorded.trimEnd().slice(0, -COMPLETED_END.length)}${INTERRUPTED_END}\n` + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.clock.setFixedTime(WEB_FIXTURE_TIME) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows the stopped summary and the interruption notice', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-interrupted-turn')) + const notice = page.getByRole('status').filter({ hasText: 'Task stopped' }) + await notice.waitFor({ timeout: 15_000 }) + expect(await notice.textContent()).toContain('DSH stopped running before this turn finished.') + expect(await notice.textContent()).toContain('INTERRUPTED') + expect(await page.locator('[data-turn-process="1"]').textContent()).toBe('Stopped') + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 295c9d1c56..8aa7829232 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -183,6 +183,7 @@ "tests/auto-review-approval.e2e.ts", "tests/auto-review-denial.e2e.ts", "tests/bash-abort-row.e2e.ts", + "tests/interrupted-turn.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/tool-details.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 443cb0407a..f9d463a64e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -144,7 +144,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou || (policy.stepGrouping === 'history' && turnLocation?.status !== 'open')) const reason = turnLocation?.end?.data.reason.kind const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true - || reason === 'aborted' || reason === 'error' + || reason === 'aborted' || reason === 'interrupted' || reason === 'error' const spec = presentation?.spec const selectStored = useCallback((state: Readonly) => turn === undefined ? undefined : storedTurnProcessEntry(state, turn), [turn]) diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index e897b061e0..9dedb81cee 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -18,16 +18,17 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ if (turn?.status !== 'closed') return null const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node) const reason = turn.end?.data.reason.kind + const stopped = reason === 'aborted' || reason === 'interrupted' const elapsedMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(1000, turn.end.time - turn.start.time) - const duration = elapsedMs === undefined || reason === 'aborted' || reason === 'error' ? undefined + const duration = elapsedMs === undefined || stopped || reason === 'error' ? undefined : formatRunDuration(elapsedMs, t) - // Other end reasons retain elapsed time; only cancellation and failure replace it. - const label = reason === 'aborted' ? t('message.stopped') + // Other end reasons retain elapsed time; only stops and failure replace it. + const label = stopped ? t('message.stopped') : reason === 'error' ? t('message.turnProcess.failed') : duration === undefined ? t('message.turnProcess.worked') : t('message.turnProcess.took') - const announcement = reason === 'aborted' ? t('message.stopped') + const announcement = stopped ? t('message.stopped') : reason === 'error' ? t('message.turnProcess.failed') : t('message.turnProcess.worked') return ( diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 8b7d89f64e..a5126cacc7 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -70,5 +70,5 @@ export function turnProcessAlwaysOpen(node: ChatNode | undefined): boolean { const location = node?.location if (location?.kind !== 'turn' && location?.kind !== 'step') return false const reason = location.turn.end?.data.reason.kind - return location.turn.status === 'open' || reason === 'aborted' || reason === 'error' + return location.turn.status === 'open' || reason === 'aborted' || reason === 'interrupted' || reason === 'error' } 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 d95b79ca05..d7c260d92c 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 @@ -38,14 +38,14 @@ function endFailure(reason: TurnEndReason): { readonly message: string; readonly case 'error': return reason.error case 'aborted': + // User and parent cancellations show only the stopped turn summary. 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. + // Completed, blocked, max-tokens, forked, and plugin-merged endings have no failure row. return undefined } } diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 38322b1772..30482b395d 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -8,6 +8,7 @@ import type { ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { TurnEndReason } from '@deepseek-ai/dsh-session/types' import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' import { sameTurnNavigationItem, turnNavigationItem, @@ -280,6 +281,8 @@ export function chatSnapshotFixture(input: { readonly turnTimings?: LegacyConversationSlice['turnTimings'] /** Recorded ends exist without starts; missing end times follow the fixture's seq × 1000 convention. */ readonly turnEnds?: LegacyConversationSlice['turnEnds'] + /** End reasons by turn; absent turns end `completed`. */ + readonly turnEndReasons?: ReadonlyMap /** Per-turn usage buckets; production derives these from session events. */ readonly turnUsages?: ReadonlyMap | undefined } = {}, previous?: ChatSnapshot): ChatSnapshot { @@ -311,7 +314,7 @@ export function chatSnapshotFixture(input: { }, end: endSeq === undefined ? undefined : { type: 'turn/end', seq: SessionSeq(endSeq), time: timing?.endTime ?? endSeq * 1000, - data: { turn, reason: { kind: 'completed' } }, + data: { turn, reason: input.turnEndReasons?.get(turn) ?? { kind: 'completed' } }, }, status: endSeq === undefined ? 'open' : 'closed', steps: EMPTY, 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 e664e2db5b..e0f05c690c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -3030,6 +3030,23 @@ describe('ChatView', () => { expect(view.getByRole('button', { name: /用量/ })).toBeTruthy() }) + it('summarizes an interrupted turn as stopped, without elapsed time, and keeps it open', () => { + const answer: AssistantMessageNode = { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }], + } + const h = makeHarness({ chat: chatSnapshotFixture({ + nodes: [user(1, 'hi'), answer, turnError(3, 'INTERRUPTED')], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), + turnEnds: new Map([[1, 3]]), + turnEndReasons: new Map([[1, { kind: 'interrupted' }]]), + }) }) + const view = render() + const control = turnProcessControl(view.container) + expect(control?.textContent).toBe('已停止') + expect(control?.disabled).toBe(true) + expect(view.getByText('任务已停止')).toBeTruthy() + }) + it('withholds the usage-details trigger when turn usage is outside the window', () => { const settled: AssistantMessageNode = { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }], 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 040c686140..5acf13fcfc 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 @@ -2810,6 +2810,14 @@ it('reports a turn closed after its process exited as a stopped turn', () => { }) }) +it('reports an interrupted ending on a turn observed from its start', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }), + ]) + expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({ seq: 2, turn: 1, code: 'INTERRUPTED' }) +}) + 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) diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index 0cbb172f8c..6d7359e71d 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -124,8 +124,9 @@ export type ModelRetryNode = LlmRetryEventData & { } /** - * Durable terminal failure for a turn that ended with an error reason; the - * turn's settled retry chain renders separately and never replaces this node. + * Durable terminal notice for a turn that ended with an error, a sign-out + * cancellation, or an interruption by a DSH stop; the turn's settled retry + * chain renders separately and never replaces this node. */ export interface TurnErrorNode { kind: 'turn-error' @@ -137,7 +138,11 @@ export interface TurnErrorNode { step: number /** Sanitized provider message; empty when a known code owns localized copy. */ message: string - /** Stable provider failure code, when recorded. */ + /** + * Stable provider failure code when recorded, or a Client display code: + * `ACCOUNT_SIGNED_OUT` for a sign-out cancellation, `INTERRUPTED` for a + * turn closed after DSH stopped. + */ code?: string } diff --git a/snapshots/web/interrupted-turn/snapshot.yml b/snapshots/web/interrupted-turn/snapshot.yml new file mode 100644 index 0000000000..546123a04b --- /dev/null +++ b/snapshots/web/interrupted-turn/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: interrupted-turn +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../seeded-history/session.v3.jsonl diff --git a/snapshots/web/interrupted-turn/ui.expected.md b/snapshots/web/interrupted-turn/ui.expected.md new file mode 100644 index 0000000000..0f55c17e6a --- /dev/null +++ b/snapshots/web/interrupted-turn/ui.expected.md @@ -0,0 +1,28 @@ +- banner: + - navigation "Session hierarchy": Use the read tool twice + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" +- button "Copy" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Read files" +- paragraph: DONE +- status: + - text: Task stoppedDSH stopped running before this turn finished. Unfinished tool calls have unknown outcomes. + - code: INTERRUPTED +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" [disabled] +- text: Available only on the last message of a completed turn {{clock}} +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands" +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "Send message" [disabled] +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "16K tok · Cache hit 98%": 16K tokCache hit 98%