Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 deletions

View File

@@ -9,8 +9,8 @@
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
// and before the cap a long one grew the card until the refuse/allow buttons
// left the viewport — an approval the user could see and not answer.
// and an uncapped card grows with it until the refuse/allow buttons leave the
// viewport — an approval the user could see and not answer.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -36,8 +36,8 @@ const MODE = webSnapshotMode()
// Irreducible payload: the command has to be long enough to pass the card's
// height cap, which is the only shape that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// screen. Unrelated tokens, not a repeated word — the model compresses a
// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a
// short command proves nothing here. The formula keeps the source small; the
// model receives the expanded literal it has to put in the command.
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await input.fill('')
// Read-only: the mode whose denial the model escalates from. Switched
// through the shipped access-mode chip, not a test-only seam.
// through the shipped access-mode chip, not a test-only override.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// The regression this scenario exists for: an uncapped card grew with
// the command until the action row left the viewport. Measured at the
// lane baseline and at a short viewport, on the live panel.
// The uncapped-card hazard the header names, measured at the lane
// baseline and at a short viewport, on the live panel.
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [1000, 700]) {
await page.setViewportSize({ width: 900, height })

View File

@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.

View File

@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import {
launchWebScaffold,
watchConsole,
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
.join('')
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
)), { timeout: 15_000 }).toBe(true)
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')

View File

@@ -1,6 +1,7 @@
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
// input only navigates to the semantic target; assertions pin content identity
// and interaction routing rather than scroll geometry or mounted row counts.
// Long-history Chat behavior contract that stays valid under a virtualized
// renderer: wheel input only navigates to the semantic target; assertions pin
// content identity and interaction routing rather than scroll geometry or
// mounted row counts.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
@@ -115,6 +117,18 @@ function requiredEvent<T extends SessionEvent['type']>(
return event
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
function turnTailKey(turn: number): string {
return conversationContextKey('turn-tail', String(turn))
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
@@ -176,8 +190,10 @@ describe('web e2e: long Chat interaction contract', () => {
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
const toolUserKey = messageKey(toolUserEvent)
const toolAssistantKey = assistantKey(toolAssistantEvent)
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
@@ -186,28 +202,27 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
`node:${String(toolUserEvent.seq)}`,
`call:${TARGET_CALL_1}`,
`call:${TARGET_CALL_2}`,
`node:${String(toolAssistantEvent.seq)}`,
toolUserKey,
conversationContextKey('tool-call', TARGET_CALL_1),
conversationContextKey('tool-call', TARGET_CALL_2),
toolAssistantKey,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
@@ -219,9 +234,12 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
const branchUserKey = messageKey(branchUserEvent)
const branchAssistantKey = assistantKey(branchAssistantEvent)
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
@@ -230,8 +248,8 @@ describe('web e2e: long Chat interaction contract', () => {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
await assistantRow.hover()
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await turnTailRow.hover()
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },

View File

@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
// (and the PR gif) need. Never asserted against model prose.
// need. Never asserted against model prose.
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// The parent run_code row wears the code variant with the model-authored
// description as its summary (the PR1 presentCall contract).
// description as its summary (the presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the

View File

@@ -8,7 +8,7 @@
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint.
//
// Two layers can only stay together by moving together. They now do: both sit
// Two layers can only stay together by moving together. They do: both sit
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
// tall as the whole draft — so one offset, applied by the browser, moves the
// caret and the words in the same frame. Scrolling the textarea and assigning
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
* platform, not the change. What is recorded is the cap, the caret-to-glyph
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
* relation, and which lines are on screen, each a comparison that survives any
* layout keeping the coupling.
* @param top - metrics with the draft scrolled to its start.
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// A layer that breaks lines somewhere else puts the words under the wrong
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
// draft. The three now share a containing block — the scrollport — so a
// scrollbar that consumes layout space costs them the same width; before,
// only the textarea scrolled, and WebKit reserved gutter space for it alone
// (768 against 776) while chromium and firefox did not.
// draft. All three share a containing block — the scrollport — so a
// scrollbar that consumes layout space costs them the same width; with
// only the textarea scrolling, WebKit reserves gutter space for it alone
// (768 against 776) while chromium and firefox do not.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
// The reported symptom, isolated. A scroll offset changes and the caret's
// distance to its own glyphs is re-read before the task ends — before any
// `scroll` listener could have run. With the layers on one scrollport the
// browser moved both, so the distance is unchanged; with the glyph layer
// browser moves both, so the distance is unchanged; with the glyph layer
// catching up in a listener it is off by the whole delta until a later
// frame, which is a caret flying away from its text mid-gesture.
const metrics = await measureComposer(page)
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
// The other way the box moves, and the one that depends on the browser: the
// textarea no longer scrolls, so revealing the caret after an edit is a
// scroll-into-view that has to walk up to the scrollport. Scroll away from
// the caret first, so the edit has somewhere to bring it back from.
// textarea holds no scroll offset of its own, so revealing the caret after
// an edit is a scroll-into-view that has to walk up to the scrollport.
// Scroll away from the caret first, so the edit has somewhere to bring it
// back from.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.hover()
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
// The composer suppresses the native paste — the machine owns the draft and
// the undo log — and restores the caret programmatically, which reveals
// nothing on its own: measured in chromium and WebKit, the view stayed
// where it was while the caret sat at the end of the pasted block. The
// restore now scrolls it into view, and this is the case that proves it.
// nothing on its own: in chromium and WebKit the view stays put while the
// caret sits at the end of the pasted block, so the restore scrolls it
// into view; this case pins it.
const input = page.locator('textarea:enabled').first()
await input.fill('one short line')
await input.press('End')

View File

@@ -11,12 +11,12 @@
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
// Without a shared reservation the two tabs disagree by exactly the bar's
// width for as long as the transcript overflows: the card jumps sideways on
// every tab switch, and inside Chat alone at the moment a growing transcript
// starts to scroll. The column reserves the gutter unconditionally
// (`scrollbar-gutter: stable`) and states the overlay branch as a scroll
// container on the same axes, so both edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
@@ -26,14 +26,14 @@
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// container's bar consumes no layout width at all, so the two tabs agree with
// and without the reservation and every comparison below holds vacuously —
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against
// 8 and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// The scenario runs that unreserved cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
@@ -64,7 +64,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
* carries the shift the unreserved cascade produces rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -114,10 +114,10 @@ async function setMeasuredViewport(
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
* The unreserved cascade, injected into the page: the reservation dropped and
* the overlay branch forced to a hidden box. `!important` beats the module
* rules without a rebuild, and the id lets the control be lifted again in the
* same session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
@@ -221,9 +221,9 @@ async function compareTabs(page: Page): Promise<TabComparison> {
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* Run the unreserved cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
* @returns the comparison as the column lays out without the reservation.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
@@ -328,7 +328,7 @@ describe('web e2e: input card position across view tabs', () => {
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// The reservation reaches both states, which is the whole point: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
@@ -348,8 +348,8 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
// width cap, so the unreserved cascade's shift shows up as a centring
// difference — half the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
@@ -363,7 +363,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// the column's width, so an unreserved gutter changes its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
@@ -379,17 +379,17 @@ describe('web e2e: input card position across view tabs', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
// never reached the layout. Under the unreserved cascade the Chat scroller
// keeps its bar and the Trajectory branch becomes a hidden box with none,
// and the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
// Restoring the sheet restores the reservation, so the control cannot leak
// into the remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -1,15 +1,15 @@
// Web e2e scenario: the conversation column scrolls on one axis only, as the
// browser actually lays it out. The reported symptom was a horizontal
// scrollbar under the whole center column once the window (or the sidebar
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
// column and becoming user-scrollable.
// browser actually lays it out. The hazard: a horizontal scrollbar appears
// under the whole center column once the window (or the sidebar drag) narrows
// it — the hero's decorative backdrop ellipse bleeds past the column and
// becomes user-scrollable.
//
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
// hero box (ConversationRoot.module.css) so the blur scales with the input
// card. What changed is the scroll container: `[data-conversation-scroll]`
// scrolls vertically, and a box that scrolls in one axis computes the other
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
// fix states `overflow-x: hidden` there.
// card. The scroll container is where the bar comes from:
// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
// computes the other axis's initial `visible` to `auto`, so the bleed becomes
// a bar; `overflow-x: hidden` on the scroller prevents it.
//
// Only a real engine reports that pair — the bleed and the resulting scroll
// range — so the scenario sweeps viewport widths that bracket the glow's
@@ -36,7 +36,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over
* Committed golden of the one-axis relation at every stop. It records
* relations and booleans, never absolute coordinates: the column width follows
* the viewport and the sidebar, and a golden carrying pixels would document the
* platform instead of the change.
* platform instead of the behavior.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -60,17 +60,20 @@ interface ColumnMetrics {
columnWidth: number
/** Resolved `overflow-x` on the conversation scroll container. */
overflowX: string
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
/**
* True when the glow's box reaches past the column's content edge — the
* condition the `overflow-x: hidden` declaration has to survive.
*/
glowBleeds: boolean
/**
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
* `auto` both report the same value, because `hidden` clips the bleed rather
* than reflowing it away. Recorded because it is the vacuity guard in
* numbers — it must stay positive at the narrow stops, or the scenario has
* stopped reproducing the situation the fix is for.
* stopped reproducing the situation `overflow-x: hidden` exists for.
*/
bleedRange: number
/** True when the column still scrolls vertically — the axis the fix must not take away. */
/** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
scrollsVertically: boolean
}
@@ -108,9 +111,10 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
* This is the one signal that separates the two states, and it is why the
* scenario needs a real engine: `overflow-x: hidden` leaves the box
* programmatically scrollable and leaves `scrollWidth` untouched, so every
* property reading agrees across the fix. Only refusing an actual input event
* differs — measured at the 1200px stop, the shipped column stays at 0 while
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
* property reading agrees across the two overflow modes. Only refusing an
* actual input event differs — measured at the 1200px stop, the shipped
* column stays at 0 while the same page with `overflow-x: auto` forced on
* lands at its scroll boundary.
* @param page - the page under test.
* @returns `scrollLeft` after one horizontal wheel over the column.
*/
@@ -175,10 +179,10 @@ type ColumnStop = ColumnMetrics & {
* Render the golden body: one line per stop, relations only.
*
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
* rather than its width, so the golden survives any platform whose column
* lands a pixel off — a fixture that has to be re-recorded per platform
* documents the platform, not the change.
* which the shipped overflow mode pins to 0 by construction. The bleed is
* recorded as a boolean rather than its width, so the golden survives any
* platform whose column lands a pixel off — a fixture that has to be
* re-recorded per platform documents the platform, not the behavior.
* @param stops - the measured stops, in sweep order.
* @returns the golden body, without a trailing newline.
*/
@@ -272,18 +276,19 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
// The reported symptom, stated directly: a horizontal wheel over the
// column moves nothing, at every stop.
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
// The axis the column is a scroller for must survive the fix.
// The axis the column is a scroller for must survive `overflow-x: hidden`.
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
}
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reports the pre-fix state when the axis is opened back up', async () => {
it('scrolls horizontally again once the axis is opened back up (control)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
// The mutation control, run in the page rather than against a second
// build: it restores exactly what the fix changed — the initial `visible`
// that a one-axis scroller computes to `auto` — and shows the same gesture,
// at the same timing, carrying the column to its positive scroll boundary.
// build: it lifts exactly the `overflow-x: hidden` declaration, so the
// initial `visible` that a one-axis scroller computes to `auto` takes
// over, and shows the same gesture, at the same timing, carrying the
// column to its positive scroll boundary.
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
// Injected with an id rather than through `addStyleTag`, so the teardown
// below can take the sheet out again by selector: it must not outlive this

View File

@@ -1,6 +1,6 @@
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
// composer's effort pane — the levels a settings profile declares are exactly
// what the picker offers, and picking one records it with the default route.
// what the picker offers, and picking one records it with the Agent default.
// Zero model calls: declaring, describing, and switching are settings/llm
// traffic only, so there is no fixture and a stray stream would fail loud.
import { readFile } from 'node:fs/promises'
@@ -77,8 +77,8 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// Picking a level is the same gesture that saves the default target, so
// the effort lands in the gateway's settings section beside the route.
// Picking a level is the same gesture that saves the default selection, so
// the effort lands in the Agent default Settings section beside provider/model.
await page.getByRole('menuitemradio', { name: 'High' }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),

View File

@@ -2,7 +2,7 @@
# deepseek-official default would be a route nothing serves. This scenario
# starts the default on its own declared reasoning model so the effort pane
# describes that model from the first open.
- id: api-gateway
- id: agent-default-model
config:
provider: acme-gateway
model: acme-think

View File

@@ -1,10 +1,10 @@
// Web e2e scenario: switching models in the composer is how this deployment's
// default is chosen. The gesture writes the `api-gateway` settings section, a
// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
// session created afterwards starts from it, and a session that already logged
// a route keeps deriving from its own log — the tier order the gateway
// resolves on every read.
// Zero model calls: the switch is settings/llm-domain traffic only, so there
// is no fixture and a stray stream would fail loud on the open seam. Both
// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
// routes are declared host-side (not through the UI, which has its own
// scenario) through the pi-ai adapter the shipped tree already mounts: a
// fixture-less scaffold registers no adapter at all, so the routes the
@@ -21,7 +21,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
/** Points the shipped `api-gateway` default at this scenario's own route. */
/** Points the shipped shared Agent default at this scenario's own route. */
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
/** The route this scenario starts on, patched over the shipped default. */
@@ -110,12 +110,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
// The switch is what sets the default: the gateway's own settings section
// The switch is what sets the default: the shared Agent-route settings section
// now names it, beside the provider profiles the Models page writes.
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).toContain('api-gateway:')
).toContain('agent-default-model:')
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain(`provider: ${ROUTE}`)
expect(document).toContain(`model: ${MODEL}`)

View File

@@ -1,8 +1,8 @@
# The fixture-less web scaffold registers no adapter, so the shipped
# deepseek-official default would be a route nothing serves — which the
# composer now correctly refuses to type into. This scenario declares its own
# composer refuses to type into. This scenario declares its own
# pi-ai routes and starts the default on one of them.
- id: api-gateway
- id: agent-default-model
config:
provider: origin-gateway
model: origin-large

View File

@@ -6,9 +6,10 @@
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
// gateway), reload replays everything from the log (zero further model
// calls), and the theme scenario proves the shipped dark palette actually
// cascades: attribute -> alias token flip -> painted surface change. Per the
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
// the hero's waiting state gets the one golden here.
// cascades: attribute -> alias token flip -> painted surface change. No
// theme/layout golden: aria snapshots are color-blind (lane scope: the
// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
// here.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -112,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
// The golden encodes an empty composer, and the button arriving does not
// mean the submitted text is gone yet: under load the capture caught a
// textbox still holding `/plan`.
// mean the submitted text is gone yet: under load the capture can catch
// a textbox still holding `/plan`.
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
@@ -232,7 +233,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
// This scenario pins the ThemeService's DOM contract seam directly (the
// This scenario pins the ThemeService's DOM contract directly (the
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
// user gesture above it (Settings -> Appearance cubes) is owned by
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade

View File

@@ -1,6 +1,6 @@
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
// and transient-retry recovery, all through the real composition and wire.
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
// readyFile marker) makes mid-stream cancel deterministic by construction,
// `throw` entries express provider failures by stable code, and `{ patches }`
// augmentation injects a transient throw before the recorded success so

View File

@@ -7,7 +7,7 @@
// provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// stray stream would fail loud because the adapter registry is empty. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference. The deletion dialog distinguishes a
// reference-free profile from a page-managed key before the credential and
@@ -85,8 +85,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const key = dialog.getByLabel('API 密钥')
const save = dialog.getByRole('button', { name: '保存', exact: true })
// The paste that used to save cleanly and then fail the first turn with a
// ByteString TypeError now names the field that holds it.
// A key no HTTP header can carry would save cleanly and fail the first
// turn with a ByteString TypeError; the form names the offending field
// instead.
await key.fill('sk-\u{1F600}minimax')
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)

View File

@@ -331,7 +331,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
// Real layout, not jsdom's stub (which computes no geometry at all):
// squeeze the output pane below its content width and the line must keep
// its single row and overflow sideways instead of folding. Soft-wrapping
// here is what shredded the column alignment this card exists to hold.
// here shreds the column alignment this card exists to hold.
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
const pane = node as HTMLElement
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')

View File

@@ -1,4 +1,4 @@
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
# Loader overlay for the real-host smoke (`dsh web --patch`): pin the
# in-browser directory picker. The shipped row is `-auto`, which resolves to
# the native OS chooser on a loopback bind with a local display — an
# interaction a Playwright page cannot drive, so the resolved backend would

View File

@@ -0,0 +1,162 @@
// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
// a built write turn (zero model calls) whose closing message names the written
// file three ways: by unique basename (links), ambiguously (stays inert), and
// as a file the turn never touched (stays inert). Package tests cover the
// resolver in isolation; only the assembled application shows a real write's
// locations reaching the prose as an opener. The click itself is not driven
// here: it hands the path to the Host's opener, which would launch a real
// application on the machine running the suite (the produced-files restraint).
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SEED_ID = 'produced-file-mentions-web-e2e'
const DONE = 'FILE_MENTION_DONE'
/** One-part text content for a built message. */
function text(value: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text: value }]
}
/** The files the built turn writes; `notes.md` is named in prose but never written. */
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
/** Build a settled write turn whose closing prose mentions files in inline code. */
function mentionFixture(): string {
const session = Session.create(SessionId('produced-file-mentions-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Produced file mentions',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
const calls = WRITES.map((path, index) => ({
path,
callId: CallId(`file-mention-${String(index)}`),
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'write',
arguments: call.args,
})),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn: 1,
step: 1,
callId: call.callId,
name: 'write',
arguments: call.args,
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: text(`Created ${call.path}`),
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
turn: 1,
step: 2,
message: createAssistantMessage({
content: [{
type: 'text',
text: [
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
'',
DONE,
].join('\n'),
}],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: inline-code mentions of produced files', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mentionFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Exactly one prose mention links: `report.html` resolves to the written
// path; the shared `style.css` basename and unwritten `notes.md` stay code.
const mentions = page.locator('[class*="markdown"] code button')
await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
expect(await mentions.first().innerText()).toBe('report.html')
expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
// The turn still ends with its produced-files row (all three writes).
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})

View File

@@ -2,8 +2,8 @@
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
// api-proxy recomputes presentation views from logged args/result content)
// must render as a bash-shaped terminal card with the parsed exit-status
// pill — not the generic console-fenced card the pwsh presenter used to
// emit. The seed is authored, not recorded: its header line carries no `cwd`
// pill — not a generic console-fenced card. The seed is authored, not
// recorded: its header line carries no `cwd`
// field (seedSession writes the session cwd itself, and a Windows temp path
// substituted into the header would not round-trip through its JSON parse),
// and no event references the workspace, so the lane replays on any host

View File

@@ -1,6 +1,6 @@
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
// or the live adapter (record). Drive steps run in every mode and wait only
// on generic completion (whenTurnSettled — never model-content selectors, so
// record cannot hang on a live model answering differently); assertion steps

View File

@@ -151,7 +151,7 @@ export interface WebScaffold {
mode: WebSnapshotMode
/** Browser-facing origin for the bound test server. */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
/** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
ctx: Context
/** Temp project directory sessions run in (bash/fs tool cwd). */
workspaceCwd: string
@@ -437,7 +437,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
// No fixture and no shipped adapter would leave the tree with ZERO
// provider routes — a state no product composition has, and one the
// composer now correctly refuses to type into. Register the same routes
// composer refuses to type into. Register the same routes
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(

View File

@@ -2,7 +2,7 @@
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
// the assembled application. The built-boot smoke proves the graph boots but
// carries no behavior assertions by contract; this is the assembled-output check
// that a broken SearchRow registration or a dropped card would fail — the
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
// Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66).
// is turn 66, the grep card turn 67).
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })

View File

@@ -83,7 +83,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
* @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
@@ -91,11 +91,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
return taken
}
const commandId = 'cmd-seeded-manual-compact'
const compactionId = 'compact-seeded-manual-compact'
at({
type: 'command/run',
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
})
const startSeq = at({ type: 'compact/start', data: { turn: null } })
const startSeq = at({
type: 'compact/start',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
// Load-bearing exactness: the projections subtract this count verbatim, so
// it must equal what the host's fold prices for these nodes. The estimator
// prices message CONTENT only, so a minimal wrapper per storage shape is
@@ -124,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
const summarySeq = at({
type: 'compact/summary',
data: {
compactionId,
sourceCommandId: commandId,
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
@@ -142,12 +148,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
source: {
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
},
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn: null } })
at({
type: 'compact/end',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
at({
type: 'command/done',
data: {
@@ -263,8 +274,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
// The pinned hazard: compaction shadows the surface on the model side
// only — the prompt and full tool output must stay on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))

View File

@@ -270,7 +270,7 @@ describe('web e2e: settings modal and General preferences', () => {
await selector.click()
await page.getByRole('menuitem', { name: 'English' }).click()
// The settings-owned copy re-registers localized: dialog title, nav,
// Appearance labels. (Only the settings namespaces are localized today
// Appearance labels. (Only the settings namespaces are localized —
// the rest of the app's copy is intentionally out of this row's scope.)
const enDialog = page.getByRole('dialog', { name: 'Settings' })
await enDialog.waitFor({ timeout: 10_000 })

View File

@@ -30,6 +30,7 @@ const EXPECTED_TOOLS = [
'edit',
'exit_plan_mode',
'get_goal',
'interrupt_agent',
'list_agents',
'ralph',
'read',

View File

@@ -1,5 +1,5 @@
// Web e2e scenario: the sidebar session list's scrollbar as the browser
// actually lays it out — the observable half of the themed-scrollbar change
// actually lays it out — the observable half of the themed scrollbars
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
@@ -17,8 +17,8 @@
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
// content, consuming no layout width unless something reserves space. That is
// the mode in which the reported symptom exists at all, so this environment
// reproduces it rather than merely approximating it — measured against clean
// master, where the list's band is 0 and the bar covers 7px of the relative
// reproduces it rather than merely approximating it — without either
// declaration the list's band is 0 and the bar covers 7px of the relative
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
// the bar and nothing can be covered; a headed run under xvfb behaves that way
// and cannot show the symptom.)
@@ -40,9 +40,8 @@
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
// so it abuts the timestamp without covering it; `band` catches that case.
// Removing both — the actual master state — is what produces the reported
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
// Removing both is what produces the reported overlap, and `timeCoveredBy`
// measures it at 7.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
@@ -135,7 +134,8 @@ interface ListMetrics {
/**
* Measure the sidebar list in the page.
* @param page - the page under test.
* @returns the list's resolved scrollbar style and the geometry the fix changes.
* @returns the list's resolved scrollbar style and the geometry the
* scrollbar-gutter/thin-scrollbar declarations shape.
*/
function measureList(page: Page): Promise<ListMetrics> {
return page.evaluate(() => {
@@ -201,8 +201,8 @@ function measureList(page: Page): Promise<ListMetrics> {
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
// what this chromium paints, measured against master where the rule is
// absent. Taking the UA width as the fallback is what keeps the assertion
// what this chromium paints, measured with the rule absent. Taking the
// UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
@@ -261,13 +261,14 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
* declarations establish.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the change. What is recorded instead is the band, the overlap,
* platform, not the behavior. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* @param light - metrics measured under the light palette.
@@ -417,12 +418,13 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
// vacuous under an overlay scrollbar, because it measures against the bar's
// own width rather than against a content edge the overlay bar does not
// move. It is not a replacement for the band assertion above; see the file
// header for which regression each one catches.
// lies under the bar. Without either declaration it measures 7 — the `h`
// of `1h` is the covered part. Unlike the client-edge comparison below it
// does not go vacuous under an overlay scrollbar, because it measures
// against the bar's own width rather than against a content edge the
// overlay bar does not move. It is not a replacement for the band
// assertion above; see the file header for which regression each one
// catches.
expect(metrics.timeCoveredBy).toBe(0)
// Corollaries of the reservation, kept because they pin where the band sits
// rather than only that it exists: the time ends inside the content area,
@@ -451,7 +453,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(quiet.band).toBeGreaterThan(0)
expect(quiet.timeCoveredBy).toBe(0)
// Scrolling without a pointer — what a keyboard or a touch drag does —
// leaves the column quiet. This is the change's one deliberate loss, and
// leaves the column quiet. This is the one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })

View File

@@ -27,7 +27,7 @@ const MODE = webSnapshotMode()
const HOLD_PROVIDER = 'web-test-hold'
const HOLD_MODEL = 'hold'
/** Model seam that completes the owner turn, then holds its delegated child open. */
/** Model stub that completes the owner turn, then holds its delegated child open. */
class StagedAdapter extends LlmAdapter {
activeCalls = 0
private calls = 0

View File

@@ -2,7 +2,7 @@
// the composer (issue #1470). The entered `/name args` line claims into
// skill.invoke: the real host forwards the gesture as an ordinary user
// prompt, injects the rendered body as instructions context named after the
// skill, and starts a turn answered by the replay seam. The transcript shows
// skill, and starts a turn answered by the replay adapter. The transcript shows
// the gesture bubble, the collapsed context-injection row, and the reply.
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
{ timeout: 10_000 },
).toBe(1)
const settled = scaffold.whenTurnSettled()
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
await composer.press('Enter')
@@ -133,8 +134,9 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
expect(injected).not.toContain(ARGS_TEXT)
await injectionRow.click()
// The injection started a turn; the replay seam answers it.
// The injection started a turn; the replay adapter answers it.
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
await settled
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)

View File

@@ -1,4 +1,4 @@
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
// Real-host smoke: spawn `dsh web` with a real key, walk the full flow
// list in a real chromium, screenshot every screen into .artifacts/ for the
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
// convention); vitest.web.config.ts loads the repo-root .env before this file
@@ -11,8 +11,9 @@
// (frame/handle) rides local names that survive hashing as suffixes; prefer
// data-* for anything new.
//
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
// and theme after, reload recovery last. Tests run sequentially in-file.
// Flow order matters: chat rounds first (the bash round reuses the first
// send's session), geometry and theme after, reload recovery last. Tests run
// sequentially in-file.
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
@@ -119,7 +120,7 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
}).toBe(true)
}
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
}
@@ -465,7 +466,7 @@ describe('dsh web keyless CLI smoke', () => {
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
let child: ChildProcess
let sessionsDir: string
let baseUrl: string
@@ -518,7 +519,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
})
it('1 cold start: loading page settles into the three-column frame', async () => {
it('cold start: loading page settles into the three-column frame', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
@@ -527,7 +528,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '01-cold-start')
})
it('2+3 empty-state first send completes a real model round', async () => {
it('empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// This scenario spawns its own server against a fresh $DSH_HOME, so the
// first-run welcome notice is unacknowledged and its overlay owns pointer
@@ -590,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '07-back-to-chat')
})
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
it('bash differential rendering: tool row click leaves the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
const input = page.locator('textarea').first()
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
@@ -604,12 +605,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)
await toolRow.click()
// Tool rows no longer drive layout.openDetails; the default column stays closed.
// Tool rows do not drive layout.openDetails; the default column stays closed.
expect(await detailsTrack(page)).toBe(0)
await screen(page, '09-details-closed')
}, 150_000)
it('6 sidebar drag widens the column and resets across reload', async () => {
it('sidebar drag widens the column and resets across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
const before = await firstTrack(page)
const handle = page.locator('[class*="handle"]').first()
@@ -627,10 +628,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
expect(await firstTrack(page)).toBe(before)
})
it('7 dark mode: the body attribute cascades the token sheets', async () => {
it('dark mode: the body attribute cascades the token sheets', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
// in P-I, so the acceptance drives the documented mechanism directly.
// The body attribute is the documented cascade mechanism; the Settings
// gesture is owned by settings-chrome.e2e.ts — drive the attribute
// directly here.
const dark = await page.evaluate(() => {
document.body.setAttribute('data-ds-dark-theme', '')
return getComputedStyle(document.body).backgroundColor
@@ -643,7 +645,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
expect(dark).not.toBe(light)
})
it('8 reload recovery: history replays after a fresh boot', async () => {
it('reload recovery: history replays after a fresh boot', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -14,10 +14,10 @@
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}

View File

@@ -0,0 +1,23 @@
- banner:
- navigation "Session hierarchy":
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Explain event sourcing in one sentence. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
- button "Commands" [disabled]:
- img
- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write
- button "Stop generating"
- button "Send message" [disabled]

View File

@@ -3,10 +3,10 @@
// A page load with a workspace already registered runs
// `WorkspacesService.startInitialSelection`: it connects the most recent
// workspace and opens its blank session. `openState` flips to `loading` the
// moment `open()` lands, which used to drive `data-phase=settling` on the
// conversation root — `visibility:hidden` over the composer seat and the
// header for the whole `session.history` round-trip, so the center column went
// blank and repainted, reading as a full-page refresh on every launch.
// moment `open()` lands; driving `data-phase=settling` on the conversation
// root from that flip would hide the composer seat and the header
// (`visibility:hidden`) for the whole `session.history` round-trip — the
// center column blanks and repaints like a full-page refresh on every launch.
//
// The unit spec pins the phase condition over hand-built stores. What only the
// assembled application can show is that the path a user actually takes
@@ -19,8 +19,9 @@
// The round-trip against a loopback host is far too fast to observe, so this
// scenario HOLDS the `session.history` response open at the browser's network
// boundary and asserts the visible frame while it is in flight. That gate is
// what makes the assertions non-vacuous: with the exemption reverted the held
// window is exactly when `settling` is painted and the composer is hidden.
// what makes the assertions non-vacuous: without the phase exemption, the
// held window is exactly when `settling` would be painted and the composer
// hidden.
//
// Zero model calls: registering a workspace and opening its blank session are
// host RPCs with no model involvement. A stray stream would fail loud with

View File

@@ -0,0 +1,319 @@
// Web e2e scenario: the composer's independent Stop interrupts a running
// continuable child. The child holds its model turn open through a replay
// hang entry; the browser proves Send and Stop coexist, the parent-offline
// disabled-Send-with-Stop composer, the subagent.interrupt
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
// on a waking send.
//
// Replay-binding note: only the PRIMARY script can hang, and scripts bind by
// first-call order, so the child issues the composition's first model call
// (claiming the overridden primary) and the parent's one UI prompt — needed
// so the non-blank parent renders its header catalog — binds to a derived
// child fixture afterwards.
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url))
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const INITIAL = 'Explain event sourcing in one sentence.'
const REARM = 'Keep working until I stop you again.'
const REARM_WAKE = 'Start that queued work now.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const WAKING = 'And add one concrete example.'
const REARMED_ANSWER = 're-armed setup answer'
const PARKED_ANSWER = 'parked follow-up answer'
const WAKING_ANSWER = 'waking answer'
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Resolve on one exact child's next aborted turn end. */
function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error('interrupt did not reach an aborted turn/end'))
}, 30_000)
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
if (session.id !== childId || event.type !== 'turn/end') return
clearTimeout(timer)
off()
if (event.data.reason.kind === 'aborted') resolve()
else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
})
})
}
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
function textCompletion(text: string): object {
return {
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}
}
describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let sidecarRoot: string
let rearmedReadyFile: string
let parent: Agent
let childId: SessionId
let tripwire: ReturnType<typeof watchConsole>
const apiCalls: string[] = []
beforeAll(async () => {
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
const readyFile = join(sidecarRoot, 'hang-ready')
rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
// The child claims this whole-script replacement: the offline and online
// interrupt paths each hold one turn, then the parked and waking turns settle.
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
{ kind: 'hang', readyFile },
{ kind: 'hang', readyFile: rearmedReadyFile },
textCompletion(REARMED_ANSWER),
textCompletion(PARKED_ANSWER),
textCompletion(WAKING_ANSWER),
]))
await writeFile(
join(sidecarRoot, 'session.jsonl'),
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
)
// The parent's one prompted turn replays this recorded single text-only
// call (binding is positional, not lineage-aware).
const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
const base = await readFile(BASE_FIXTURE, 'utf8')
const [header, ...events] = base.trimEnd().split('\n')
if (header === undefined) throw new Error('base replay fixture has no header')
await writeFile(parentTurnPath, [
header
.replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
.replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
...events,
'',
].join('\n'))
scaffold = await launchWebScaffold({
replayFixture: join(sidecarRoot, 'session.jsonl'),
replayOverride: join(sidecarRoot, 'replay.override.json'),
replayChildFixtures: [parentTurnPath],
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
page.on('request', (request) => {
const path = new URL(request.url()).pathname
if (path.startsWith('/api/')) apiCalls.push(path)
})
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const root = scaffold.ctx.agents.roots()[0]
if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
parent = root
// The child's first model call claims the primary override and holds.
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: LABEL,
signal: new AbortController().signal,
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
})
childId = started.childId
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
// One prompted parent turn makes the parent non-blank so the session
// header (and its subagent catalog action) renders.
const parentSettled = scaffold.whenTurnSettled()
const parentInput = page.locator('textarea:enabled').first()
await parentInput.fill('Ask a research subagent to explain event sourcing.')
await parentInput.press('Enter')
expect(await parentSettled).toBe(parent.id)
// Reload onto the restart baseline (the proven route to a freshly
// discovered catalog), with the child still live and running host-side.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (sidecarRoot !== undefined) {
await rm(sidecarRoot, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed')
})
it('interrupts the live child through the parent-offline composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
// Simulate a parent that went offline: the catalog delivers
// parentAvailable: false while the child Activation stays live (the
// interrupt RPC itself needs no live parent — covered host-side by
// subagent-interrupt.e2e.ts).
const pattern = '**/api/subagent.list'
await page.route(pattern, async (route) => {
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
}
if (body.result.ok) body.result.value.parentAvailable = false
await route.fulfill({ response, json: body })
})
try {
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', {
name: 'Parent session offline; sending is unavailable but you can still stop the run',
})
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(true)
const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1)
expect(await stop.isEnabled()).toBe(true)
const send = page.getByRole('button', { name: 'Send message' })
expect(await send.count()).toBe(1)
expect(await send.isDisabled()).toBe(true)
await compareOrRefreshGolden(
OFFLINE_COMPOSER_EXPECTED,
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
MODE,
)
// Keep the continuable Activation resident after this first abort. The
// direct setup queue does not change the parent-offline UI contract: its
// input and Send remain disabled throughout the exercised browser path.
await scaffold.ctx.subagents.followup(
parent,
childId,
[{ type: 'text', text: REARM }],
{ source: { kind: 'user' }, signal: new AbortController().signal },
)
const aborted = waitForAbortedTurn(scaffold, childId)
const interruptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.interrupt')
await stop.click()
expect(((await (await interruptResponse).json()) as {
result: { ok: boolean; value?: { accepted: boolean } }
}).result).toMatchObject({ ok: true, value: { accepted: true } })
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
await aborted
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
// Wake the parked setup message only after cancellation converges. A
// second hang keeps the parent-available case independent from this stop.
await scaffold.ctx.subagents.followup(
parent,
childId,
[{ type: 'text', text: REARM_WAKE }],
{ source: { kind: 'user' }, signal: new AbortController().signal },
)
await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
} finally {
await page.unroute(pattern)
}
}, 60_000)
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
// Reselect the child with the truthful catalog: parent available again.
await page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', { name: 'Message the agent' })
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(false)
// Queue a follow-up through Send while independent Stop remains available.
const promptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.prompt')
await input.fill(FOLLOWUP)
await page.getByRole('button', { name: 'Send message' }).click()
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
.toMatchObject({ ok: true })
const aborted = waitForAbortedTurn(scaffold, childId)
const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1)
const interruptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.interrupt')
await stop.click()
expect(((await (await interruptResponse).json()) as {
result: { ok: boolean; value?: { accepted: boolean } }
}).result).toMatchObject({ ok: true, value: { accepted: true } })
// The addressed child stops through its own RPC, never the generic one.
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
await aborted
// Parked: the Activation stays resident and idle with the retained
// follow-up; the primary returns to Send without a new turn starting.
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined()
expect(child!.inbox.nextTurn).toHaveLength(2)
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
// Only the waking send resumes the parked queue, FIFO, to settlement.
await input.fill(WAKING)
await input.press('Enter')
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING])
const turnEndKinds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => event.data.reason.kind)
expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
})
})

View File

@@ -0,0 +1,176 @@
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
// composition. A live continuable child holds its model turn open through a
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
// proves from the real session state that the turn aborted, the follow-up
// parked without auto-starting a new turn, and a later waking send resumed the
// preserved FIFO order. No browser: the RPC surface is the product surface
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
const MODE = webSnapshotMode()
const INITIAL = 'Explain event sourcing in one sentence.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const WAKING = 'And add one concrete example.'
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
const response = await fetch(`${baseUrl}/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
method,
payload,
}),
})
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
return (await response.json() as { result: RpcResult<T> }).result
}
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
function textCompletion(text: string): object {
return {
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}
}
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
let scaffold: WebScaffold
let sidecarRoot: string
let readyFile: string
let parentId: SessionId
let childId: SessionId
beforeAll(async () => {
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
readyFile = join(sidecarRoot, 'hang-ready')
// Whole-script replacement: the child's three model calls are the hang
// (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
// The parent never runs a turn, so the child claims this primary script.
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
{ kind: 'hang', readyFile },
textCompletion('resumed response one'),
textCompletion('resumed response two'),
]))
// Header-only primary fixture: the bare-array override replaces the
// derived script entirely; the path only anchors replay installation.
await writeFile(
join(sidecarRoot, 'session.jsonl'),
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
)
scaffold = await launchWebScaffold({
replayFixture: join(sidecarRoot, 'session.jsonl'),
replayOverride: join(sidecarRoot, 'replay.override.json'),
})
// A live parent Agent through the real API; no workspace or browser.
const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
cwd: scaffold.workspaceCwd,
})
if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
parentId = sessionId(created.value.sessionId)
const parent = scaffold.ctx.agents.get(parentId)
if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: 'event-sourcing researcher',
signal: new AbortController().signal,
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
})
childId = started.childId
// The hang entry writes readyFile after its prefix chunks, immediately
// before waiting for cancellation: the deterministic "turn is open" gate.
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await scaffold?.close().catch((error: unknown) => failures.push(error))
await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed')
})
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
// Queue the follow-up while the turn is still open, then interrupt.
const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
content: [{ type: 'text', text: FOLLOWUP }],
})
expect(queued).toMatchObject({ ok: true })
const settled = scaffold.whenTurnSettled()
const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
})
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
// accepted acknowledges the admitted cancel, not quiescence: wait for the
// aborted turn/end (the composition's first turn/end) before asserting.
expect(await settled).toBe(childId)
// Parked, not resumed: the Activation stays resident with an idle driver,
// the follow-up is retained, and no second turn opened.
const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined()
expect(child!.status).toBe('idle')
expect(child!.inbox.nextTurn).toHaveLength(1)
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
expect((lastEnd)?.data.reason.kind).toBe('aborted')
// Only an explicit waking send resumes the parked queue, FIFO, then the
// child runs both turns to completion and settles.
const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
content: [{ type: 'text', text: WAKING }],
})
expect(waking).toMatchObject({ ok: true })
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
// Human-origin messages only: the real composition also injects
// runtime-context snapshots as non-user-source messages.
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
const turnEndKinds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => (event).data.reason.kind)
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
}, 120_000)
})

View File

@@ -2,7 +2,7 @@
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the
// keyless FixtureApiClient transport, opens the fixture session, and pins the
// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
// reaches — the `todo_write` tool row and the dock's plan strip.
//
// The row is pinned as three separate fields on purpose. `summary=` is the

View File

@@ -1,6 +1,6 @@
// Web e2e scenario: assistant IconActions belong to the settled answer, so
// they arrive with `turn/end` and not before. The recorded turn narrates in
// plain text before its tool call, which is the shape that used to hand the
// plain text before its tool call, which is the shape that would hand the
// footer to mid-turn narration for the seconds a tool runs and then move it
// down. A `hang` sidecar on the SECOND model call parks the turn after the
// narration and the tool result are durable, so the running state is stable by

View File

@@ -91,8 +91,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
// a delete mints a fresh blank Session+Agent too (no cwd-based reuse
// exists), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -254,7 +254,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
// (no cwd-based blank reuse exists, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -482,7 +482,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// The card is REACHABLE: it sits 8px off the row, so getting to it means
// crossing ground that belongs to neither. Hovering it must not dismiss
// it — the regression this scenario guards.
// it — the hazard this scenario pins.
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
await card.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
@@ -515,10 +515,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
// that return trip used to fire the list's pointerleave and close the
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
// the pointer ON the trigger, so entering the list has to come first for
// the return to be a real departure.
// without the gap-crossing grace, that return trip fires the list's
// pointerleave and closes the menu — a hesitating pointer loses it.
// Order matters — clicking leaves the pointer ON the trigger, so entering
// the list has to come first for the return to be a real departure.
await item.hover()
await page.waitForTimeout(POINTER_TRANSIT_MS)
await trigger.hover()