Add the activity capability seam: ctx.activities is a pure observation
plane where long-running work (background bash/pwsh, workflow runs) opens a
handle, appends transient output into a byte-offset ring buffer, and settles
exactly once. Nothing model-visible changes: reads are non-consuming and
lossy-by-design (absolute UTF-8 offsets survive eviction and report gaps),
correlation carries {callId, jobId}, and visibility is owner-fenced like jobs.
The seam is strictly optional. Producers tap it through ctx.get('activities')
and contain every observation failure, so an unregistered or broken registry
never affects the underlying job (proven by broken-registry tests).
Ship the full path in one change:
- @deepseek-ai/dsh-activity: Service Definition, pumpActivityOutput pull
substrate, invariant companion; dsh-activity-local: ring-buffer provider
with retention config and owner cleanup.
- shell: ShellProcess.observed exposes non-consuming stdout/stderr readers;
tool-bash/tool-pwsh mirror background jobs into activities.
- tool-workflow: phase/log/agent lines mirrored as a workflow activity.
- api-activity-controller: Typert Remote control (roster baseline+buckets)
and observe (opened/output/status frames, coalesced and byte-capped)
streams plus the client feed service with reference-counted observation.
- ui-activity: session-header action rendering live output through
TerminalBlock, which now shows output while a command is still running.
Docs: activity subsystem page, architecture row, regenerated catalogs, and
the Agent Note (2026-08-24-activity-observation-seam), all bilingual. A
keyless web e2e replays a real run_in_background stream to ARIA goldens.
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
|
|
import { apply, createActivityRosterStream } from '../src/client/index.ts'
|
|
import { ClientActivityModel } from '../src/client/model.ts'
|
|
import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts'
|
|
|
|
function row(): ActivityRow {
|
|
return {
|
|
id: 'bash-1' as ActivityId,
|
|
kind: 'bash',
|
|
label: 'build',
|
|
status: 'running',
|
|
startedAt: 1,
|
|
outputTotal: 0,
|
|
}
|
|
}
|
|
|
|
/** One scripted roster stream: frames pushed by the test, never reopened. */
|
|
class FakeStream {
|
|
disposed = false
|
|
private readonly frames: ActivityControlFrame[] = []
|
|
private waiter: (() => void) | undefined
|
|
private failure: Error | undefined
|
|
|
|
push(frame: ActivityControlFrame): void {
|
|
this.frames.push(frame)
|
|
this.waiter?.()
|
|
}
|
|
|
|
poison(error: Error): void {
|
|
this.failure = error
|
|
this.waiter?.()
|
|
}
|
|
|
|
dispose(): Promise<void> {
|
|
this.disposed = true
|
|
this.waiter?.()
|
|
return Promise.resolve()
|
|
}
|
|
|
|
async *[Symbol.asyncIterator]() {
|
|
while (!this.disposed) {
|
|
if (this.failure !== undefined) throw this.failure
|
|
const frame = this.frames.shift()
|
|
if (frame === undefined) {
|
|
await new Promise<void>((resolve) => { this.waiter = resolve })
|
|
continue
|
|
}
|
|
yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} }
|
|
}
|
|
}
|
|
}
|
|
|
|
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
|
|
describe('activity-controller client plugin', () => {
|
|
it('installs ctx.activityFeed and folds roster frames from the control stream', async () => {
|
|
const ctx = new Context()
|
|
const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = []
|
|
const controlCalls: AbortSignal[] = []
|
|
ctx.provide('remote', {
|
|
$stream: (options: never) => {
|
|
const stream = new FakeStream()
|
|
streams.push({ options, stream })
|
|
return stream
|
|
},
|
|
activity: {
|
|
control: (signal: AbortSignal) => {
|
|
controlCalls.push(signal)
|
|
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
|
|
},
|
|
observe: () => { throw new Error('not exercised') },
|
|
},
|
|
} as never)
|
|
const fiber = ctx.plugin({ inject: ['remote'], apply })
|
|
await fiber
|
|
|
|
streams[0]!.stream.push({ type: 'baseline', activities: [row()] })
|
|
streams[0]!.stream.push({ type: 'rows', activities: [] })
|
|
await tick()
|
|
const snapshot = ctx.activityFeed.state.getSnapshot()
|
|
expect(snapshot.phase).toBe('ready')
|
|
expect(snapshot.rowsBySession['']).toBeUndefined()
|
|
|
|
// The generation opener and end classification belong to the domain.
|
|
streams[0]!.options.open(new AbortController().signal)
|
|
expect(controlCalls).toHaveLength(1)
|
|
expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError)
|
|
expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error)
|
|
|
|
// A terminal roster failure keeps the last snapshot in place.
|
|
streams[0]!.stream.poison(new Error('roster stream broke'))
|
|
await tick()
|
|
expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready')
|
|
|
|
await fiber.dispose()
|
|
expect(streams[0]!.stream.disposed).toBe(true)
|
|
})
|
|
|
|
it('keeps the last roster on a terminal stream failure', async () => {
|
|
const model = new ClientActivityModel()
|
|
const remote = {
|
|
$stream: () => new FakeStream(),
|
|
activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } },
|
|
}
|
|
const stream = createActivityRosterStream(remote as never, model)
|
|
model.replaceBaseline([row()])
|
|
stream.start()
|
|
await stream.dispose()
|
|
expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build')
|
|
})
|
|
})
|