Review found the `onTasksChanged` teardown reasoning inverted. The comment claimed every registration is an effect on the registry's own fiber, so listeners would be gone before service disposal empties the store — but the traceable proxy rebinds `this.ctx` to the CALLER, which this package's own HMR-safety test already proves. The only shipped consumer registers from the api-proxy mux stream, so it was still listening and simply kept the rows it last received. Service disposal now announces the emptied set, and teardown announces its stopping transition immediately instead of leaving an observer on `running` for however long a slow producer takes to release. Two documentation claims were false in the opposite direction: the Agent Note and the ui-task README both said an unowned task is invisible in the header, while `list(caller)` returns unowned tasks to every caller, the carrier fans their changes out to every subscribed session, and this PR's own test asserts exactly that. The note even contradicted itself two sections earlier. Both sides now state the real asymmetries — another session's tasks, and the process-local registry emptying on restart. The "no Web path calls the consuming `ctx.tasks.read()`" invariant claimed a test that did not exist; the carrier suite's producer had no `readOutput` at all, so a stray read would have failed nothing. Its producer now counts cursor consumption and the lifecycle and baseline paths both assert zero. Also: a session created after the mux opened now receives the task baseline it missed, the popover samples its clock when it opens rather than at mount, and a failed task's unbounded producer detail elides instead of widening the row.
134 lines
6.1 KiB
TypeScript
134 lines
6.1 KiB
TypeScript
// Web e2e scenario: the session-header background-task list over the real
|
|
// host. No model call is involved — a genuine `run_in_background` bash call
|
|
// registers with `ctx.tasks`, and the assertion chain is the whole delivery
|
|
// path: registry change feed → api-proxy `session/tasks` frame → the client's
|
|
// `tasksBySession` mirror → the header action.
|
|
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
|
import {
|
|
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
|
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { newEnglishPage, saveFailureShot } from './support.ts'
|
|
|
|
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url))
|
|
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
|
|
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
|
const MODE = webSnapshotMode()
|
|
const SEED_ID = 'background-task-list-web-e2e'
|
|
// Long enough that the running assertions never race the process exiting on
|
|
// their own; the test kills it explicitly to reach the settled state.
|
|
const COMMAND = 'sleep 45'
|
|
|
|
/**
|
|
* Wait for the Host to publish the live Agent that opening a session resumes.
|
|
* @param scaffold - the booted web scaffold.
|
|
* @param sessionId - the opened session's identity.
|
|
* @returns the registered Agent instance.
|
|
*/
|
|
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
|
|
const deadline = Date.now() + 30_000
|
|
for (;;) {
|
|
const found = scaffold.ctx.agents.get(sessionId)
|
|
if (found !== undefined) return found
|
|
if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
}
|
|
}
|
|
|
|
describe.skipIf(MODE === 'record')('web e2e: background task list', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
let agent: Agent
|
|
let taskId: TaskId
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({})
|
|
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 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 })
|
|
|
|
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()
|
|
|
|
// Opening the session drives the Host's ordinary Agent resolution; the
|
|
// task owner must be that exact live instance, never a second one.
|
|
// `expect.poll` is test-scoped, so this hook polls by hand.
|
|
agent = await liveAgent(scaffold, SessionId(SEED_ID))
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
await scaffold?.close()
|
|
})
|
|
|
|
it('shows a running background task in the session header without a refresh', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running'))
|
|
// Point assertion, not a poll: `expect.poll` retries until a predicate
|
|
// holds, so polling for zero passes at t=0 and proves nothing. The
|
|
// "renders nothing without a task" branch is owned by the component suite.
|
|
const trigger = page.getByRole('button', { name: '1 background task running' })
|
|
expect(await trigger.count()).toBe(0)
|
|
|
|
const started = await scaffold.ctx.tools.execute({
|
|
signal: new AbortController().signal,
|
|
callId: CallId('background-task-list-e2e'),
|
|
name: 'bash',
|
|
arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true },
|
|
agent,
|
|
})
|
|
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
|
|
const matched = /\bbash-\d+\b/.exec(reported)
|
|
if (matched === null) throw new Error(`background bash reported no task id: ${reported}`)
|
|
taskId = TaskId(matched[0])
|
|
|
|
await trigger.waitFor({ timeout: 15_000 })
|
|
await trigger.click()
|
|
const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first()
|
|
await row.waitFor({ timeout: 10_000 })
|
|
await expect.poll(() => row.textContent()).toContain(COMMAND)
|
|
|
|
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled'))
|
|
expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested')
|
|
|
|
// The trigger drops its live count once the task leaves running/stopping,
|
|
// which is also the proof that settlement reached the browser unprompted.
|
|
const idle = page.getByRole('button', { name: '1 background task' })
|
|
await idle.waitFor({ timeout: 20_000 })
|
|
|
|
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('keeps its snapshot inventory closed', async () => {
|
|
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
|
|
})
|
|
})
|