Files
deepseek-harness/packages/api/activity-controller/tests/client-apply.client.spec.ts
T
Yichen Jiang 4a79310339 feat(activity): optional live-output observation seam with a web viewer
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.
2026-08-24 22:40:14 +08:00

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')
})
})