fix(ui-chat): summarize interrupted turns as stopped and pin the notice in a web snapshot

This commit is contained in:
Turtle committed 2026-10-08 11:37:51 +08:00
1 parent 3121a4b5da
commit 830e298a04
12 files changed
+161 -12

No files matched your search

+77
View File
@@ -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<typeof watchConsole>
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'])
})
})
+1
View File
@@ -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",
@@ -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<ChatStoreState>) => turn === undefined
? undefined : storedTurnProcessEntry(state, turn), [turn])
@@ -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 (
@@ -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'
}
@@ -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
}
}
@@ -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<number, TurnEndReason>
/** Per-turn usage buckets; production derives these from session events. */
readonly turnUsages?: ReadonlyMap<number, TurnTokenUsage> | 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,
@@ -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(<h.ChatView {...h.props} />)
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' }],
@@ -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)
@@ -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
}
@@ -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
@@ -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%