Merge pull request #6649 from deepseek-harness/xtr/fix-trajectory-input-order
fix(web): 修复停止补发后轨迹中的输入顺序
This commit is contained in:
commit
27c1b4c56f
12 files changed
+443
-76
No files matched your search
@@ -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!"
|
||||
@@ -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<typeof watchConsole>
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const requestInputs: string[][] = []
|
||||
const releaseQuestion = Promise.withResolvers<undefined>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -55,6 +55,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
|
||||
|
||||
视图是纯投影:Trajectory 自有的定义从共享会话窗口组装业务记录——包括因取消而定稿并持久保留的前缀、仅有分片时采用的中断回退记录,以及被中断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering(中途引导)分类器通过持续保留的拼接状态只保留下一步的 Inbox ID,并让后续上下文共享当前已认领的每个批次。
|
||||
|
||||
用户消息、中途引导、上下文输入及请求提示词变更按日志顺序放在所属请求的 Step 分组内,位于请求分隔行和助手输出之前。
|
||||
|
||||
原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
|
||||
|
||||
工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
|
||||
@@ -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(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
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[] = [
|
||||
{
|
||||
|
||||
@@ -751,9 +751,7 @@
|
||||
"24334994394c1d4336d192b2f38986beadcd0f6b235108370c3a736c34eebe98": 1,
|
||||
"33e462c9f8d9ef55f1b54a408f047caaa9467facc4fc88887fa5979fdce6c09f": 1,
|
||||
"455072902745ebd120bdeaaebf1b00b2d747a300409066715375263bcc4e8a63": 1,
|
||||
"4df26af834cb4f8d55bb6908736ddeb6971b4c4c1cbd5a9ace2f9cfe9c0b94e4": 1,
|
||||
"6078d7e1390a0c3c94892edd74526a76e6cbbda8360656bf9f97d367371c0c4c": 1,
|
||||
"71b9c7a18658556ab36c9af394f29dac5885fab402304b0b6f4d5127d6acdeff": 1,
|
||||
"9117562f15005ef8ec18dbad9c29cdc723c4af2c6a9c217af3cd7d8380de999b": 1,
|
||||
"98169406d3c872458f70f3f8f70abdd6eeaca296816ce91cdcf8392cfa6a77bf": 1,
|
||||
"afecf8092cd8de095ffbe00a1df00816897e8205854fd33a9d2beeaee88eca95": 1,
|
||||
|
||||
@@ -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%
|
||||
Reference in new issue
Block a user