Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
95
apps/web/tests/declared-reasoning.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
8
apps/web/tests/declared-reasoning.overlay.yml
Normal 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
|
||||
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal 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)
|
||||
})
|
||||
@@ -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}}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -31,6 +31,7 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'interrupt_agent',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
|
||||
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal file
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
148
apps/web/tests/skill-user-invoke.e2e.ts
Normal file
148
apps/web/tests/skill-user-invoke.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu "模型与推理等级":
|
||||
- menuitemradio "Default" [checked]:
|
||||
- text: Default
|
||||
- img
|
||||
- menuitemradio "Off"
|
||||
- menuitemradio "High"
|
||||
- menuitemradio "Max"
|
||||
@@ -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.)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
|
||||
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal file
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal 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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
318
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
318
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal 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)
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user