diff --git a/apps/web/tests/expected/retained-steering/trajectory.expected.md b/apps/web/tests/expected/retained-steering/trajectory.expected.md new file mode 100644 index 0000000000..a38822a043 --- /dev/null +++ b/apps/web/tests/expected/retained-steering/trajectory.expected.md @@ -0,0 +1,35 @@ +- 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 pl…"': + - 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 pl…"' + - '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, compaction": + - 'cell "Request #1"': + - 'button "Request #1"' + - cell + - 'row "USER, Interjection: include the word BANANA in your final reply."': + - cell "Turn 2 USER": USER + - 'cell "Interjection: include the word BANANA in your final reply."' + - row "USER, Also acknowledge this second message.": + - cell "USER" + - cell "Also acknowledge this second message." + - row "SYSTEM, Tools Updated": + - cell "SYSTEM" + - cell "Tools Updated" + - 'row "CONTEXT, Tool added: trajectory_order_probe"': + - cell "CONTEXT" + - 'cell "Tool added: trajectory_order_probe"' + - 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!" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3ab28fbe32..2d457fcb98 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -28,6 +28,8 @@ 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 RETAINED_TRAJECTORY_EXPECTED = fileURLToPath(new URL('./expected/retained-steering/trajectory.expected.md', import.meta.url)) 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 @@ -80,9 +82,11 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // clocks must read the fixture day or a run across Asia/Shanghai midnight // renders the same message with a date prefix. hostClock = pinHostClock() - scaffold = await launchWebScaffold(MODE === 'record' - ? {} - : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true }) + // Trajectory previews truncate before path normalization; pin the input length across hosts. + scaffold = await launchWebScaffold({ + workspacePathLength: 112, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true }), + }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -193,6 +197,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 +211,131 @@ 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 + let hostClock: MockInstance | undefined + 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 () => { + hostClock = pinHostClock() + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, compareReplaySession: false, workspacePathLength: 112, + }) + 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) + await pinBrowserClock(page) + 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 { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + } finally { + hostClock?.mockRestore() + } + }) + + 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) + + scaffold.ctx.effect(() => scaffold.ctx.tools.register({ + name: 'trajectory_order_probe', + description: 'A tool added between requests to exercise the tool catalog update.', + parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, + async execute() { return null }, + })) + await input.fill(FOLLOWUP) + await input.press('Enter') + await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(2) + 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) + const loggedInputs = [ + { text: STEER, seq: first[0]!.seq }, + { text: FOLLOWUP, seq: second[0]!.seq }, + ].sort((left, right) => left.seq - right.seq) + expect(requestInputs.at(-1)?.filter(text => text === STEER || text === FOLLOWUP)) + .toEqual(loggedInputs.map(value => value.text)) + expect(loggedInputs.every(value => value.seq < response!.seq)).toBe(true) + + const expectOrder = async (selector: string, includeTools = false) => { + await expect.poll(async () => { + const text = await page.locator(selector).textContent() + const expected = [ + ...loggedInputs.map(value => value.text), + ...(includeTools ? ['Tools Updated'] : []), + RESPONSE, + ] + const positions = expected.map(value => text?.indexOf(value) ?? -1) + return positions.every(position => position >= 0) + && positions.every((position, index) => index === 0 || positions[index - 1]! < position) + && 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]', true) + await compareOrRefreshGolden( + RETAINED_TRAJECTORY_EXPECTED, + await captureStableAria(page, '[data-trajectory-scroll]', scaffold.workspaceCwd), + MODE, + ) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await expectOrder('[data-trajectory-scroll]', true) + 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..4205d20bbe 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: 422f0f91eeab6707 + zh: 3516d868cfbb0e02 /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..45f0f721b6 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 and request prompt changes share their request's Step group in log order, before the request separator and assistant output. + 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..c682e9d728 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/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 4cf15a0847..2940741317 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -558,7 +558,7 @@ function indexRequestBoundaries( const key = requestKey(record.turn, record.group) if (!requestGroups.has(key)) continue if (boundaries.has(key)) continue - if (record.cell.kind === 'user' || record.cell.kind === 'context') continue + if (record.cell.kind === 'user' || record.cell.kind === 'context' || record.cell.kind === 'system') continue boundaries.set(key, record.cell.index) } return boundaries diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 11d67ba237..f43c58b29e 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. @@ -324,10 +325,7 @@ export function deriveTrajectoryLayout( } if (entry.kind === 'system') { const { change, request } = entry - const turn = change.kind === 'initial' - ? firstVisibleTurn(nodes, partial) - : enclosingPromptTurn(nodes, change.seq, partial) - pushMessage(turn, { + const laid: LaidCell = { absTime: finiteTime(change.time), cell: { index: ++index, @@ -342,7 +340,15 @@ export function deriveTrajectoryLayout( timeSeconds: 0, startedAt: finiteTime(change.time), }, - }) + } + if (request !== undefined) { + pushStepInput(request.turn, request.step, [laid]) + } else { + const turn = change.kind === 'initial' + ? firstVisibleTurn(nodes, partial) + : enclosingPromptTurn(nodes, change.seq, partial) + pushMessage(turn, laid) + } prevAbsTime = finiteTime(change.time) ?? prevAbsTime continue } @@ -400,35 +406,21 @@ 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') { + // Without request location, steering stays in the preceding turn; other inputs open the next. + 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 +441,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 +878,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 +899,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..60fece7c5c 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,9 @@ 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 { deriveTrajectoryTimeline } from '../src/client/timeline.ts' +import { t } from './locale.client.ts' const DEFINITIONS: ConversationNodeDefinition[] = [] const registrationContext = { @@ -172,6 +175,155 @@ function systemMessage(text: string) { } describe('Trajectory conversation Definitions', () => { + describe.each([{ turn: 2, step: 1 }, { turn: 1, step: 2 }])( + 'request prompt ordering in turn $turn, step $step', + ({ turn, step }) => { + it.each(['waiting', 'streaming', 'settled'] as const)( + 'keeps tool changes after inputs and before the response (%s)', + (phase) => { + const events = [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'system/message', { turn: 1, step: 1, message: systemMessage('prompt') }, { surfaceOp: 'append' }), + at(4, 'request/header', { + reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [] }, + }), + at(5, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('first', 'Earlier response') }), + at(6, 'step/end', { turn: 1, step: 1 }), + ] + if (turn === 2) { + events.push( + at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(8, 'turn/start', { turn }), + ) + } + events.push( + at(9, 'step/start', { turn, step }), + at(10, 'user/message', { + id: 'followup', role: 'user', content: [{ type: 'text', text: 'Continue' }], source: { kind: 'user' }, + }), + at(11, 'developer/message', { + message: { + id: 'tool-update', role: 'developer', + content: [{ type: 'tool-addition', toolName: 'search' }], + source: { kind: 'tool-registry' }, + }, + }), + at(12, 'request/header', { + reason: 'series', + header: { + config: { provider: 'test', model: 'test' }, + tools: [{ name: 'search', description: 'Search', parameters: { type: 'object', properties: {} } }], + }, + }), + ) + if (phase === 'streaming') { + events.push( + at(13, 'assistant/live-chunk', { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } }), + at(14, 'assistant/live-chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text: 'Answer' } }), + ) + } else if (phase === 'settled') { + events.push( + at(13, 'assistant/message', { turn, step, message: assistantMessage('answer', 'Answer') }), + at(14, 'step/end', { turn, step }), + ) + } + const current = snapshot(assembler(events)) + expect(current.requests.at(-1)).toMatchObject({ turn, step, promptChange: { kind: 'tools', seq: 12 } }) + const turns = deriveTrajectoryLayout({ ...current, nodes: current.eventNodes }, t) + const cells = turns.flatMap(value => value.groups.flatMap(group => group.cells)) + const requestCells = turns.find(value => value.turn === turn)?.groups + .filter(group => group.title === `Step ${step}`).flatMap(group => group.cells) + expect(requestCells?.map(cell => cell.requestOnly ? '[request]' : cell.previewMarkdown || cell.text)).toEqual([ + 'Continue', 'Tool added: search', 'Tools Updated', phase === 'waiting' ? '[request]' : 'Answer', + ]) + const byIndex = new Map(cells.map(cell => [cell.index, cell])) + expect(deriveTrajectoryTimeline(turns)?.spans.map((span) => { + const cell = byIndex.get(span.index)! + return cell.previewMarkdown || cell.text + })).toEqual([ + 'Initial System Prompt', 'Earlier response', 'Continue', 'Tool added: search', 'Tools Updated', + ...phase === 'waiting' ? [] : ['Answer'], + ]) + }, + ) + }, + ) + + 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..8816ec1ce2 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -171,34 +171,40 @@ describe('deriveTrajectoryLayout', () => { }) it('appends a streaming partial without rebuilding unaffected finalized turns', () => { - const nodes = [{ + const nodes: ConversationNode[] = [{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'finalized' }], - }] as unknown as LegacyConversationSlice['nodes'] + }, { + kind: 'user', seq: 4, time: 3_100, + content: [{ type: 'text', text: 'Continue' }], source: null, + }] const partial = { turn: 2, step: 1, blocks: [{ kind: 'reasoning' as const, text: 'streaming' }], timing: { stepStartTime: 3_000, firstTokenTime: 3_200 }, } - const request = { + const request: RequestView = { purpose: 'assistant', startSeq: 3, turn: 2, step: 1, startedAt: 3_000, completedAt: null, status: 'running', - } as unknown as RequestView + } const base = deriveTrajectoryLayout({ nodes, partial: { ...partial, blocks: [] }, requests: [request], runningCalls: [], }) - expect(base).toHaveLength(1) + expect(base).toHaveLength(2) + expect(base[1]?.groups[0]?.cells).toMatchObject([{ kind: 'user', previewMarkdown: 'Continue' }]) - const streamed = appendTrajectoryPartialLayout(base, partial, 1) + const streamed = appendTrajectoryPartialLayout(base, partial, 2) expect(streamed[0]).toBe(base[0]) expect(streamed).toHaveLength(2) expect(streamed[1]?.groups[0]?.cells).toMatchObject([{ - index: 2, + index: 2, kind: 'user', previewMarkdown: 'Continue', + }, { + index: 3, kind: 'message', text: '', previewMarkdown: 'streaming', @@ -211,7 +217,7 @@ describe('deriveTrajectoryLayout', () => { completedTime: null, }, }]) - expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined() + expect(streamed[1]?.groups[0]?.cells[1]?.requestOnly).toBeUndefined() }) it('replaces a running-call placeholder with the matching streamed tool call', () => { @@ -310,7 +316,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 +351,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' }, + { 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/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 188b96bb5f..668d133bb9 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -927,6 +927,26 @@ describe('TrajectoryTable', () => { expect(screen.queryByText('Completed')).toBeNull() }) + it.each([false, true])('places the request separator after input and prompt rows (waiting: %s)', (waiting) => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 2, + groups: [{ + title: 'Step 1', + cells: [ + { index: 1, kind: 'user', text: 'Continue', timeSeconds: 0 }, + { index: 2, kind: 'context', text: 'Tool added: search', timeSeconds: 0 }, + { index: 3, kind: 'system', text: 'Tools Updated', timeSeconds: 0 }, + { index: 4, kind: 'message', text: waiting ? '' : 'Answer', requestOnly: waiting, timeSeconds: 0 }, + ], + }], + }] + const view = render() + const rows = [...view.container.querySelectorAll('tr[data-kind]')] + const requestRow = screen.getByRole('button', { name: 'Request #1' }).closest('tr') + expect(rows.indexOf(requestRow!)).toBe(3) + expect(screen.getByRole('row', { name: 'SYSTEM, Tools Updated' }).contains(requestRow)).toBe(false) + }) + it('marks failed requests and lays coincident request markers left to right', () => { const turns: readonly TrajectoryTurnModel[] = [ { diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f1673f57e6..5ae7814525 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -751,9 +751,7 @@ "24334994394c1d4336d192b2f38986beadcd0f6b235108370c3a736c34eebe98": 1, "33e462c9f8d9ef55f1b54a408f047caaa9467facc4fc88887fa5979fdce6c09f": 1, "455072902745ebd120bdeaaebf1b00b2d747a300409066715375263bcc4e8a63": 1, - "4df26af834cb4f8d55bb6908736ddeb6971b4c4c1cbd5a9ace2f9cfe9c0b94e4": 1, "6078d7e1390a0c3c94892edd74526a76e6cbbda8360656bf9f97d367371c0c4c": 1, - "71b9c7a18658556ab36c9af394f29dac5885fab402304b0b6f4d5127d6acdeff": 1, "9117562f15005ef8ec18dbad9c29cdc723c4af2c6a9c217af3cd7d8380de999b": 1, "98169406d3c872458f70f3f8f70abdd6eeaca296816ce91cdcf8392cfa6a77bf": 1, "afecf8092cd8de095ffbe00a1df00816897e8205854fd33a9d2beeaee88eca95": 1, diff --git a/snapshots/web/steering/trajectory.expected.md b/snapshots/web/steering/trajectory.expected.md new file mode 100644 index 0000000000..991a9d4f93 --- /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 pl…"': + - 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 pl…"' + - '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%