feat(api): serve job-record observation from the session namespace

SessionJob rows carry outputTotal exactly for record-declaring jobs, and a
new session.observeJob stream serves one job's retained record from an
absolute offset (anchor, coalesced output frames, terminal status) with the
fenced read resolved from the request's session — the same authorization
shape as the jobs roster. The client half gains ctx.jobOutput: the
reference-counted per-job observation service and its accumulated view
model, ported from the activity controller this replaces.
This commit is contained in:
Yichen Jiang committed 2026-09-01 18:03:09 +08:00
1 parent 8537b8d5bb
commit 6e8a7a50ea
11 files changed
+1384

No files matched your search

@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import { createSessionControlStream } from './transport.ts'
import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
import type {} from '../remote-events.ts'
@@ -23,6 +24,8 @@ export type {
} from './transport.ts'
export { createScope, scopeOf } from './scope.ts'
export type { AgentContext, AgentScopeHandle } from './scope.ts'
export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts'
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type {
@@ -89,6 +92,7 @@ export const inject = [
export function apply(ctx: Context): void {
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, remotes)
new ClientJobOutput(ctx, remotes, new ClientJobOutputModel())
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
@@ -0,0 +1,348 @@
/**
* React-free client job-record observation: per-job accumulated output views
* over the `session.observeJob` stream, reference-counted per job so
* overlapping panels share one stream. The roster itself rides the session
* control stream (`jobsBySession`); this module carries only the output.
* @module @deepseek-ai/dsh-api-session-controller/client/job-output
*/
import { Service, type Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionJob, SessionObserveJobFrame } from '../types.ts'
import type { SessionRemote } from './transport.ts'
/** Bounded per-job render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
/** One observed job's live view state. */
export interface ObservedJob {
readonly jobId: JobId
/** Accumulated output tail, bounded to the render limit. */
readonly text: string
/** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
readonly gapBefore: boolean
/** Latest status delivered on the observation stream. */
readonly status: SessionJob['status']
readonly detail?: string
/** True while the observation stream is open and the job has not settled. */
readonly streaming: boolean
/** Terminal observation failure, when the stream ended abnormally. */
readonly error?: string
}
/** Immutable client job-output state. */
export interface JobOutputSnapshot {
/** Live observation state keyed by job id. */
readonly observed: Readonly<Record<string, ObservedJob>>
}
/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */
interface ObservedState {
view: ObservedJob
/** Resume offset for the next generation's `from`. */
cursor: number | undefined
}
/**
* Owns per-job observation state. Pure data plus subscriptions — transport
* wiring stays in the client service, UI stays in slot components.
*/
export class ClientJobOutputModel {
private readonly observedStates = new Map<string, ObservedState>()
private readonly listeners = new Set<() => void>()
private snapshotCache: JobOutputSnapshot = { observed: {} }
private snapshotDirty = false
/**
* Read the identity-stable current snapshot.
* @returns the cached snapshot, rebuilt only after a change.
*/
getSnapshot(): JobOutputSnapshot {
if (this.snapshotDirty) {
const observed: Record<string, ObservedJob> = {}
for (const [id, state] of this.observedStates) observed[id] = state.view
this.snapshotCache = { observed }
this.snapshotDirty = false
}
return this.snapshotCache
}
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* The resume offset for one job's next observation generation.
* @param id - observed job.
* @returns the last accepted `next`, or undefined for a fresh observation.
*/
cursorOf(id: JobId): number | undefined {
return this.observedStates.get(String(id))?.cursor
}
/**
* Install or reset observation state when a generation's anchor arrives.
* @param id - observed job.
* @param frame - the generation's `opened` anchor.
*/
observeOpened(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'opened' }>): void {
const existing = this.observedStates.get(String(id))
// A fresh view anchored past offset zero starts after an evicted head
// (fresh observations anchor at the registry's earliest retained byte), so
// it owes the same gap mark a live observer earned from lossy reads. A
// resume that already accumulated text keeps its recorded gap state.
const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
const view: ObservedJob = {
jobId: id,
text: existing?.view.text ?? '',
gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: true,
}
this.observedStates.set(String(id), { view, cursor: frame.from })
this.changed()
}
/**
* Append one output frame's chunks to the bounded render tail.
* @param id - observed job.
* @param frame - a coalesced `output` frame.
*/
observeOutput(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'output' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
let gapBefore = state.view.gapBefore || frame.lossy === true
|| frame.chunks.some(chunk => chunk.gapBefore === true)
if (text.length > RENDER_TAIL_LIMIT) {
let cut = text.length - RENDER_TAIL_LIMIT
// Never split a surrogate pair at the render bound.
const unit = text.charCodeAt(cut)
if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
text = text.slice(cut)
gapBefore = true
}
state.view = { ...state.view, text, gapBefore }
state.cursor = frame.next
this.changed()
}
/**
* Record the terminal status delivered at the end of the observation stream.
* @param id - observed job.
* @param frame - the terminal `status` frame.
*/
observeStatus(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'status' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
state.view = {
...state.view,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: false,
}
this.changed()
}
/**
* Record a terminal observation failure.
* @param id - observed job.
* @param error - the stream's terminal failure.
*/
observeFailed(id: JobId, error: unknown): void {
const state = this.observedStates.get(String(id))
if (state === undefined) return
state.view = { ...state.view, streaming: false, error: String(error) }
this.changed()
}
/**
* Drop observation state after the last observer stops.
* @param id - the no-longer-observed job.
*/
observeStopped(id: JobId): void {
if (!this.observedStates.delete(String(id))) return
this.changed()
}
private changed(): void {
this.snapshotDirty = true
notifySubscribers(this.listeners, 'job output')
}
}
/** Bare observable source for the client job-output snapshot. */
export interface JobOutputSource {
/** Read the identity-stable current snapshot. */
getSnapshot(): JobOutputSnapshot
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void
}
/** The job-output client service face. */
export interface IJobOutput {
/** Per-job observation state. */
readonly state: JobOutputSource
/**
* Start observing one job's live record; reference-counted, so two viewers
* of the same job share one stream.
* @param sessionId - owning session used for the fenced read; undefined for an unowned job.
* @param id - job to observe.
* @returns stop function releasing this observer's reference.
*/
observe(sessionId: SessionId | undefined, id: JobId): () => void
}
/** One reference-counted observation stream. */
interface ObservationEntry {
refs: number
stopped: boolean
dispose: () => Promise<void>
}
/** Remote face the observation runner drives. */
type JobObserveRemote = Pick<ClientRemote, '$stream'> & { readonly session: Pick<SessionRemote, 'observeJob'> }
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free client job-record observation control. */
jobOutput: IJobOutput
}
}
/** Owns the bare job-output snapshot and per-job observation streams. */
export class ClientJobOutput extends Service implements IJobOutput {
readonly state: JobOutputSource
private readonly entries = new Map<string, ObservationEntry>()
/**
* @param ctx - client root Context.
* @param remote - generated session namespace plus the Gateway stream factory.
* @param model - shared client job-output model.
*/
constructor(
ctx: Context,
private readonly remote: JobObserveRemote,
private readonly model: ClientJobOutputModel,
) {
super(ctx, 'jobOutput')
this.state = model
ctx.effect(() => () => {
const open = [...this.entries.values()]
this.entries.clear()
for (const entry of open) {
entry.stopped = true
void entry.dispose()
}
}, 'session-controller.client.observations')
}
observe(sessionId: SessionId | undefined, id: JobId): () => void {
const key = String(id)
const existing = this.entries.get(key)
if (existing !== undefined && !existing.stopped) {
existing.refs += 1
return this.releaser(key, existing)
}
const entry = this.startObservation(sessionId, id)
this.entries.set(key, entry)
return this.releaser(key, entry)
}
/**
* Release closures bind the exact entry they were minted for, never the
* map's current occupant: a later `observe()` on the same id may have
* replaced a stopped entry, and decrementing or disposing through the key
* alone would tear down that newer stream's references.
*/
private releaser(key: string, entry: ObservationEntry): () => void {
let released = false
return () => {
if (released) return
released = true
entry.refs -= 1
if (entry.refs > 0) return
if (this.entries.get(key) === entry) this.entries.delete(key)
entry.stopped = true
void entry.dispose().then(() => {
// Clear the view only while no successor observation holds the key:
// a re-expand inside the dispose round-trip already re-anchored the
// model, and a stale clear would blank its panel for good.
if (this.entries.has(key)) return
// The key is the stringified branded id this releaser was minted for.
this.model.observeStopped(key as JobId)
})
}
}
private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry {
const name = `job observation ${String(id)}`
const stream = this.remote.$stream<SessionObserveJobFrame>({
name,
open: (signal) => {
const from = this.model.cursorOf(id)
return this.remote.session.observeJob(
{
jobId: id,
...sessionId !== undefined ? { sessionId } : {},
...from !== undefined ? { from } : {},
},
signal,
)
},
// A premature end after the anchor is retryable (a Host reload closes the
// generation); resuming from the cursor loses nothing. An end before the
// anchor is terminal.
ended: accepted => accepted
? new RemoteStreamCarrierError(`${name} ended before settlement`)
: new Error(`${name} ended before its anchor`),
})
const entry: ObservationEntry = {
refs: 1,
stopped: false,
dispose: () => stream.dispose(),
}
void (async () => {
try {
for await (const item of stream) {
const frame = item.value
if (frame.type === 'opened') {
this.model.observeOpened(id, frame)
item.accept()
continue
}
if (frame.type === 'output') {
this.model.observeOutput(id, frame)
continue
}
// Terminal status: leave the loop before the generation end is
// classified, then close the stream for good.
this.model.observeStatus(id, frame)
break
}
} catch (error) {
if (!entry.stopped) this.model.observeFailed(id, error)
} finally {
entry.stopped = true
void entry.dispose()
}
})()
return entry
}
}
@@ -212,5 +212,6 @@ function jobView(job: JobSnapshot): SessionJob {
...(job.detail === undefined ? {} : { detail: job.detail }),
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }),
}
}
@@ -2,6 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-jobs'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -15,6 +16,7 @@ import {
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { observeJobRecord } from './observe-job.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import { buildModelCatalog } from './catalog.ts'
@@ -35,6 +37,8 @@ import type {
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionObserveJobFrame,
SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -63,12 +67,22 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Default coalescing window between job-observation reads, in milliseconds. */
const DEFAULT_OBSERVE_FLUSH_MS = 100
/** Default soft byte budget per job-observation output frame. */
const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size eligible for one full projection observation. */
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
/** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
/** Host integrations replaceable by direct unit tests. */
@@ -96,6 +110,8 @@ export class SessionController extends TypertRemoteService {
static Config: z<Config> = z.object({
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
nativeOpen: z.boolean(),
observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
})
private readonly agents: ApiSessionAgentController
@@ -106,6 +122,8 @@ export class SessionController extends TypertRemoteService {
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
private readonly promotions = new Set<Promise<void>>()
private readonly observeFlushMs: number
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context containing the Session capability assembly.
@@ -130,6 +148,8 @@ export class SessionController extends TypertRemoteService {
this.openPath = internals.openPath ?? openNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS
this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionSkillCatalog)
@@ -386,6 +406,30 @@ export class SessionController extends TypertRemoteService {
return this.controlState.control(signal)
}
/**
* Stream one job's retained record output from an absolute byte offset,
* then its terminal status once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session resolves the fenced-read caller; the registry rejects a
* job the session does not own, a job without a record, or an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' })
observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable<SessionObserveJobFrame> {
const registry = this.ctx.get('jobs')
if (registry === undefined) {
throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)')
}
const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId)
return observeJobRecord(registry, request, {
flushMs: this.observeFlushMs,
maxFrameBytes: this.observeMaxFrameBytes,
caller,
}, signal)
}
}
export { buildModelCatalog }
@@ -0,0 +1,189 @@
/** Per-job record observation generations: anchor, coalesced output, terminal status. */
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs'
import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts'
/** Cadence and framing bounds for one observation generation. */
export interface ObserveJobOptions {
/** Coalescing window after a wake before reading, in milliseconds. */
readonly flushMs: number
/**
* Soft byte budget per `output` frame. Accumulated chunks flush once the
* budget is met; one chunk larger than the budget ships whole (the
* registry's retention cap is the hard bound on any single chunk).
*/
readonly maxFrameBytes: number
/** The live owning agent for fenced reads, resolved from the request's session. */
readonly caller: Agent | undefined
}
function isTerminal(status: JobSnapshot['status']): boolean {
return status !== 'running' && status !== 'stopping'
}
/** A record job's total offset; the anchor check proved the declaration and it never leaves. */
function recordTotal(snapshot: JobSnapshot): number {
/* v8 ignore next -- a declared record never loses its offsets. */
return snapshot.outputTotal ?? 0
}
/** Wake-flag waiter: a wake between waits is never lost. */
class OutputWaiter {
private dirty = false
private resolve: (() => void) | undefined
wake(): void {
this.dirty = true
this.resolve?.()
}
/**
* Resolve on the next wake, immediately when one already arrived, or on abort.
* @param signal - generation cancellation.
*/
wait(signal: AbortSignal): Promise<void> {
if (this.dirty || signal.aborted) {
this.dirty = false
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = (): void => {
signal.removeEventListener('abort', finish)
/* v8 ignore next -- one wait owns the sole installed resolver. */
if (this.resolve === finish) this.resolve = undefined
this.dirty = false
resolve()
}
this.resolve = finish
signal.addEventListener('abort', finish, { once: true })
})
}
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => {
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
}
/**
* Stream one job's retained record output from an absolute offset: one
* `opened` anchor, coalesced `output` frames as the record advances, then one
* terminal `status` after the settled job is drained, after which the
* generation closes normally. Reads are non-consuming — the model-facing
* cursor and notice state never observe them; reconnecting callers resume by
* passing the last frame's `next` as `from`. Throws for a job that declared
* no record.
* @param registry - the live job registry.
* @param request - target job and optional resume offset.
* @param options - cadence, framing bounds, and the fenced-read caller.
* @param signal - generation cancellation owned by the Remote stream carrier.
* @returns the observation frame sequence for one generation.
*/
export async function* observeJobRecord(
registry: JobRegistry,
request: SessionObserveJobRequest,
options: ObserveJobOptions,
signal: AbortSignal,
): AsyncIterable<SessionObserveJobFrame> {
if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) {
throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`)
}
signal.throwIfAborted()
const id = JobId(String(request.jobId))
const waiter = new OutputWaiter()
// Subscribe before the first read so an append between the anchor read and
// the wait cannot be missed.
const unsubscribe = registry.onOutput((changed) => {
if (changed === id) waiter.wake()
})
try {
const caller = options.caller
let snapshot = registry.get(id, caller)
if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) {
throw new Error(`job ${id} declared no output record`)
}
let cursor = request.from ?? snapshot.outputEarliest
yield {
type: 'opened',
jobId: id,
from: cursor,
earliest: snapshot.outputEarliest,
total: snapshot.outputTotal,
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
}
while (!signal.aborted) {
const read = registry.readRecord(id, cursor, caller)
if (read.chunks.length > 0) {
yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes)
}
cursor = read.next
snapshot = registry.get(id, caller)
if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) {
yield {
type: 'status',
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
// Settlement always stamps finishedAt; the guard only discharges the optional field type.
/* v8 ignore next */
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
}
return
}
await waiter.wait(signal)
// Let a burst accumulate so producer chatter becomes bounded frames.
await sleep(options.flushMs, signal)
}
} finally {
unsubscribe()
}
}
/** Split one read into frames along the soft per-frame byte budget. */
function* outputFrames(
chunks: readonly SessionJobWireChunk[],
next: number,
lossy: boolean,
maxFrameBytes: number,
): Iterable<SessionObserveJobFrame> {
let batch: SessionJobWireChunk[] = []
let batchBytes = 0
let flaggedLossy = lossy
for (const chunk of chunks) {
batch.push(chunk)
batchBytes += Buffer.byteLength(chunk.text, 'utf8')
if (batchBytes >= maxFrameBytes) {
const last = batch[batch.length - 1]
/* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */
const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8')
/* v8 ignore stop */
yield {
type: 'output',
chunks: batch,
next: end,
...flaggedLossy ? { lossy: true as const } : {},
}
flaggedLossy = false
batch = []
batchBytes = 0
}
}
if (batch.length > 0 || flaggedLossy) {
yield {
type: 'output',
chunks: batch,
next,
...flaggedLossy ? { lossy: true as const } : {},
}
}
}
@@ -469,6 +469,12 @@ export interface SessionJob {
readonly detail?: string
readonly startedAt: number
readonly finishedAt?: number
/**
* Total UTF-8 bytes the job's observation record holds so far. Present
* exactly when the job declared a record — the row is observable through
* `session.observeJob`.
*/
readonly outputTotal?: number
}
/** Complete live control baseline emitted once per control stream generation. */
@@ -493,6 +499,63 @@ export type SessionControlFrame =
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
/** Opens one observation stream over a job's retained record output. */
export interface SessionObserveJobRequest {
/** Owning session used for the fenced read; omitted for an unowned job. */
readonly sessionId?: SessionId
readonly jobId: JobId
/**
* Absolute byte offset to resume from — a previous frame's `next`. Omitted
* starts at the oldest retained byte.
*/
readonly from?: number
}
/** One retained record chunk on the wire. `channel` widens to string like `kind`. */
export interface SessionJobWireChunk {
/** Absolute offset of the chunk's first byte. */
readonly at: number
readonly text: string
readonly channel?: string
/** Bytes immediately before this chunk were lost at the producer or to retention. */
readonly gapBefore?: true
}
/**
* Job observation stream frames: one `opened` anchor, then coalesced `output`
* batches, then — once the job has settled and its retained record is
* drained — one terminal `status`, after which the stream closes normally.
* Status rides the same stream as output so settlement can never race a
* still-open output channel.
*/
export type SessionObserveJobFrame =
| {
readonly type: 'opened'
readonly jobId: JobId
/** Offset the first `output` frame continues from. */
readonly from: number
/** Oldest retained byte at open time; `from < earliest` means the head is gone. */
readonly earliest: number
/** Total bytes produced at open time. */
readonly total: number
readonly status: SessionJob['status']
readonly detail?: string
}
| {
readonly type: 'output'
readonly chunks: readonly SessionJobWireChunk[]
/** Offset to resume from after this frame. */
readonly next: number
/** Bytes between the requested offset and `chunks` were already evicted. */
readonly lossy?: true
}
| {
readonly type: 'status'
readonly status: SessionJob['status']
readonly detail?: string
readonly finishedAt?: number
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
@@ -162,6 +162,25 @@ describe('Session control jobs updates', () => {
])
})
it('carries outputTotal exactly for record-declaring jobs', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start({
kind: 'bash',
label: 'observable run',
owner: agent,
record: true,
run: (job) => {
job.append('hi')
return { cancel: () => {}, done: new Promise<never>(() => {}) }
},
})
const [frame] = await collected
expect(frame?.jobs[0]?.outputTotal).toBe(2)
})
it('fans an unowned change out to every attached session', async () => {
const { ctx, control } = await harness(true)
const second = ctx.sessions.create()
@@ -0,0 +1,413 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts'
import type { SessionObserveJobFrame } from '../src/types.ts'
const ID = 'bash-1' as JobId
function opened(model: ClientJobOutputModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void {
model.observeOpened(ID, {
type: 'opened',
jobId: ID,
from: 0,
earliest: 0,
total: 0,
status: 'running',
...over,
})
}
describe('ClientJobOutputModel observation', () => {
it('accumulates output, advances the resume cursor, and settles on status', () => {
const model = new ClientJobOutputModel()
opened(model)
expect(model.cursorOf(ID)).toBe(0)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 })
model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 })
expect(model.cursorOf(ID)).toBe(3)
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text).toBe('abc')
expect(view?.streaming).toBe(true)
model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' })
const settled = model.getSnapshot().observed[String(ID)]
expect(settled?.streaming).toBe(false)
expect(settled?.status).toBe('completed')
expect(settled?.detail).toBe('exit code: 0')
})
it('keeps snapshot identity stable between changes and notifies subscribers', () => {
const model = new ClientJobOutputModel()
let notified = 0
const unsubscribe = model.subscribe(() => { notified += 1 })
const before = model.getSnapshot()
expect(model.getSnapshot()).toBe(before)
opened(model)
expect(notified).toBe(1)
expect(model.getSnapshot()).not.toBe(before)
unsubscribe()
model.observeStopped(ID)
expect(notified).toBe(1)
})
it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => {
const model = new ClientJobOutputModel()
opened(model, { from: 4, earliest: 8, total: 10 })
expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
const clean = new ClientJobOutputModel()
opened(clean)
clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 })
expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
const lossy = new ClientJobOutputModel()
opened(lossy)
lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true })
expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
})
it('marks a fresh observation anchored past the evicted head', () => {
// Fresh observations anchor at the registry's earliest retained byte, so
// from === earliest > 0 means the head was already discarded.
const fresh = new ClientJobOutputModel()
opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 })
expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
// A retry that accumulated no text yet earns the mark the same way.
const retried = new ClientJobOutputModel()
opened(retried)
expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false)
opened(retried, { from: 6, earliest: 6, total: 6 })
expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
})
it('bounds the render tail without splitting a surrogate pair', () => {
const model = new ClientJobOutputModel()
opened(model)
const emoji = '😀'.repeat((64 * 1024) + 8)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.gapBefore).toBe(true)
expect(view!.text.length).toBeLessThanOrEqual(128 * 1024)
// The bound landed between pairs: the surviving text still round-trips.
expect(/^(?:😀)+$/u.test(view!.text)).toBe(true)
})
it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => {
const model = new ClientJobOutputModel()
model.observeOpened(ID, {
type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon',
})
expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon')
const long = 'x'.repeat((128 * 1024) + 5)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text.length).toBe(128 * 1024)
expect(view?.gapBefore).toBe(true)
})
it('advances the cut past a low surrogate landing exactly on the bound', () => {
const model = new ClientJobOutputModel()
opened(model)
// 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index.
const text = 'z😀' + 'a'.repeat((128 * 1024) - 1)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text.length).toBe((128 * 1024) - 1)
expect(view?.text.startsWith('a')).toBe(true)
})
it('preserves accumulated text across a reconnect anchor and clears on stop', () => {
const model = new ClientJobOutputModel()
opened(model)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 })
opened(model, { from: 4, earliest: 0, total: 4 })
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept')
model.observeStopped(ID)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
expect(model.cursorOf(ID)).toBeUndefined()
// A second stop is inert.
model.observeStopped(ID)
})
it('records a terminal stream failure on the live view', () => {
const model = new ClientJobOutputModel()
opened(model)
model.observeFailed(ID, new Error('carrier gone'))
const view = model.getSnapshot().observed[String(ID)]
expect(view?.streaming).toBe(false)
expect(view?.error).toContain('carrier gone')
// A failure for an untracked id is inert.
model.observeFailed('bash-9' as JobId, new Error('ignored'))
})
})
/** One scripted logical stream: frames are pushed by the test, never reopened. */
class FakeStream {
disposed = false
/** Model a carrier whose disposal surfaces as an iterator throw. */
throwOnDispose = false
/** Hold the dispose promise open until {@link releaseDispose}. */
deferDispose = false
private disposeRelease: (() => void) | undefined
private disposePending: Promise<void> | undefined
private readonly frames: SessionObserveJobFrame[] = []
private waiter: (() => void) | undefined
private failure: Error | undefined
readonly accepts: number[] = []
push(frame: SessionObserveJobFrame): void {
this.frames.push(frame)
this.waiter?.()
}
poison(error: Error): void {
this.failure = error
this.waiter?.()
}
dispose(): Promise<void> {
if (this.throwOnDispose && this.failure === undefined) {
this.failure = new Error('carrier tore down')
} else {
this.disposed = true
}
this.waiter?.()
if (this.deferDispose) {
// Repeat disposals (releaser plus the consumer's finally) share one
// deferred promise, so releaseDispose resumes every waiter.
this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve })
return this.disposePending
}
return Promise.resolve()
}
releaseDispose(): void {
this.disposeRelease?.()
}
async *[Symbol.asyncIterator]() {
let generation = 1
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,
value: frame,
signal: new AbortController().signal,
accept: () => { this.accepts.push(generation) },
}
generation = 1
}
}
}
function bench() {
const ctx = new Context()
const model = new ClientJobOutputModel()
const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = []
const observeCalls: unknown[] = []
const remote = {
$stream: (options: { open: (signal: AbortSignal) => unknown }) => {
const stream = new FakeStream()
streams.push({ options, stream })
return stream
},
session: {
observeJob: (request: unknown) => {
observeCalls.push(request)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
},
}
const output = new ClientJobOutput(ctx, remote as never, model)
return { ctx, model, output, streams, observeCalls }
}
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
describe('ClientJobOutput observation streams', () => {
it('feeds anchor, output, and terminal frames into the model, then closes', async () => {
const { model, output, streams } = bench()
output.observe(undefined, ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi')
expect(stream.accepts).toHaveLength(1)
stream.push({ type: 'status', status: 'completed' })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false)
expect(stream.disposed).toBe(true)
})
it('shares one stream across observers and stops after the last release', async () => {
const { model, output, streams } = bench()
const first = output.observe(undefined, ID)
const second = output.observe(undefined, ID)
expect(streams).toHaveLength(1)
streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
first()
first()
expect(streams[0]!.stream.disposed).toBe(false)
second()
await tick()
expect(streams[0]!.stream.disposed).toBe(true)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
})
it('a late release of a superseded observation neither tears down nor clears its successor', async () => {
const { model, output, streams } = bench()
const firstStop = output.observe(undefined, ID)
const first = streams[0]!.stream
first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
first.push({ type: 'status', status: 'completed' })
await tick()
// A successor observation replaces the settled entry while the first
// observer still holds its releaser.
const secondStop = output.observe(undefined, ID)
const second = streams[1]!.stream
second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
firstStop()
await tick()
expect(second.disposed).toBe(false)
expect(model.getSnapshot().observed[String(ID)]).toBeDefined()
secondStop()
await tick()
expect(second.disposed).toBe(true)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
})
it('a re-observation inside the dispose round-trip keeps its fresh view', async () => {
const { model, output, streams } = bench()
const stop = output.observe(undefined, ID)
const first = streams[0]!.stream
first.deferDispose = true
first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
stop()
// Re-expand while the previous generation's dispose is still in flight.
output.observe(undefined, ID)
const second = streams[1]!.stream
second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
first.releaseDispose()
await tick()
// The stale post-dispose clear must not blank the successor's view.
expect(model.getSnapshot().observed[String(ID)]).toBeDefined()
second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive')
})
it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => {
const { output, streams, observeCalls, model } = bench()
const stop = output.observe('alice' as SessionId, ID)
streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 })
await tick()
// The generation opener reads the live cursor at open time and carries the
// fenced-read session.
streams[0]!.options.open(new AbortController().signal)
expect(observeCalls.at(-1)).toEqual({ jobId: ID, sessionId: 'alice', from: 3 })
stop()
await tick()
output.observe(undefined, ID)
expect(streams).toHaveLength(2)
streams[1]!.options.open(new AbortController().signal)
// Stopped observation dropped the cursor: the fresh stream starts unanchored.
expect(observeCalls.at(-1)).toEqual({ jobId: ID })
expect(model.cursorOf(ID)).toBeUndefined()
})
it('classifies a premature end as retryable only after the anchor', () => {
const { output, streams } = bench()
output.observe(undefined, ID)
const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error }
expect(options.ended(true).name).toBe('RemoteStreamCarrierError')
expect(options.ended(false).name).toBe('Error')
})
it('records a consumer failure on the live view', async () => {
const { model, output, streams } = bench()
output.observe(undefined, ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
stream.poison(new Error('frame decode broke'))
await tick()
const view = model.getSnapshot().observed[String(ID)]
expect(view?.error).toContain('frame decode broke')
expect(view?.streaming).toBe(false)
})
it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => {
const { ctx, model, output, streams } = bench()
const stop = output.observe(undefined, ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
// Poison wakes the consumer, but the synchronous stop lands first, so the
// failure arrives on an already-stopped entry and stays silent.
stream.poison(new Error('after stop'))
stop()
await tick()
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
const second = output.observe(undefined, ID)
await ctx.fiber.dispose()
// The disposal path already removed every entry; a live releaser whose
// entry is gone is inert.
second()
})
it('stays silent when the carrier teardown itself throws after a stop', async () => {
const { model, output, streams } = bench()
const stop = output.observe(undefined, ID)
const { stream } = streams[0]!
stream.throwOnDispose = true
stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
// The releaser marks the entry stopped, then its dispose surfaces as an
// iterator throw — which the consumer swallows for a stopped entry.
stop()
await tick()
expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined()
})
it('records a terminal stream failure and service disposal closes live streams', async () => {
const { ctx, model, output, streams } = bench()
output.observe(undefined, ID)
const failing = streams[0]!.stream
failing.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
const iterator = failing[Symbol.asyncIterator]()
void iterator
// Simulate a terminal consumer failure by disposing the underlying stream:
// the consumer loop ends without a status frame and cleans up.
const spy = vi.spyOn(model, 'observeFailed')
await failing.dispose()
await tick()
const other = 'bash-2' as JobId
output.observe(undefined, other)
await ctx.fiber.dispose()
expect(streams.every(entry => entry.stream.disposed)).toBe(true)
expect(spy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,298 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { observeJobRecord } from '../src/observe-job.ts'
import type { ObserveJobOptions } from '../src/observe-job.ts'
import type { SessionObserveJobFrame } from '../src/types.ts'
import { createSessionTestController } from './test-remote.ts'
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 })
ctx.jobs.attachController('observe-job-test')
return ctx
}
function registerAgent(ctx: Context, rawId: string): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(rawId)
const session = Session.create(id)
const agent = {
id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scopeFiber.ctx,
} as unknown as Agent
ctx.agents.register(agent)
return agent
}
/** Start one record job whose done settles on demand; expose the producer face. */
function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) {
let settle!: (outcome: JobOutcome) => void
let face!: RunningJob
const id = ctx.jobs.start({
kind: 'bash',
label: options.label ?? 'echo',
...options.owner !== undefined ? { owner: options.owner } : {},
...options.record === false ? {} : { record: true },
run: (job) => {
face = job
return {
cancel() {},
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
}
},
})
return {
id,
append: (text: string, opts?: Parameters<RunningJob['append']>[1]) => { face.append(text, opts) },
settle: async (outcome: JobOutcome) => {
settle(outcome)
await new Promise(resolve => setTimeout(resolve, 0))
},
}
}
/** Read frames until `count` arrive or the generator finishes, then abort. */
async function collect<Frame>(
iterable: AsyncIterable<Frame>,
count: number,
abort: AbortController,
): Promise<Frame[]> {
const frames: Frame[] = []
const iterator = iterable[Symbol.asyncIterator]()
try {
while (frames.length < count) {
const step = await iterator.next()
if (step.done) return frames
frames.push(step.value)
}
} finally {
abort.abort()
await iterator.next().catch(() => undefined)
}
return frames
}
const OBSERVE: Omit<ObserveJobOptions, 'caller'> = { flushMs: 5, maxFrameBytes: 1024 }
describe('observeJobRecord', () => {
function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) {
const abort = new AbortController()
const frames: SessionObserveJobFrame[] = []
const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal)
const done = (async () => {
for await (const frame of stream) frames.push(frame)
})()
return { abort, frames, done }
}
it('anchors, streams coalesced output, and closes with the terminal status', async () => {
const ctx = await harness()
const job = startRecordJob(ctx)
job.append('early ')
const { frames, done } = observed(ctx, { jobId: job.id })
await new Promise(resolve => setTimeout(resolve, 20))
job.append('live', { channel: 'stderr' })
await new Promise(resolve => setTimeout(resolve, 20))
await job.settle({ status: 'completed', detail: 'exit code: 0' })
await done
expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' })
const outputs = frames.filter(frame => frame.type === 'output')
const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('')
expect(text).toBe('early live')
expect(outputs.at(-1)?.next).toBe(10)
expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr')
expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' })
})
it('resumes from an explicit offset and flags an evicted resume lossy', async () => {
const ctx = await harness()
const job = startRecordJob(ctx, { label: 'spam' })
job.append('a'.repeat(60))
job.append('b'.repeat(60))
await job.settle({ status: 'completed' })
const fresh = observed(ctx, { jobId: job.id, from: 0 })
await fresh.done
const output = fresh.frames.find(frame => frame.type === 'output')
expect(output?.lossy).toBe(true)
expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60))
const resumed = observed(ctx, { jobId: job.id, from: 120 })
await resumed.done
expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false)
expect(resumed.frames.at(-1)?.type).toBe('status')
})
it('splits oversized reads along the frame budget', async () => {
const ctx = await harness()
const job = startRecordJob(ctx, { label: 'wide' })
job.append('x'.repeat(30))
job.append('y'.repeat(30))
await job.settle({ status: 'completed' })
const abort = new AbortController()
const frames = await collect<SessionObserveJobFrame>(
observeJobRecord(ctx.jobs, { jobId: job.id }, {
flushMs: 5,
maxFrameBytes: 30,
caller: undefined,
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs).toHaveLength(2)
expect(outputs[0]?.next).toBe(30)
expect(outputs[1]?.next).toBe(60)
})
it('carries the lossy flag on the first frame of a split evicted read', async () => {
const ctx = await harness()
const job = startRecordJob(ctx, { label: 'evicted split' })
job.append('a'.repeat(40))
job.append('b'.repeat(40))
job.append('c'.repeat(40))
await job.settle({ status: 'completed' })
const abort = new AbortController()
const frames = await collect<SessionObserveJobFrame>(
observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, {
flushMs: 5,
maxFrameBytes: 40,
caller: undefined,
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs[0]?.lossy).toBe(true)
expect(outputs[1]?.lossy).toBeUndefined()
})
it('reads owned records through the resolved caller and rejects foreign, bad, and unknown input', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const job = startRecordJob(ctx, { label: 'secret', owner })
job.append('classified')
await job.settle({ status: 'completed' })
const { frames, done } = observed(ctx, { jobId: job.id }, owner)
await done
expect(frames.some(frame => frame.type === 'output'
&& frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true)
const foreign = new AbortController()
await expect(collect(
observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, foreign.signal),
1,
foreign,
)).rejects.toThrow(/belongs to another session/)
const abort = new AbortController()
await expect(collect(
observeJobRecord(ctx.jobs, { jobId: job.id, from: -1 }, { ...OBSERVE, caller: owner }, abort.signal),
1,
abort,
)).rejects.toThrow(/invalid observe offset/)
const abortUnknown = new AbortController()
await expect(collect(
observeJobRecord(ctx.jobs, { jobId: JobId('bash-999') }, { ...OBSERVE, caller: undefined }, abortUnknown.signal),
1,
abortUnknown,
)).rejects.toThrow(/unknown job/)
})
it('refuses a job that declared no record', async () => {
const ctx = await harness()
const job = startRecordJob(ctx, { record: false })
const abort = new AbortController()
await expect(collect(
observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal),
1,
abort,
)).rejects.toThrow(/declared no output record/)
await job.settle({ status: 'completed' })
})
it('ignores output signals for other jobs while waiting', async () => {
const ctx = await harness()
const watched = startRecordJob(ctx, { label: 'watched' })
const noisy = startRecordJob(ctx, { label: 'noisy' })
const { abort, frames, done } = observed(ctx, { jobId: watched.id })
await new Promise(resolve => setTimeout(resolve, 15))
noisy.append('unrelated')
await new Promise(resolve => setTimeout(resolve, 15))
await watched.settle({ status: 'completed' })
await done
abort.abort()
expect(frames.filter(frame => frame.type === 'output')).toEqual([])
await noisy.settle({ status: 'completed' })
})
it('stops cleanly on abort while waiting for output', async () => {
const ctx = await harness()
const job = startRecordJob(ctx, { label: 'idle' })
const { abort, frames, done } = observed(ctx, { jobId: job.id })
await new Promise(resolve => setTimeout(resolve, 20))
abort.abort()
await done
expect(frames[0]?.type).toBe('opened')
expect(frames.at(-1)?.type).not.toBe('status')
await job.settle({ status: 'completed' })
})
})
describe('SessionController.observeJob', () => {
const defaults = {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
}
it('refuses observation without a job registry', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const controller = createSessionTestController(ctx, defaults)
expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal))
.toThrow(/job observation unavailable/)
})
it('resolves the fenced-read caller from the request session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('observe-job-test')
const controller = createSessionTestController(ctx, defaults)
const session = ctx.sessions.create(SessionId('observing-session'))
const owner = {
id: session.id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
} as unknown as Agent
ctx.agents.register(owner)
const job = startRecordJob(ctx, { label: 'owned run', owner })
job.append('hi')
await job.settle({ status: 'completed' })
const abort = new AbortController()
const frames = await collect<SessionObserveJobFrame>(
controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal),
3,
abort,
)
expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status'])
})
})
@@ -34,6 +34,8 @@ import type {
SessionFollowRequest,
SessionListRequest,
SessionListValue,
SessionObserveJobFrame,
SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -71,6 +73,7 @@ export interface TestSessionRemote {
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable<SessionObserveJobFrame>
}
/** Dependencies and policy supplied by a Session Controller unit harness. */
@@ -273,5 +276,6 @@ export function createSessionTestRemote(
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal),
}
}
@@ -18,6 +18,7 @@
"src/history.ts",
"src/list.ts",
"src/model-selection-projection.ts",
"src/observe-job.ts",
"src/skill-catalog.ts"
],
"references": [