Files
deepseek-harness/packages/api/job-controller/tests/client-apply.client.spec.ts
T
Yichen Jiang 7ec142a325 refactor(jobs): move job observation into api-job-controller
Review asked for the observation wire and its client service to leave
session-controller: a job should reach the Host only through its own
Remote namespace, and the session plugin's apply had to materialize
{ $stream, session } for a service that is not session state.

@deepseek-ai/dsh-api-job-controller now owns the Host JobController
(job.observe over ctx.jobs, requiring the agent and job registries) and
the client ClientJobOutput (ctx.jobOutput) over ClientJobOutputModel,
resolving both Remote faces in its own apply. The remotes assembly
mounts the job namespace; ui-jobs injects the new package; the wire
types drop their Session prefix and spell the status union out so the
client face pulls no Host declaration merges into browser programs.
session-controller keeps the jobsBySession roster and loses observe-job,
job-output, and the observe cadence config.

ui-chat's slots and snapshot-builder import CommandNode and friends from
the declaring package: the typert analyzer's cross-package rule trips on
the snapshot re-export as soon as another client face joins the root
Client program (the new controller does), which is why this branch
carried the change before. JobPumpSource documents why it is an offset
snapshot reader rather than a Node.js Readable.
2026-09-02 22:53:36 +08:00

66 lines
2.5 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { afterEach, describe, expect, it } from 'vitest'
import * as JobClient from '../src/client/index.ts'
import type { JobObserveFrame } from '../src/types.ts'
const contexts = new Set<Context>()
afterEach(async () => {
await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() }))
contexts.clear()
})
/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */
async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> {
const ctx = new Context()
contexts.add(ctx)
const observeCalls: unknown[] = []
const connection: ConnectionHandle = {
isLoopback: true,
generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} },
state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) },
reconnect: () => {},
registerGenerationSource: () => () => {},
start: () => ({ stop: () => {} }),
}
const job = {
// The fake stream never yields; opening and releasing must leave no view behind.
observe: (request: unknown) => {
observeCalls.push(request)
return (async function* (): AsyncGenerator<JobObserveFrame> {})()
},
}
ctx.reflect.provide('remote', {
$stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
job,
})
ctx.reflect.provide('remote.job', job)
await ctx.plugin(JobClient)
return { ctx, observeCalls }
}
async function flush(): Promise<void> {
for (let index = 0; index < 12; index++) await Promise.resolve()
}
describe('Job Controller Client apply', () => {
it('declares the Remote services it binds', () => {
expect(JobClient.inject).toEqual(['remote', 'remote.job'])
})
it('installs ctx.jobOutput over the captured job namespace', async () => {
const { ctx, observeCalls } = await mount()
expect(ctx.jobOutput).toBeDefined()
const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never)
await flush()
expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }])
stop()
await flush()
expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({})
})
})