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()`.
This commit is contained in:
@@ -760,3 +760,96 @@ describe('LocalTaskService disposal', () => {
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTaskService.onTasksChanged', () => {
|
||||
it('fires after registration, the stopping transition, and settlement', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'alice')
|
||||
ctx.agents.register(owner)
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
const p = producer({ owner })
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
// Registration is announced only once the record is readable.
|
||||
expect(seen).toEqual(['alice'])
|
||||
expect(ctx.tasks.list(owner)).toHaveLength(1)
|
||||
|
||||
expect(ctx.tasks.kill(id, owner)).toBe('requested')
|
||||
expect(seen).toEqual(['alice', 'alice'])
|
||||
expect(ctx.tasks.get(id, owner).status).toBe('stopping')
|
||||
|
||||
p.settle({ status: 'killed' })
|
||||
await tick()
|
||||
expect(seen).toEqual(['alice', 'alice', 'alice'])
|
||||
expect(ctx.tasks.get(id, owner).status).toBe('killed')
|
||||
await disposeAgentScope(owner)
|
||||
})
|
||||
|
||||
it('reports an unowned change as undefined, since every caller can see it', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('announces the owner-disposal removal, and stays silent when that owner had none', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'alice')
|
||||
const bystander = stubAgent(ctx, 'bob')
|
||||
ctx.agents.register(owner)
|
||||
ctx.agents.register(bystander)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(seen).toEqual(['alice'])
|
||||
|
||||
// Disposing an owner with no records changes no visible set.
|
||||
await disposeAgentScope(bystander)
|
||||
expect(seen).toEqual(['alice'])
|
||||
|
||||
await disposeAgentScope(owner)
|
||||
expect(seen).toEqual(['alice', 'alice'])
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a throwing listener so the lifecycle commit still stands', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') })
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
const id = ctx.tasks.start(producer().spec)
|
||||
expect(id).toBe('bash-1')
|
||||
expect(seen).toEqual([undefined])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw'))
|
||||
})
|
||||
|
||||
it('unregisters through its disposer and with its fiber (HMR safety)', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: number[] = []
|
||||
const detach = ctx.tasks.onTasksChanged(() => void seen.push(1))
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tasks.onTasksChanged(() => void seen.push(2))
|
||||
}, { inject: ['tasks'] }))
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2])
|
||||
|
||||
detach()
|
||||
detach() // second call of the same disposer is a no-op
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2, 2])
|
||||
|
||||
await fiber.dispose()
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2, 2])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user