fix(ui-chat): summarize interrupted turns as stopped and pin the notice in a web snapshot
This commit is contained in:
1 parent
3121a4b5da
commit
830e298a04
12 files changed
+161
-12
No files matched your search
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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%
|
||||
Reference in new issue
Block a user