Merge master into subagent usage branch

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
#	apps/web/tests/snapshots/subagent-conversation/tree.expected.md
#	apps/web/tests/subagent-conversation.e2e.ts
#	packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
This commit is contained in:
kingwl
2026-08-02 23:37:17 +08:00
102 changed files with 1586 additions and 291 deletions

View File

@@ -72,7 +72,6 @@ describe('core Web profile', () => {
"tools": [
"bash",
"str_replace_editor",
"list_agents",
],
}
`)

View File

@@ -158,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const observeTurn = async () => {
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
try {
await input.press('Enter')
if (MODE !== 'record') {
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
await expect.poll(async () => await liveTail.evaluate(element => (
element.scrollWidth > element.clientWidth
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
)), { timeout: 10_000, interval: 10 }).toBe(true)
}
return await settled
} finally {
if (MODE !== 'record') await page.setViewportSize(originalViewport)
}
}
const sessionId = await observeTurn()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}

View File

@@ -14,5 +14,5 @@
- button "Branch into a new conversation":
- img
- status:
- strong: 此子代理暂时只读
- text: 父会话当前不在线,重新打开父会话后即可继续发送消息。
- strong: This subagent is read-only for now
- text: The parent session is offline; reopen it to continue sending messages.

View File

@@ -0,0 +1,3 @@
- tree "Subagent sessions":
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…

View File

@@ -1,8 +1,8 @@
- tree "子代理会话":
- treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒" [expanded] [level=1]:
- button "收起 event-sourcing researcher 的下级子代理":
- tree "Subagent sessions":
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
- button "Collapse event-sourcing researcher descendants":
- img
- text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}
- group:
- treeitem "example editor 可继续 · 当前未运行 0 tok · 0秒" [level=2]
- treeitem "event-sourcing reviewer 一次性 · 当前未运行 0 tok · 0秒" [level=1]
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]
- treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]

View File

@@ -3,8 +3,8 @@
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- button "1 个子代理":
- text: 1 个子代理
- button "1 subagent":
- text: 1 subagent
- img
- tablist:
- tab "Chat" [selected]

View File

@@ -15,11 +15,12 @@ import {
launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
@@ -77,7 +78,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
paceMs: 25,
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
page.on('request', (request) => {
const path = new URL(request.url()).pathname
if (path.startsWith('/api/')) apiCalls.push(path)
@@ -219,13 +220,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const catalogButton = page.getByRole('button', { name: /个子代理/ })
const catalogButton = page.getByRole('button', { name: /subagents/ })
await catalogButton.waitFor({ timeout: 15_000 })
await catalogButton.click()
const catalogTree = page.getByRole('tree', { name: '子代理会话' })
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
await catalogTree.press('Escape')
await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 })
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
}, 120_000)
@@ -241,13 +242,66 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
})
it('keeps known descendants reachable across a stale empty catalog response', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
const pattern = '**/api/subagent.list'
let firstClaimed = false
let emptyDelivered = false
let trailingRequested = false
let releaseCatalog = (): void => {}
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
await page.route(pattern, async (route) => {
if (firstClaimed) {
const response = await route.fetch()
trailingRequested = true
await catalogHeld
await route.fulfill({ response })
return
}
firstClaimed = true
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
}
if (body.result.ok) body.result.value.entries = []
await route.fulfill({ response, json: body })
emptyDelivered = true
})
const warningStart = tripwire.warnings.length
try {
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '3 subagents' }).click()
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
await compareOrRefreshGolden(
STALE_CATALOG_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
MODE,
)
releaseCatalog()
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
await tree.press('Escape')
} finally {
releaseCatalog()
await page.unroute(pattern)
}
})
it('expands a persisted grandchild progressively without activating either level', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
expect(await page.getByRole('button', {
name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`,
name: `Expand ${ONE_SHOT_LABEL} descendants`,
}).count()).toBe(0)
await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click()
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
const childLabel = await childRow.getAttribute('aria-label')
await page.waitForTimeout(1_100)
@@ -257,16 +311,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
const snapshot = await captureStableAria(
page,
'[role="tree"][aria-label="子代理会话"]',
'[role="tree"][aria-label="Subagent sessions"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
await page.getByRole('tree', { name: '子代理会话' }).press('Escape')
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
})
it('opens the completed child from persistence without activating it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await expect.poll(
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
@@ -308,18 +362,18 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
).toBe('running')
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await hierarchy.getByRole('button').first().click()
const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' })
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}.*正在运行`),
name: new RegExp(`${LABEL}.*running`),
}).waitFor({ timeout: 10_000 })
await ended
await page.getByRole('treeitem', {
name: new RegExp(`${LABEL}.*当前未运行`),
name: new RegExp(`${LABEL}.*not running`),
}).waitFor({ timeout: 10_000 })
expect(await page.getByRole('button', { name: '3 个子代理' })
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)
@@ -337,9 +391,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
it('opens an unavailable persisted grandchild after recording the available child', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
await page.getByRole('button', { name: '1 个子代理' }).click()
await page.getByRole('button', { name: '1 subagent' }).click()
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor()
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
const crumbs = await hierarchy.getByRole('button').allTextContents()
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
@@ -358,9 +412,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
.getByRole('treeitem')
.last()
await parentSession.click()
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor()
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
})
@@ -369,7 +423,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem', { name: /Ask a research subagent to/ })
.click()
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
const forkResponse = page.waitForResponse(response =>
@@ -395,7 +449,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.locator('textarea:enabled').first().waitFor()
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
@@ -412,7 +466,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
await page.getByRole('button', { name: '3 个子代理' }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.locator('textarea:enabled').first()
await input.waitFor()