Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

This commit is contained in:
Yichen Jiang
2026-08-08 22:54:07 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -26,6 +26,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',
@@ -118,7 +119,7 @@ export function mountAssembledApp(): void {
* Match a CSS-module class by its logical name.
* Module class names carry a per-build hash in one of two schemes —
* ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
* ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name
* feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
* containing this one must not match (`line` must not hit `lineNumber`).
* @param el - element whose class list is inspected.
* @param name - logical (unhashed) module class name.

View File

@@ -95,7 +95,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-tool']) {
expect(styleOwners).toContain(plugin)
}
})

View File

@@ -0,0 +1,95 @@
// 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.
// 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'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
/** Starts the shipped default on this scenario's declared reasoning model. */
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
// The whole reasoning offer is the profile: key = selectable level, value
// = the wire spelling dispatch would send (`max: ultra` renames; the
// valueless `off` means "supported, send nothing"). The route sets no
// deployment default, so the pane leads with the provider-default entry.
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{
id: 'acme-think',
name: 'Acme Think',
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
}],
},
},
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers exactly the declared levels and records the picked one', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
await page.getByRole('menuitem', { name: /推理等级/ }).click()
// Declared levels, nothing else: the provider-default entry (the route
// configures no `reasoning`), then Off/High/Max — minimal, low, medium,
// and xhigh were not declared and must not be offered.
const levels = page.getByRole('menuitemradio')
await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
.toEqual(['Default', 'Off', 'High', 'Max'])
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.
await page.getByRole('menuitemradio', { name: 'High' }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).toContain('reasoningEffort: high')
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('选择模型,当前 Acme Think推理等级 High')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -0,0 +1,8 @@
# The fixture-less web scaffold registers no adapter, so the shipped
# 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
config:
provider: acme-gateway
model: acme-think

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

@@ -33,7 +33,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import Group from '@cordisjs/plugin-group'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
import {
addHarnessSourceSection,
assertEntriesLoaded,
@@ -592,11 +592,14 @@ function rawSessionLog(session: Session): string {
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
const fresh = scrubRequestHeaders(rawSessionLog(agent.session))
.split(sessionId).join('{{sessionId}}')
.split(scaffold.workspaceCwd).join('{{cwd}}')
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
await writeFile(fixturePath, tokenized)
const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
const stable = stabilizeFixtureMessageIds([fresh], [existing])[0]
if (stable === undefined) throw new Error('record harvest: no stabilized fixture')
await writeFile(fixturePath, stable)
}
/**
@@ -689,8 +692,9 @@ export async function seedSession(
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
* decode-throughput volatility collapse to stable tokens.
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration,
* decode-throughput, and path-sensitive compaction estimates collapse to
* stable tokens.
*
* Throughput needs a token for the same reason durations do, and no fixture
* can supply one: the figure divides a replayed step's output tokens by the
@@ -717,6 +721,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
duration => duration.startsWith('约') ? duration : '{{duration}}',
)
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
// Seeded compaction prices realized file paths, whose length differs
// between local worktrees and CI scratch directories.
.replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')

View File

@@ -4,8 +4,9 @@
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded
// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds
// into its checkpoint, while an Access-chip pick later runs `/permission` on
// the host. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
@@ -40,18 +41,18 @@ const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* Append a complete manual `/compact` lifecycle and valid compaction transaction
* over the recorded turn's own surface. The recording stays model-authentic and
* reusable; replay adds this deterministic condition before seeding it cold, so
* the scenario pins both the log-preserving marker and its single-card command
* presentation through the real host and browser.
* @param raw - the seed fixture text, already realized (placeholder-free) so
* the shadow price below is computed from the exact strings the host folds.
* @param meter - the composed token meter; the appended `compact/summary`'s
* shadow price must be the exact heuristic price of the shadowed nodes, the
* way compact-basic derives it, because the token-meter projections subtract
* it verbatim.
* @returns the fixture with a compacted turn appended.
* @returns the fixture with a manual compaction lifecycle appended.
*/
function withCompaction(raw: string, meter: TokenMeterService): string {
const lines = raw.trimEnd().split('\n')
@@ -74,14 +75,10 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
@@ -94,8 +91,12 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const commandId = 'cmd-seeded-manual-compact'
at({
type: 'command/run',
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
})
const startSeq = at({ type: 'compact/start', data: { 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
@@ -147,8 +148,21 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
at({ type: 'compact/end', data: { turn: null } })
at({
type: 'command/done',
data: {
commandId,
kind: 'success',
text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`,
sourceEventSeq: summarySeq,
},
})
// The persistence seed helper requires a terminal turn/end. Keep the manual
// command standalone, then add a closed zero-step fixture boundary after it.
const closureTurn = lastTurn + 1
at({ type: 'turn/start', data: { turn: closureTurn } })
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
@@ -257,7 +271,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
timeout: 10_000,
}).toBe(1)
expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
@@ -381,7 +399,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()

View File

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

View File

@@ -0,0 +1,156 @@
import { mkdir } from 'node:fs/promises'
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 { AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-workspace'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-subagent-activity', import.meta.url))
const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md')
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. */
class StagedAdapter extends LlmAdapter {
activeCalls = 0
private calls = 0
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (this.calls === 0) {
this.calls += 1
yield { type: 'finish', reason: { kind: 'stop' } }
return
}
this.calls += 1
const signal = options.signal
if (signal === undefined) throw new Error('staged Web adapter requires a turn signal')
this.activeCalls += 1
try {
await new Promise<never>((_resolve, reject) => {
const abort = (): void => {
reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted'))
}
if (signal.aborted) abort()
else signal.addEventListener('abort', abort, { once: true })
})
} finally {
this.activeCalls -= 1
}
}
}
async function waitForRunningChild(
scaffold: WebScaffold,
adapter: StagedAdapter,
childId: SessionIdValue,
): Promise<void> {
const deadline = Date.now() + 10_000
while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') {
if (Date.now() >= deadline) throw new Error('held child did not enter its running model call')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
describe('web e2e: sidebar subagent activity', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let parentHandle: AgentHandle
let childId: SessionIdValue
let adapter: StagedAdapter
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold()
adapter = new StagedAdapter()
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter),
'sidebar subagent activity staged adapter',
)
const cwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(cwd)
parentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('sidebar-activity-owner'),
meta: { cwd },
agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL },
})
parentHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Delegate a background task.' }],
source: { kind: 'user' },
}))
await parentHandle.agent.whenIdle()
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: 'sidebar activity child',
signal: new AbortController().signal,
request: {
prompt: [{ type: 'text', text: 'Hold this delegated task open.' }],
parent: parentHandle.agent,
},
})
childId = started.childId
await waitForRunningChild(scaffold, adapter, childId)
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
await workspace.attachSession(parentHandle.agent.session.id)
}, 60_000)
afterAll(async () => {
const failures: unknown[] = []
const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId)
if (child !== undefined) {
child.cancel({ kind: 'user' })
await child.whenIdle().catch((error: unknown) => failures.push(error))
}
await browser?.close().catch((error: unknown) => failures.push(error))
await parentHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'sidebar subagent activity teardown failed')
})
it('pins a running descendant on its visible idle owner row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity'))
const sidebar = page.getByRole('tree', { name: 'Sessions' })
const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background task/ })
await ownerRow.waitFor({ timeout: 10_000 })
expect(parentHandle.agent.status).toBe('idle')
await compareOrRefreshGolden(
RUNNING_OWNER_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
MODE,
)
expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1)
await ownerRow.click()
const runningTrigger = page.getByRole('button', { name: '1 subagent running' })
await runningTrigger.waitFor({ timeout: 10_000 })
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -1,5 +1,6 @@
// Web e2e scenario: the real host filters skill.list to the model-and-user
// intersection before the browser slash source renders candidates. A real
// Web e2e scenario: the real host serves every user-invocable skill to the
// browser slash source — user-only (disable-model-invocation) entries appear
// with their marker while user-disabled quadrants stay hidden. A real
// chromium connects a fresh workspace seeded with all four policy quadrants;
// no model call is issued, so a stray stream fails loud on the open LLM seam.
import { mkdir, writeFile } from 'node:fs/promises'
@@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
await scaffold?.close()
})
it('renders only the model-and-user intersection in slash candidates', async () => {
it('renders every user-invocable skill and marks the user-only entry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
const input = page.locator('textarea').first()
await input.fill('/policy')
@@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => {
{ timeout: 10_000 },
).toBe(1)
// The user-only quadrant is invocable here — its only entry point — and
// wears the user-only marker; both user-disabled quadrants stay hidden.
expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1)
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)

View File

@@ -0,0 +1,148 @@
// Web e2e scenario: a user invokes a disable-model-invocation skill through
// 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
// 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'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SKILL_NAME = 'user-invoke-demo'
const ARGS_TEXT = 'and confirm the fixture wiring'
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${SKILL_NAME}`,
'description: Prove user-explicit invocation of a model-hidden skill',
'disable-model-invocation: true',
'---',
'',
'Reply with the fixture acknowledgement line.',
'',
].join('\n'))
}
const REPLAY: ReplayOverrideDoc = [{
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: REPLY },
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}]
describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let replayDir: string
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(REPLAY))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
// Paced replay keeps the timing-derived chrome (TTFT / tok/s) present
// deterministically; instant playback races it in and out of the golden.
paceMs: 10,
})
await seedUserOnlySkill(scaffold.workspaceCwd)
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 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 (replayDir !== undefined) {
await rm(replayDir, { 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, 'skill-user-invoke e2e cleanup failed')
})
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
const composer = page.locator('textarea:enabled').last()
await composer.waitFor({ timeout: 15_000 })
// The menu lists the user-only skill (its only entry point) before enter.
await composer.fill(`/${SKILL_NAME}`)
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await expect.poll(
() => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
{ timeout: 10_000 },
).toBe(1)
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
await composer.press('Enter')
// The gesture stays an ordinary user bubble (decorated /name token plus
// the trailing text), ahead of the injected context.
const bubble = page.locator('[data-ref-chip="skill"]').first()
await bubble.waitFor({ timeout: 15_000 })
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
// The rendered body arrives as a context-injection row named after the
// skill; expanding it reveals the canonical <skill_content> block, and
// the user's text is NOT folded into it.
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
await injectionRow.waitFor({ timeout: 15_000 })
await injectionRow.click()
const injectionBody = page
.locator('[data-context-injection-body]')
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
await injectionBody.waitFor({ timeout: 10_000 })
const injected = await injectionBody.textContent()
expect(injected).toContain('Reply with the fixture acknowledgement line.')
expect(injected).not.toContain(ARGS_TEXT)
await injectionRow.click()
// The injection started a turn; the replay seam answers it.
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -0,0 +1,7 @@
- menu "模型与推理等级":
- menuitemradio "Default" [checked]:
- text: Default
- img
- menuitemradio "Off"
- menuitemradio "High"
- menuitemradio "Max"

View File

@@ -1,13 +1,13 @@
kind=matches
summary=显示 9 / 共 42 处匹配 · 3 个文件
file=packages/client/ui-primitives/src/SearchBlock.tsx3
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
line=138: export function SearchBlock(props: SearchBlockProps) {
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
line=35: const search = searchCardModel(block)
line=52: search={search}
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
line=36: const search = searchCardModel(block)
line=56: search={search}
line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
@@ -15,13 +15,13 @@ packages/client/ui-primitives/src/SearchBlock.tsx
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
Line 138: export function SearchBlock(props: SearchBlockProps) {
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
packages/client/ui-conversation/src/client/contract/search-card-model.ts
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 35: const search = searchCardModel(block)
Line 52: search={search}
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
packages/client/ui-tool/src/client/tool/models/search-card-model.ts
Line 45: export const CHAT_SEARCH_MAX_LINES = 8
Line 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
Line 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 36: const search = searchCardModel(block)
Line 56: search={search}
Line 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -31,9 +31,7 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img

View File

@@ -31,9 +31,7 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img

View File

@@ -0,0 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "1 subagent running Delegate a background task. now"
- treeitem "New Session" [selected]

View File

@@ -1,3 +1,4 @@
- listbox "Trigger suggestions":
- text: Skills
- option "policy-shared Available to both model and user invocation" [selected]
- option "policy-user-only user-only · Available only to user invocation"

View File

@@ -0,0 +1,33 @@
- banner:
- navigation "Session hierarchy":
- button "/user-invoke-demo and confirm the fixtur" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Context injection user-invoke-demo":
- img
- img
- text: Context injection user-invoke-demo
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok

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

@@ -365,22 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
() => scaffold.ctx.agents.get(childId)?.status,
{ timeout: 10_000 },
).toBe('running')
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await hierarchy.getByRole('button').first().click()
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
await runningTrigger.waitFor({ timeout: 10_000 })
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
await runningTrigger.click()
await page.getByRole('treeitem', {
name: new RegExp(`${LABEL}.*running`),
}).waitFor({ timeout: 10_000 })
await ended
await page.getByRole('treeitem', {
name: new RegExp(`${LABEL}.*not running`),
}).waitFor({ timeout: 10_000 })
expect(await page.getByRole('button', { name: '3 subagents' })
.locator('[data-state="ongoing"]').count()).toBe(0)
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)

View File

@@ -0,0 +1,318 @@
// 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 — PR 1's host coverage).
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 PR-stacked UI coverage 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

@@ -38,6 +38,7 @@
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/default-model.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
@@ -57,6 +58,7 @@
"tests/markdown-inline-code-links.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/skill-user-invoke.e2e.ts",
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/agent-preset-selection.e2e.ts",
@@ -64,8 +66,12 @@
"tests/shipped-composition.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/subagent-interrupt.e2e.ts",
"tests/subagent-interrupt-ui.e2e.ts",
"tests/sidebar-subagent-activity.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
"tests/turn-tail-actions.e2e.ts",