2026-08-19 17:55:06 +08:00
|
|
|
// Session-header background jobs driven by a real `ctx.jobs` entry. No model
|
|
|
|
|
// call is involved.
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
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'
|
2026-08-19 13:03:32 +08:00
|
|
|
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
2026-08-13 00:36:22 +08:00
|
|
|
import { JobId } from '@deepseek-ai/dsh-jobs'
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
import {
|
|
|
|
|
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
|
|
|
|
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
|
|
|
|
} from './scaffold.ts'
|
|
|
|
|
import { newEnglishPage, saveFailureShot } from './support.ts'
|
|
|
|
|
|
2026-08-24 03:46:58 +08:00
|
|
|
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
|
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/background-job-list', import.meta.url))
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
|
|
|
|
|
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
|
|
|
|
const MODE = webSnapshotMode()
|
2026-08-13 00:36:22 +08:00
|
|
|
const SEED_ID = 'background-job-list-web-e2e'
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
// 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'
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-19 17:55:06 +08:00
|
|
|
* Wait for opening a session to publish its live Agent.
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
* @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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 00:36:22 +08:00
|
|
|
describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
let scaffold: WebScaffold
|
|
|
|
|
let browser: Browser
|
|
|
|
|
let page: Page
|
|
|
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
|
let agent: Agent
|
2026-08-13 00:36:22 +08:00
|
|
|
let jobId: JobId
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-08-25 02:07:56 +08:00
|
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
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
|
2026-08-13 00:36:22 +08:00
|
|
|
// job owner must be that exact live instance, never a second one.
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
// `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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-13 00:36:22 +08:00
|
|
|
it('shows a running background job in the session header without a refresh', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
|
2026-08-19 17:55:06 +08:00
|
|
|
// Polling for zero would pass at t=0 before delivery and prove nothing.
|
2026-08-13 00:36:22 +08:00
|
|
|
const trigger = page.getByRole('button', { name: '1 background job running' })
|
fix(tasks): correct the change-feed contract and its documentation
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.
2026-08-10 13:37:18 +08:00
|
|
|
expect(await trigger.count()).toBe(0)
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
|
|
|
|
|
const started = await scaffold.ctx.tools.execute({
|
|
|
|
|
signal: new AbortController().signal,
|
2026-08-19 13:03:32 +08:00
|
|
|
callId: ToolCallId('background-job-list-e2e'),
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
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)
|
2026-08-13 00:36:22 +08:00
|
|
|
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
|
|
|
|
|
jobId = JobId(matched[0])
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
|
|
|
|
|
await trigger.waitFor({ timeout: 15_000 })
|
|
|
|
|
await trigger.click()
|
2026-08-13 00:36:22 +08:00
|
|
|
const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first()
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
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 () => {
|
2026-08-13 00:36:22 +08:00
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
|
|
|
|
|
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
|
2026-08-31 22:30:51 +08:00
|
|
|
const idle = page.getByRole('button', { name: '1 background job', exact: true })
|
feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.
Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.
Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00
|
|
|
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'])
|
|
|
|
|
})
|
|
|
|
|
})
|