diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3ab28fbe32..cb9ea88e29 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -28,6 +28,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md') +const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const MODE = webSnapshotMode() // The question composer replaces the textarea, so fill → Queue row → Steer // starts only after request/context and must finish before the first replay @@ -193,6 +194,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { scaffold.workspaceCwd, ) await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.locator('[data-trajectory-row-key]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) + await compareOrRefreshGolden( + TRAJECTORY_EXPECTED, + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), + MODE, + ) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) @@ -200,10 +208,101 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md', + 'trajectory.expected.md', ]) }) }) +describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop message', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + const requestInputs: string[][] = [] + const releaseQuestion = Promise.withResolvers() + const FOLLOWUP = 'Also acknowledge this second message.' + const RESPONSE = "Great, let's move forward. BANANA!" + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + scaffold.ctx.on('llm/stream', async function* (options, next) { + requestInputs.push(options.messages.flatMap(message => + message.role === 'user' + ? message.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : [])) + for await (const chunk of next()) { + yield chunk + if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise + } + }, { prepend: true }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + releaseQuestion.resolve(undefined) + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it('keeps both inputs before the response in Chat and Trajectory, including after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-input-order')) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.fill(PROMPT) + await input.press('Enter') + await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1) + await input.fill(STEER) + await input.press('Meta+Enter') + await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Stop generating', exact: true }).click() + releaseQuestion.resolve(undefined) + await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1) + expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0) + + await input.fill(FOLLOWUP) + await input.press('Enter') + await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(2) + expect(requestInputs.at(-1)?.filter(text => text === STEER || text === FOLLOWUP)).toEqual([STEER, FOLLOWUP]) + const first = claimedMessages(sessionEvents, STEER) + const second = claimedMessages(sessionEvents, FOLLOWUP) + const response = sessionEvents.findLast(event => event.type === 'assistant/message') + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(first[0]!.seq).toBeLessThan(second[0]!.seq) + expect(second[0]!.seq).toBeLessThan(response!.seq) + + const expectOrder = async (selector: string) => { + await expect.poll(async () => { + const text = await page.locator(selector).textContent() + const expected = [STEER, FOLLOWUP, RESPONSE] + const positions = expected.map(value => text?.indexOf(value) ?? -1) + return positions.every(position => position >= 0) + && positions[0]! < positions[1]! && positions[1]! < positions[2]! + && expected.every(value => text?.indexOf(value) === text?.lastIndexOf(value)) + }, { timeout: 10_000 }).toBe(true) + } + await page.getByText(RESPONSE, { exact: true }).waitFor() + await expectOrder('[class*="centerCol"]') + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await expectOrder('[data-trajectory-scroll]') + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await expectOrder('[data-trajectory-scroll]') + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) + describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => { let scaffold: WebScaffold let browser: Browser diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 6db4c334a3..9bd52b1db1 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0f4ee2189324ac06 zh: 12e76fed22c09bc1 /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation: - en: 74cc8fc87241da34 - zh: cd72ff9c0fdd2575 + en: 0752847908851581 + zh: 8a2b71babc272ec6 /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation/virtual-rows: en: c85e342bc7eee4e6 zh: 9feab66ef1755b94 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 6a9fa9698d..f58d68a073 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -55,6 +55,8 @@ A fixed Overview above the ledger projects real record start/duration timing fro The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot. Its steering classifier retains only next-step Inbox IDs through persistent splice state and shares each current claimed batch across later Contexts. +User, steering, and context inputs consumed by one request share its Step group in log order, before the request separator and assistant output. This also applies when a stopped turn retains steering that the next request consumes together with a new user message. + Native and nested PTC Tool results retain their raw structured error details and presentation metadata. Failed records show the error code in the ledger and the error name and code in the inspector. Tool records begin at durable tool/call events and retain complete argument text with a lazy `args` view, reused by paired results. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index c37632a926..b7fb6c2e69 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -55,6 +55,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 视图是纯投影:Trajectory 自有的定义从共享会话窗口组装业务记录——包括因取消而定稿并持久保留的前缀、仅有分片时采用的中断回退记录,以及被中断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering(中途引导)分类器通过持续保留的拼接状态只保留下一步的 Inbox ID,并让后续上下文共享当前已认领的每个批次。 +同一请求消费的用户消息、中途引导与上下文输入按日志顺序放在其 Step 分组内,位于请求分隔行和助手输出之前。停止后保留的中途引导与新用户消息被下一次请求一并消费时,也遵循此规则。 + 原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。 工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。 diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 11d67ba237..78d073695e 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -169,6 +169,7 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< /** * Fold a snapshot into turn → Message/Step groups with expanded cells. + * Inputs admitted to the same Step retain log order before its response. * @param input - nodes plus in-flight partial/runningCalls. * @param t - Trajectory locale translator. * @returns turns ordered by first appearance. @@ -400,35 +401,20 @@ export function deriveTrajectoryLayout( continue } const { node, nodeIndex: i } = entry - if (node.kind === 'user') { - // user/message has no turn on the wire; enclose it in the next assistant - // (or partial) turn, else open the turn after the last assistant. - const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) - pushMessage(turn, { - absTime: finiteTime(node.time), - cell: { - index: ++index, - kind: 'user', - ...inputCellDetail(node, t), - opensTurn: true, - }, - }) - prevAbsTime = finiteTime(node.time) ?? prevAbsTime - continue - } - if (node.kind === 'steering') { - const placement = steeringPlacement( + if (node.kind === 'user' || node.kind === 'steering' || node.kind === 'context') { + const placement = inputPlacement( followingAssistants[i], partial, - lastAssistantTurn, eventLocations?.get(node.seq), + node.kind === 'steering' ? lastAssistantTurn ?? 1 : (lastAssistantTurn ?? 0) + 1, ) - const laid = { + const laid: LaidCell = { absTime: finiteTime(node.time), cell: { index: ++index, - kind: 'user' as const, + kind: node.kind === 'context' ? 'context' : 'user', ...inputCellDetail(node, t), + ...(node.kind === 'user' ? { opensTurn: true } : {}), }, } if (placement.step === undefined) pushMessage(placement.turn, laid) @@ -449,19 +435,6 @@ export function deriveTrajectoryLayout( lastAssistantTurn = node.turn continue } - if (node.kind === 'context') { - const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) - pushMessage(turn, { - absTime: finiteTime(node.time), - cell: { - index: ++index, - kind: 'context', - ...inputCellDetail(node, t), - }, - }) - prevAbsTime = finiteTime(node.time) ?? prevAbsTime - continue - } if (node.kind === 'compaction') { // Chat owns the human-facing compaction marker. It contributes no // duplicate trajectory cell, but still advances the duration cursor. @@ -899,26 +872,11 @@ function stringifySourceValue(value: unknown): string { return json || String(value) } -/** - * Turn that encloses a user/message: next assistant turn, else the - * in-flight partial, else the turn after the last finalized assistant (or 1). - */ -function enclosingUserTurn( +function inputPlacement( followingAssistant: AssistantMessageNode | undefined, partial: TrajectorySnapshot['partial'], - lastAssistantTurn: number | null, -): number { - if (followingAssistant !== undefined) return followingAssistant.turn - if (partial !== null) return partial.turn - if (lastAssistantTurn !== null) return lastAssistantTurn + 1 - return 1 -} - -function steeringPlacement( - followingAssistant: AssistantMessageNode | undefined, - partial: TrajectorySnapshot['partial'], - lastAssistantTurn: number | null, location: ConversationLocation | undefined, + fallbackTurn: number, ): { turn: number; step?: number } { if (location?.kind === 'step') { return { turn: location.turn.turn, step: location.step.step } @@ -935,7 +893,7 @@ function steeringPlacement( return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) } } if (locatedTurn !== undefined) return { turn: locatedTurn } - return { turn: lastAssistantTurn ?? 1 } + return { turn: fallbackTurn } } function indexFollowingAssistants( diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 5989817f49..a62297f516 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -18,6 +18,8 @@ import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-m import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts' import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts' import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' +import { t } from './locale.client.ts' const DEFINITIONS: ConversationNodeDefinition[] = [] const registrationContext = { @@ -172,6 +174,80 @@ function systemMessage(text: string) { } describe('Trajectory conversation Definitions', () => { + it.each(['waiting', 'streaming', 'settled', 'prepend'] as const)( + 'keeps retained steering and subsequent inputs before their shared response (%s)', + (phase) => { + const steer = { + id: 'retained-steer', role: 'user', + content: [{ type: 'text', text: '你可以直接更新master' }], + source: { kind: 'user' }, + } + const followup = { + id: 'followup', role: 'user', + content: [{ type: 'text', text: '的worktree,不用绕弯子拉snapshot' }], + source: { kind: 'user' }, + } + const events = [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }), + at(4, 'step/end', { turn: 1, step: 1 }), + at(5, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }), + at(6, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [followup] }), + at(7, 'turn/start', { turn: 2 }), + at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(9, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(10, 'step/start', { turn: 2, step: 1 }), + at(11, 'user/message', steer), + at(12, 'user/message', followup), + at(13, 'developer/message', { + message: { + id: 'context', role: 'developer', + content: [{ type: 'text', text: 'Current workspace context.' }], + source: { kind: 'plugin', plugin: 'test-context' }, + }, + }), + ] + if (phase === 'streaming') { + events.push( + at(14, 'assistant/live-chunk', { + turn: 2, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' }, + }), + at(15, 'assistant/live-chunk', { + turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Both inputs received.' }, + }), + ) + } else if (phase !== 'waiting') { + events.push( + at(14, 'assistant/message', { + turn: 2, step: 1, message: assistantMessage('response', 'Both inputs received.'), + }), + at(15, 'step/end', { turn: 2, step: 1 }), + at(16, 'turn/end', { turn: 2, reason: { kind: 'completed' } }), + ) + } + const value = assembler(phase === 'prepend' ? events.slice(10) : events, phase === 'prepend') + const assertOrder = () => { + const current = snapshot(value) + const turns = deriveTrajectoryLayout({ ...current, nodes: current.eventNodes }, t) + expect(turns.find(turn => turn.turn === 2)?.groups.flatMap(group => + group.cells.map(cell => cell.requestOnly ? '[request]' : cell.previewMarkdown).filter(Boolean))).toEqual([ + '你可以直接更新master', + '的worktree,不用绕弯子拉snapshot', + 'Current workspace context.', + phase === 'waiting' ? '[request]' : 'Both inputs received.', + ]) + } + assertOrder() + if (phase === 'prepend') { + value.prepend(events.slice(0, 10), false) + value.flush() + assertOrder() + } + expect(snapshot(value).eventNodes.slice(0, 3).map(node => node.kind)).toEqual(['steering', 'user', 'context']) + }, + ) + it('publishes a waiting request before any stream output arrives', () => { const value = assembler([at(1, 'turn/start', { turn: 1 })]) expect(value.append(at(2, 'step/start', { turn: 1, step: 1 }))).toBe('immediate') diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 8b3dfb7160..48a0b772e9 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -310,7 +310,7 @@ describe('deriveTrajectoryLayout', () => { ]) }) - it('places steering in its resolved step instead of the turn-opening Message group', () => { + it('places opening and steering inputs in their respective consuming steps', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null }, { @@ -345,9 +345,13 @@ describe('deriveTrajectoryLayout', () => { expect(turns).toHaveLength(1) expect(turns[0]?.groups.map(group => group.title)).toEqual([ - 'Message', 'Step 1', 'Step 2', + 'Step 1', 'Step 2', ]) - expect(turns[0]?.groups[2]?.cells).toMatchObject([ + expect(turns[0]?.groups[0]?.cells).toMatchObject([ + { kind: 'user', previewMarkdown: 'start', opensTurn: true }, + { kind: 'message', previewMarkdown: 'first step' }, + ]) + expect(turns[0]?.groups[1]?.cells).toMatchObject([ { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 }, { kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 }, ]) diff --git a/snapshots/web/steering/trajectory.expected.md b/snapshots/web/steering/trajectory.expected.md new file mode 100644 index 0000000000..9d299ba49b --- /dev/null +++ b/snapshots/web/steering/trajectory.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": Use the ask_user_question tool to + - text: Standard mode + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" + - tab "Trajectory" [selected] +- toolbar "Trajectory toolbar": + - button "Use actual duration": Duration + - button "Collapse turns": Turns + - button "Collapse calls": Calls + - searchbox "Search trajectory" +- region "Trajectory timeline" +- table: + - rowgroup: + - row "SYSTEM, Initial System Prompt": + - cell "SYSTEM" + - cell "Initial System Prompt" + - row "USER, Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop.": + - cell "Turn 1 USER": USER + - cell "Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop." + - 'row "CONTEXT, Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable. Approval policy: as…"': + - cell "CONTEXT" + - 'cell "Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable. Approval policy: as…"' + - 'row "CONTEXT, Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."': + - cell "CONTEXT" + - 'cell "Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."' + - row "Request 1, ASSISTANT, (tool call only)": + - 'cell "Request #1 ASSISTANT"': + - 'button "Request #1"' + - text: ASSISTANT + - cell "(tool call only)" + - 'row "TOOL, ask_user_question {\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"': + - cell "TOOL" + - 'cell "ask_user_question{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]} → {\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"' + - 'row "USER, Interjection: include the word BANANA in your final reply."': + - cell "USER" + - 'cell "Interjection: include the word BANANA in your final reply."' + - row "Request 2, ASSISTANT, Great, let's move forward. BANANA!": + - 'cell "Request #2 ASSISTANT"': + - 'button "Request #2"' + - text: ASSISTANT + - cell "Great, let's move forward. BANANA!" +- 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 "30 tok · Cache hit 0%": 30 tokCache hit 0% +- button "0% of context used": 0%