From 81d32bf52cff95d219c8987a01cccfa76225d1e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 15:46:08 +0800 Subject: [PATCH] test(web): isolate sidebar subagent activity snapshot --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 2 +- ...026-07-27-web-subagent-conversations.zh.md | 2 +- .../tests/sidebar-subagent-activity.e2e.ts | 152 ++++++++++++++++++ .../owner-running.expected.md | 6 + .../sidebar-running.expected.md | 5 - apps/web/tests/subagent-conversation.e2e.ts | 10 -- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 9 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 apps/web/tests/sidebar-subagent-activity.e2e.ts create mode 100644 apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md delete mode 100644 apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index f93d3a4a34..05b99f0d68 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 1b9bbfbc56..bcfaf5d958 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index a9ed0e20f3..6b7299b408 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -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。 ## 后果 diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts new file mode 100644 index 0000000000..b7d24b6a09 --- /dev/null +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -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 { + 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((_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 { + 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(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 + + 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([]) + }) +}) diff --git a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md new file mode 100644 index 0000000000..c5a2766750 --- /dev/null +++ b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md b/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md deleted file mode 100644 index fec6fe658e..0000000000 --- a/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md +++ /dev/null @@ -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] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 69250ed6b6..0049cb2791 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -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) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 781cb57a2e..354781e934 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/tsconfig.host.json b/tsconfig.host.json index 1467555ed1..f3a3493b3a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",