test(web): isolate sidebar subagent activity snapshot

This commit is contained in:
Yichen Jiang
2026-08-08 15:46:08 +08:00
parent b3a53c1e51
commit 81d32bf52c
9 changed files with 164 additions and 19 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: 1b9bbfbc566e77932629af9f1e0714819973f111
2026-07-27-web-subagent-conversations.zh.md: a9ed0e20f3617f7f5f8ae9d0f2d77c7ba8aef9ea
2026-07-27-web-subagent-conversations.md: bcfaf5d958d2e886082d24c37da7600abb47953c
2026-07-27-web-subagent-conversations.zh.md: 6b7299b4089c6413b99d2bedbc2bb8961b27735c
@@ -105,7 +105,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, aggregate running transition in the header and owner sidebar row, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the header's aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the visible idle owner's aggregate running state, then cancels the turn during teardown.
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
## Consequences
@@ -105,7 +105,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及页头和 owner 侧边栏行中的聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及页头中的聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定可见空闲 owner 的聚合运行状态,随后在 teardown 期间取消该轮次。
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
## 后果
@@ -0,0 +1,152 @@
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 assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -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,5 +0,0 @@
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "1 subagent running Ask a research subagent to now" [selected]
@@ -23,7 +23,6 @@ const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/t
const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.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 RUNNING_SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar-running.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))
const MODE = webSnapshotMode()
@@ -368,15 +367,6 @@ 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 runningOwnerRow = page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem', { name: /1 subagent running/ })
await runningOwnerRow.waitFor({ timeout: 10_000 })
expect(await runningOwnerRow.locator('[data-state="ongoing"]').count()).toBe(1)
await compareOrRefreshGolden(
RUNNING_SIDEBAR_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
MODE,
)
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)
+1
View File
@@ -66,6 +66,7 @@
"tests/produced-files.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/subagent-conversation.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",
+1
View File
@@ -53,6 +53,7 @@
"apps/web/tests/startup-auto-selection.e2e.ts",
"apps/web/tests/produced-files.e2e.ts",
"apps/web/tests/subagent-conversation.e2e.ts",
"apps/web/tests/sidebar-subagent-activity.e2e.ts",
"apps/web/tests/bash-abort-row.e2e.ts",
"apps/web/tests/skill-tool-row.e2e.ts",
"apps/web/tests/turn-tail-actions.e2e.ts",