Merge remote-tracking branch 'origin/master' into dshw/pr-2250

# Conflicts:
#	packages/client/connection/tests/fake-api.client.ts
#	packages/client/runtime/tests/manager.client.spec.ts
#	packages/client/runtime/tests/workspaces-service.client.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
This commit is contained in:
_Kerman
2026-08-12 04:22:58 +08:00
523 changed files with 4024 additions and 2331 deletions

View File

@@ -6,10 +6,11 @@
import { Service } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
TypeRTClientRemote,
RemoteResult,
TypeRTCodec,
TypeRTDisposer,
TypeRTRemoteContribution,
@@ -328,7 +329,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
scoped: ScopedMethod | undefined,
callerCtx: Context,
values: readonly unknown[],
): Promise<unknown> {
): Promise<RemoteResult<unknown>> {
if (scoped !== undefined) {
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
const identity = binder?.identity(callerCtx)
@@ -359,9 +360,9 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
callerCtx: Context,
values: readonly unknown[],
boundIdentity?: BoundContextIdentity,
): Promise<unknown> {
): Promise<RemoteResult<unknown>> {
const endpoint = endpointOf(descriptor)
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
if (!token.active) return withdrawn(endpoint)
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
if (values.length !== expected && !hasCallerSignal) {
@@ -391,7 +392,8 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
let valueIndex = 0
descriptor.parameters.forEach((parameter, parameterIndex) => {
if (parameterIndex === projection?.parameterIndex) return
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
if (value !== undefined) args[parameter.wire] = value
valueIndex += 1
})
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
@@ -400,10 +402,16 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
if (!result.ok) throw remoteFailure(endpoint, result.error)
return parse(descriptor.result, result.value, endpoint, 'result')
try {
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
if (!mountActive(token)) return withdrawn(endpoint)
if (!result.ok) return { ok: false, error: result.error }
return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
} catch (error) {
// Carrier throws (offline, abort, a rejected result payload) are outcomes
// of the call, not assembly faults, so they join the same error branch.
return carrierFailure(endpoint, error)
}
}
}
@@ -412,7 +420,7 @@ type InvokeRemote = (
scoped: ScopedMethod | undefined,
callerCtx: Context,
args: readonly unknown[],
) => Promise<unknown>
) => Promise<RemoteResult<unknown>>
class RemoteNamespaceService extends Service {
private readonly methods = new Map<string, RemoteMethodRecord>()
@@ -467,7 +475,7 @@ class RemoteNamespaceService extends Service {
Object.defineProperty(this, method, {
configurable: true,
enumerable: true,
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
const callerCtx = this.ctx
const current = this.methods.get(method)
const direct = current?.direct
@@ -566,6 +574,15 @@ function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: stri
}
}
function remoteFailure(endpoint: string, error: RpcError): Error {
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
/** The namespace retired before or during the call, so no request outcome exists. */
function withdrawn(endpoint: string): RemoteResult<never> {
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
}
function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
}
function internalFailure(message: string): RemoteResult<never> {
return { ok: false, error: { code: 'internal', message, details: {} } }
}

View File

@@ -70,6 +70,18 @@ export class TypertGatewayError extends Error {
}
}
/** Business invocation lost its carrier cancellation race. */
class RemoteInvocationCancelled extends Error {
/**
* @param endpoint - canonical Remote endpoint.
* @param cause - business rejection observed after carrier cancellation.
*/
constructor(endpoint: string, cause: unknown) {
super(`Remote invocation "${endpoint}" was aborted`, { cause })
this.name = 'RemoteInvocationCancelled'
}
}
/**
* Resolve strict generated definitions or conservative SRC markers against
* current Cordis Services and TypeRT providers.
@@ -157,7 +169,17 @@ export class TypertGatewayService extends Service implements TypertGateway {
)
}
const result = await Reflect.apply(method, receiver, args) as unknown
let result: unknown
try {
result = await Reflect.apply(method, receiver, args) as unknown
} catch (error) {
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
throw error
}
// A weak descriptor declares no return type, so nothing returned is a void
// result and rides the wire as an absent value field. A strict descriptor
// keeps its schema: there, undefined has to be a declared result.
if (result === undefined && descriptor.result.mode !== 'strict') return result
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
}
@@ -190,6 +212,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
args: payload.args,
signal,
})
// A void or explicitly absent business result carries no `value` field;
// JSON has no `undefined`, and the envelope's optional slot is the one
// representation of absence that both args and results already use.
return { ok: true, value }
} catch (error) {
return rpcFailure(error)
@@ -384,6 +409,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
args: Readonly<Record<string, unknown>>,
endpoint: string,
): Promise<unknown> {
// An absent field reached assertExactArguments' allowance, so this parameter
// takes undefined; a present-but-undefined field is not JSON-safe input and
// still fails decode. Lookup ids are never omissible, so absence here only
// ever belongs to a json parameter.
if (!Object.hasOwn(args, parameter.wire)) return undefined
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
if (parameter.source === 'json') return value
const key = parameter.lookup
@@ -439,6 +469,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
}
function rpcFailure(error: unknown): ConnectionRpcResult {
if (error instanceof RemoteInvocationCancelled) {
return {
ok: false,
error: { code: 'cancelled', message: error.message, details: {} },
}
}
if (error instanceof TypeRTLookupFailure) {
return { ok: false, error: error.failure as ConnectionRpcError }
}
@@ -559,7 +595,15 @@ function assertExactArguments(
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
const actual = Reflect.ownKeys(args)
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
// A JSON field may be omitted when the strict descriptor declares absence,
// and always under SRC: a weak descriptor reads parameter names from the
// JavaScript signature and cannot see which are optional, so LIB is where an
// omitted required argument is caught. Lookup ids are never omissible.
const acceptsMissing = new Set(descriptor.parameters
.filter(parameter => parameter.source === 'json'
&& (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
.map(parameter => parameter.wire))
const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key))
if (extra.length === 0 && missing.length === 0) return
const clauses: string[] = []
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
@@ -575,7 +619,10 @@ function decode(
field: string,
): unknown {
try {
if (codec.mode === 'strict') value = codec.schema.parse(value)
if (codec.mode === 'strict') {
value = codec.schema.parse(value)
if (value === undefined) return value
}
assertJsonValue(value, new Set())
return value
} catch (cause) {

View File

@@ -5,6 +5,7 @@ import { z } from 'zod'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
RemoteResult,
TypeRTClientRemote,
TypeRTContext,
TypeRTRemoteScopeApi,
@@ -42,23 +43,26 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
interface TypeRTRemoteMap {
'goals/create': (
'probe/create': (
agentId: string,
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
) => Promise<RemoteResult<{ readonly ref: string }>>
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
}
interface TypeRTRemoteScopeMap {
'fixture:goals/create': (
'fixture:probe/create': (
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
) => Promise<RemoteResult<{ readonly ref: string }>>
'fixture:probe/rename': (
request: { readonly objective: string },
) => Promise<RemoteResult<{ readonly renamed: boolean }>>
}
interface TypeRTRemoteNamespaceMap {
goals: TypeRTRemoteNamespace<'goals'>
probe: TypeRTRemoteNamespace<'probe'>
}
}
@@ -87,9 +91,9 @@ const renameResultSchema = z.object({ renamed: z.boolean() })
function directDescriptor(): InvocationDescriptor {
return {
id: '@fixture/goals#goals/create',
service: 'goals',
namespace: 'goals',
id: '@fixture/probe#probe/create',
service: 'probe',
namespace: 'probe',
method: 'create',
invocation: { kind: 'direct' },
scope: { context: 'fixture', wire: 'agentId' },
@@ -112,9 +116,9 @@ function directDescriptor(): InvocationDescriptor {
function contextDescriptor(): InvocationDescriptor {
return {
id: '@fixture/goals#goals/rename',
service: 'goals',
namespace: 'goals',
id: '@fixture/probe#probe/rename',
service: 'probe',
namespace: 'probe',
method: 'rename',
invocation: {
kind: 'context',
@@ -132,6 +136,25 @@ function contextDescriptor(): InvocationDescriptor {
}
}
function maybeDescriptor(): InvocationDescriptor {
const schema = z.union([z.string(), z.null(), z.undefined()])
return {
id: '@fixture/probe#probe/maybe',
service: 'probe',
namespace: 'probe',
method: 'maybe',
invocation: { kind: 'direct' },
parameters: [{
name: 'value',
wire: 'value',
source: 'json',
acceptsUndefined: true,
codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
}],
result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
}
}
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
const { ctx } = await benchFiber(call)
return ctx
@@ -153,28 +176,29 @@ describe('Client TypeRT API', () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const businessGoals = { owner: 'host business service' }
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
const businessProbe = { owner: 'host business service' }
const disposeBusinessProbe = ctx.provide('probe', businessProbe)
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
const retained = ctx.remote.goals.create
const retained = ctx.remote.probe.create
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }))
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
'probe/create',
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal),
)
const callerAbort = new AbortController()
await expect(ctx.remote.goals.create(
await expect(ctx.remote.probe.create(
'agent-1',
{ objective: 'cancel me' },
callerAbort.signal,
)).resolves.toEqual({ ref: 'goal-1' })
)).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
const combinedSignal = call.mock.calls.at(-1)?.[3]
expect(combinedSignal).toBeInstanceOf(AbortSignal)
expect(combinedSignal).not.toBe(callerAbort.signal)
@@ -182,18 +206,62 @@ describe('Client TypeRT API', () => {
callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: client api: probe/create rejected "result"',
details: {},
},
})
await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('goals')).toBe(businessGoals)
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
expect(ctx.get('probe')).toBe(businessProbe)
expect(ctx.typert.remotes.list()).toEqual([])
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
disposeBusinessGoals()
await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: Remote method probe/create is no longer mounted',
details: {},
},
})
disposeBusinessProbe()
})
it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValueOnce({ ok: true, value: undefined })
.mockResolvedValueOnce({ ok: true, value: null })
const ctx = await bench(call)
const dispose = await ctx.remote.$mount({
package: '@fixture/maybe',
descriptors: [maybeDescriptor()],
})
await expect(ctx.remote.probe.maybe(undefined)).resolves.toStrictEqual({ ok: true, value: undefined })
expect(call).toHaveBeenNthCalledWith(
1,
'/api',
'probe/maybe',
{ args: {} },
expect.any(AbortSignal),
)
await expect(ctx.remote.probe.maybe(null)).resolves.toStrictEqual({ ok: true, value: null })
expect(call).toHaveBeenNthCalledWith(
2,
'/api',
'probe/maybe',
{ args: { value: null } },
expect.any(AbortSignal),
)
await dispose()
})
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
@@ -205,24 +273,25 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' }))
.resolves.toEqual({ ok: true, value: { ref: 'goal-2' } })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
'probe/create',
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
await expect((ctx as FixtureContext).remote.probe.create({ objective: 'wrong scope' }))
.rejects.toThrow('expected 2 business argument(s)')
await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
})
it('uses the caller Context identity for scoped namespace methods', async () => {
@@ -234,23 +303,24 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
await expect(agentCtx.remote.probe.rename({ objective: 'land' }))
.resolves.toEqual({ ok: true, value: { renamed: true } })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/rename',
'probe/rename',
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'land' }))
.rejects.toThrow('requires a "fixture" Context')
await assembly.dispose()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
})
it('rejects weak descriptors and namespace collisions before registration', async () => {
@@ -282,32 +352,32 @@ describe('Client TypeRT API', () => {
await expect(ctx.remote.$mount({
package: '@fixture/direct-duplicates',
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
descriptors: [direct, { ...direct, id: '@fixture/probe#probe/create-again' }],
})).rejects.toThrow('repeats direct method')
await expect(ctx.remote.$mount({
package: '@fixture/scoped-duplicates',
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
descriptors: [context, { ...context, id: '@fixture/probe#probe/rename-again' }],
})).rejects.toThrow('repeats scoped method')
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
await expect(ctx.remote.$mount({
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
})).rejects.toThrow('direct method goals/create is already mounted')
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#probe/create' }],
})).rejects.toThrow('direct method probe/create is already mounted')
await disposeDirect()
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
await expect(ctx.remote.$mount({
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
})).rejects.toThrow('scoped method goals/rename is already mounted')
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#probe/rename' }],
})).rejects.toThrow('scoped method probe/rename is already mounted')
await expect(ctx.remote.$mount({
package: '@fixture/service-method-conflict',
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
descriptors: [{ ...context, id: '@fixture/probe#probe/remove', method: 'remove' }],
})).rejects.toThrow('conflicts with its namespace service')
const scopedService = ctx.get('remote.goals') as unknown as object
const scopedService = ctx.get('remote.probe') as unknown as object
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
await expect(ctx.remote.$mount({
package: '@fixture/service-own-property-conflict',
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
descriptors: [{ ...direct, id: '@fixture/probe#probe/custom', method: 'custom' }],
})).rejects.toThrow('conflicts with its namespace service')
Reflect.deleteProperty(scopedService, 'custom')
await disposeScoped()
@@ -323,10 +393,11 @@ describe('Client TypeRT API', () => {
package: '@fixture/multiple-scoped',
descriptors: [directDescriptor(), contextDescriptor()],
})
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
await expect(agentCtx.remote.probe.rename({ objective: 'remounted' }))
.resolves.toEqual({ ok: true, value: { renamed: true } })
expect(call).toHaveBeenLastCalledWith(
'/api',
'goals/rename',
'probe/rename',
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
expect.any(AbortSignal),
)
@@ -338,7 +409,7 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = {
...first,
id: '@fixture/goals#goals/archive',
id: '@fixture/probe#probe/archive',
method: 'archive',
}
const defineProperty = Object.defineProperty
@@ -353,11 +424,11 @@ describe('Client TypeRT API', () => {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
expect(ctx.remote.probe.create).toBeTypeOf('function')
expect((ctx.remote.probe as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
await retry()
})
@@ -367,7 +438,7 @@ describe('Client TypeRT API', () => {
package: '@fixture/context-anchor',
descriptors: [contextDescriptor()],
})
const namespace = ctx.get('remote.goals') as unknown as {
const namespace = ctx.get('remote.probe') as unknown as {
installScoped: (...args: unknown[]) => void
readonly create?: unknown
}
@@ -429,28 +500,28 @@ describe('Client TypeRT API', () => {
const ctx = await bench(call)
const descriptor = directDescriptor()
const dispose = await ctx.remote.$mount({
package: '@fixture/goals',
package: '@fixture/probe',
descriptors: [descriptor, contextDescriptor()],
})
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).remote.goals
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise<unknown>
const probe = (ctx as FixtureContext).remote.probe
const rename = probe.rename as unknown as (...args: unknown[]) => Promise<unknown>
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
.rejects.toThrow('got 4')
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
await expect(rename.call(probe)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
.rejects.toThrow('expected 2 business argument(s)')
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
ctx.set('connection', undefined)
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await dispose()
})
@@ -464,23 +535,30 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = {
...first,
id: '@fixture/goals#goals/archive',
id: '@fixture/probe#probe/archive',
method: 'archive',
}
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] })
const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' })
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
await dispose()
resolveCall({ ok: true, value: { ref: 'goal-1' } })
await expect(invocation).rejects.toThrow('withdrawn during invocation')
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
await expect(invocation).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: Remote method probe/create is no longer mounted',
details: {},
},
})
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
})
it('fails a method obtained from a withdrawn namespace getter', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
const namespace = ctx.get('remote.goals') as unknown as object
const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
const namespace = ctx.get('remote.probe') as unknown as object
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
await dispose()
@@ -498,7 +576,7 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...base } = directDescriptor()
const descriptor: InvocationDescriptor = {
...base,
id: '@fixture/goals#goals/prototype',
id: '@fixture/probe#probe/prototype',
method: 'prototype',
parameters: [{
name: 'value',
@@ -509,8 +587,8 @@ describe('Client TypeRT API', () => {
}
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
const method = (ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
expect(Object.getPrototypeOf(payload.args)).toBeNull()
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
@@ -526,15 +604,15 @@ describe('Client TypeRT API', () => {
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
await expect(ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }))
.rejects.toThrow('fixture namespace startup failure')
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
} finally {
spy.mockRestore()
}
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
const retry = await ctx.remote.$mount({ package: '@fixture/probe-retry', descriptors: [directDescriptor()] })
expect(ctx.remote.probe.create).toBeTypeOf('function')
await retry()
})
@@ -554,13 +632,13 @@ describe('Client TypeRT API', () => {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({
package: '@fixture/direct-method-retry',
descriptors: [directDescriptor()],
})
expect(ctx.remote.goals.create).toBeTypeOf('function')
expect(ctx.remote.probe.create).toBeTypeOf('function')
await retry()
})
@@ -578,42 +656,66 @@ describe('Client TypeRT API', () => {
spy.mockRestore()
}
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
expect((ctx.get('remote.probe') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
await retry()
})
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
expect(ctx.get('remote.goals')).toBeDefined()
expect(ctx.get('remote.probe')).toBeDefined()
await dispose()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
const replacement = { owner: 'replacement' }
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
expect(ctx.get('remote.goals')).toBe(replacement)
const disposeReplacement = ctx.reflect.provide('remote.probe', replacement)
expect(ctx.get('remote.probe')).toBe(replacement)
await disposeReplacement()
})
it('throws RPC failures with the structured error as its cause', async () => {
it('delivers an RPC failure in the error branch with the Host error verbatim', async () => {
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
let failure: unknown
try {
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(Error)
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
expect(failure.message).toContain('internal: host failed')
expect(failure.cause).toBe(rpcError)
const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' })
expect(outcome.ok).toBe(false)
if (outcome.ok) throw new Error('expected the Client API invocation to report a failure')
expect(outcome.error).toBe(rpcError)
})
it('folds a transport throw into the error branch', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
.mockRejectedValue(new Error('carrier offline')))
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: carrier offline',
details: {},
},
})
})
it('folds a carrier throw that is not an Error into the error branch', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
.mockRejectedValue('carrier exploded'))
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: carrier exploded',
details: {},
},
})
})
it('owns each $on subscription in the calling fiber', async () => {

View File

@@ -77,6 +77,12 @@ class GoalService extends Service {
return this.nextResult === undefined ? value : this.nextResult
}
@Remote
maybe(value: string | null | undefined): string | null | undefined {
this.calls.push('maybe')
return value
}
@Remote
fail(request: unknown): never {
void request
@@ -782,6 +788,19 @@ describe('TypertGatewayService', () => {
}), 'input-invalid')
})
it('admits an omitted SRC field and hands the Host method undefined', async () => {
const { ctx, service } = await setup()
// A weak descriptor reads parameter names from the JavaScript signature and
// cannot see which are optional, so an absent field is admitted; the case
// above keeps an explicitly undefined field rejected.
await expect(ctx.typertGateway.invoke({
namespace: 'goals',
method: 'passthrough',
args: {},
})).resolves.toBeUndefined()
expect(service.calls).toContain('passthrough')
})
it('rejects cyclic SRC input and non-JSON SRC results', async () => {
const { ctx, service } = await setup()
const cyclic: { self?: unknown } = {}
@@ -945,7 +964,7 @@ describe('TypertGatewayService', () => {
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
registerAgentLookup(ctx, { id: 'agent-1' })
registerStrict(ctx, [createDescriptor()])
registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
expect(connection.matches?.('goals/create')).toBe(true)
expect(connection.matches?.('goals/passthrough')).toBe(true)
expect(connection.matches?.('goals')).toBe(false)
@@ -973,6 +992,15 @@ describe('TypertGatewayService', () => {
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({
ok: true,
value: undefined,
})
await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({
ok: true,
value: null,
})
for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
const result = await handler(endpoint, { args: {} }, signal)
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -987,11 +1015,33 @@ describe('TypertGatewayService', () => {
}
service.businessError = 'non-error failure' as unknown as Error
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
await expect(handler(
'goals/fail',
{ args: { request: null } },
new AbortController().signal,
)).resolves.toEqual({
ok: false,
error: { code: 'internal', message: 'non-error failure', details: {} },
})
// A business rejection observed while the carrier signal is already aborted
// is the caller's cancellation, not an internal gateway fault.
const cancelledCall = new AbortController()
cancelledCall.abort(new Error('client disconnected'))
service.businessError = new Error('fixture business failure')
await expect(handler(
'goals/fail',
{ args: { request: null } },
cancelledCall.signal,
)).resolves.toEqual({
ok: false,
error: {
code: 'cancelled',
message: 'Remote invocation "goals/fail" was aborted',
details: {},
},
})
await gatewayFiber.dispose()
expect(connection.handler).toBeUndefined()
})
@@ -1302,6 +1352,28 @@ function strictOnlyDescriptor(): InvocationDescriptor {
}
}
function maybeDescriptor(): InvocationDescriptor {
const value = strictCodec(
'@fixture/gateway#MaybeValue',
z.union([z.string(), z.null(), z.undefined()]),
)
return {
id: '@fixture/gateway#goals/maybe',
service: 'goals',
namespace: 'goals',
method: 'maybe',
invocation: { kind: 'direct' },
parameters: [{
name: 'value',
wire: 'value',
source: 'json',
acceptsUndefined: true,
codec: value,
}],
result: value,
}
}
async function expectCode(
promise: Promise<unknown>,
code: TypertGatewayError['code'],

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/index.ts",
"src/invariant.ts",
"src/types.ts"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/connection/tsconfig.host.json"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -1,27 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"files": [],
"references": [
{
"path": "../../../vendor/cosmokit"
"path": "./tsconfig.host.json"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/connection"
},
{
"path": "../../typert/type-meta"
"path": "./tsconfig.client.json"
}
]
}

View File

@@ -1,10 +1,12 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
@@ -17,12 +19,22 @@ export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-llm/types'
export type {} from '@deepseek-ai/dsh-agent-presets/types'
export type {} from '@deepseek-ai/dsh-settings/types'
/**
* The Gateway Client face's own declaration merges, type-only: `ctx.remote` and
* with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still
* carries no runtime edge to the Gateway implementation.
* The carrier's Client-facing types, re-exported so a business package names one
* assembly package instead of both this facade and the Connection plugin. Type-only:
* the carrier's runtime values stay behind their own module edge.
*/
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {
ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient,
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem,
SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -40,5 +52,16 @@ export const inject = ['remote']
* @returns disposer after every selected Remote namespace is ready.
*/
export async function apply(ctx: Context): Promise<() => Promise<void>> {
return await ctx.remote.$mount(goalsRemote)
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {
for (const dispose of disposers.reverse()) await dispose()
throw error
}
return async () => {
for (const dispose of disposers.reverse()) await dispose()
}
}

View File

@@ -147,19 +147,21 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
} catch {
invalidRejected = true
}
// Every generated method resolves to the RemoteResult envelope; the
// business values below are what the assertions pin.
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.remote.goals.edit(
rootAgent.id,
rootResult.ref,
rootResult.value.ref,
{ objective: 'edited root goal' },
)
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
const result = {
invalidRejected,
rootResult,
rootEdit,
scopedResult,
rootResult: rootResult.value,
rootEdit: rootEdit.value,
scopedResult: scopedResult.value,
rootGoal: host.goals.get(rootAgent)?.objective,
scopedGoal: host.goals.get(scopedAgent)?.objective,
rootEvents: rootAgent.session.events.length,

View File

@@ -15,7 +15,10 @@
"path": "../../../vendor/cordis"
},
{
"path": "../gateway"
"path": "../gateway/tsconfig.client.json"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../credentials/credentials"

View File

@@ -56,6 +56,8 @@
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",

View File

@@ -10,7 +10,7 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,

View File

@@ -28,6 +28,7 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
@@ -115,7 +116,7 @@ const TERMINAL_OUTPUT_FIXTURE = [
`${sgr(32, '\u2713')} duplication 2.10s`,
`${sgr(31, '\u2717')} unit 8.41s`,
'',
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.client.spec.tsx'),
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
' expected 16 lines, received 24',
'',
@@ -200,7 +201,7 @@ const SEARCH_PATHS_FIXTURE = [
'packages/client/ui-primitives/src/SearchBlock.module.css',
'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
'packages/client/ui-tool/tests/search-card.spec.tsx',
'packages/client/ui-tool/tests/search-card.client.spec.tsx',
]
/**
@@ -1379,11 +1380,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return createFixtureWorld(options).api
}
interface FixtureWorld {
/** Both fixture faces over one state graph. */
export interface FixtureWorld {
/** Legacy unary/stream API the fixture still answers. */
readonly api: ApiProxy
/** Generic Remote caller for the endpoints business services own. */
readonly rpc: ClientConnectionRpc
}
/**
* Build both fixture faces so a caller can drive the Remote endpoints and the
* legacy API against one in-memory state graph.
* @param options - fixture branches for empty state and failure timing.
* @returns the legacy API face and the Remote RPC face.
*/
export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld {
return createFixtureWorld(options)
}
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// The resident fixture sessions all carry history, so none of them is blank.
@@ -1598,6 +1612,98 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
: undefined
)
/** Canonical fixture implementation of the generated Commands Remote contract. */
const commandRemotes = {
list(id: SessionId): RpcResult<readonly CommandDescriptor[]> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
return {
ok: true,
value: [
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
}
},
execute(id: SessionId, line: string): RpcResult<CommandExecution | undefined> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
// Structured split mirroring the Host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
let result: CommandResult
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
result = { kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` }
} else if (spec === undefined) {
result = { kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` }
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
result = { kind: 'success', text: `preset ${preset}` }
}
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
}
if (name === 'goal') {
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const objective = args.trim()
const current = backscanGoal(logOf(id))
let text: string
if (objective === '') {
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
} else if (current !== null && current.goal.phase !== 'complete') {
text = `A goal already exists (${current.goal.objective}). Clear it first.`
} else {
const created = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
text = `Goal created: ${created.goal.objective}`
}
const result: CommandResult = { kind: 'success', text }
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
}
const running = summaryOf(id)?.running === true
const outcomes: Record<string, string> = {
compact: 'fixture已压缩假动作',
echo: args.trim(),
plan: args.trim() === 'off'
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
: (running
? 'Entering plan mode (applies from the next step). Use /plan off to leave.'
: 'Plan mode on. Use /plan off to leave.'),
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return { ok: true, value: undefined }
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
if (name === 'plan' && !running) {
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
}
const result: CommandResult = { kind: 'success', ...text === '' ? {} : { text } }
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
},
}
const goalView = (projection: FxGoalProjection): FxGoalView => ({
...projection.goal,
roundsStarted: projection.roundsStarted,
@@ -2478,104 +2584,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return ok(request, { archivedSessionIds: [...archivedSessionIds] })
},
},
commands: {
// The catalog mirrors one session's effective view (every fixture
// session has an agent, like the real host).
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
commands: [
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
})
},
// Pure admission, mirroring the host: an admitted command logs the
// command/run + command/done lifecycle pair (mux-broadcast by append),
// and the response only reports resolution.
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
// Structured split mirroring the host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
// /permission mirrors the host handler: switch through the knob
// events (each append pushes a permissions projection frame).
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } })
}
return ok(request, { matched: true as const, commandId })
}
if (name === 'goal') {
// Host parallel: /goal with an objective creates (or reports) the
// current goal; the command lifecycle pair brackets the mutation.
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const objective = args.trim()
const current = backscanGoal(logOf(id))
let text: string
if (objective === '') {
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
} else if (current !== null && current.goal.phase !== 'complete') {
text = `A goal already exists (${current.goal.objective}). Clear it first.`
} else {
const created = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
text = `Goal created: ${created.goal.objective}`
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
return ok(request, { matched: true as const, commandId })
}
// Host parallel: /plan on an idle fixture session commits plan/mode
// immediately (the boundary flush covers only a running turn), so the
// outcome copy matches the immediate branch of the host handler.
const running = summaryOf(id)?.running === true
const outcomes: Record<string, string> = {
compact: 'fixture已压缩假动作',
echo: args.trim(),
plan: args.trim() === 'off'
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
: (running
? 'Entering plan mode (applies from the next step). Use /plan off to leave.'
: 'Plan mode on. Use /plan off to leave.'),
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
if (name === 'plan' && !running) {
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
return ok(request, { matched: true as const, commandId })
},
},
agentPresets: {
// Both trusts appear, because a surface must present a locally authored
// preset differently from one the deployment vetted.
@@ -2884,12 +2892,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const args = (payload as {
args: {
agentId: SessionId
line?: string
ref?: { id: string; revision: number }
request?: { objective?: string; maxGoalRounds?: number }
}
}).args
const sessionId = args.agentId
switch (endpoint) {
case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId))
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string))
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
objective: args.request?.objective as string,
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
@@ -2983,8 +2994,6 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.insertBefore': return this.api.workspace.insertBefore(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
case 'command.list': return this.api.commands.list(request)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.list': return this.api.agentPresets.list(request)
case 'agentPreset.select': return this.api.agentPresets.select(request)

View File

@@ -18,7 +18,7 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { ConnectionState } from '../src/client/connection.ts'
import { ConnectionController } from '../src/client/connection.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
import { FakeApiClient, deferred, ok } from './fake-api.client.ts'
const SID = 'fk-c1' as SessionId
const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }

View File

@@ -1,9 +1,8 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -172,19 +171,10 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>

View File

@@ -1,28 +1,35 @@
/**
* Fixture commands/skills domains: contract-shape conformance for the two
* domains added to ApiProxy rpcId echo, session-addressed catalogs, execute
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
* dispatch rows.
* Fixture commands/skills domains: session-addressed catalogs, execute
* parse/dispatch and its logged lifecycle pair, skill.list session resolution,
* and the FixtureApiClient dispatch rows. Commands answer on the Remote face
* and skills on the legacy API face, so both are driven here.
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts'
/** Drive one commands Remote endpoint against the fixture state graph. */
async function callRemote<T>(
rpc: ReturnType<typeof createFixtureFaces>['rpc'],
endpoint: string,
args: Record<string, unknown>,
): Promise<T> {
const result = await rpc.call('/api', endpoint, { args })
if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`)
return result.value as T
}
const sid = (id: string): SessionId => id as SessionId
let reqCount = 0
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
const signal = new AbortController().signal
describe('createFixtureApi commands/skills', () => {
it('serves the addressed session catalog with rpcId echo', async () => {
const api = createFixtureApi()
const request = req({ sessionId: sid('fx-alpha') })
const response = await api.commands.list(request)
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
const commands = response.result.value.commands
it('serves the addressed session catalog', async () => {
const { rpc } = createFixtureFaces()
const commands = await callRemote<{ name: string; input?: { hint: string } }[]>(
rpc, 'commands/list', { agentId: sid('fx-alpha') })
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
@@ -31,13 +38,13 @@ describe('createFixtureApi commands/skills', () => {
})
it('rejects a catalog request for an unknown session', async () => {
const api = createFixtureApi()
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
const { rpc } = createFixtureFaces()
const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } })
expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
const api = createFixtureApi()
const { api, rpc } = createFixtureFaces()
const frames: unknown[] = []
const abort = new AbortController()
const stream = api.events.mux(req({}), abort.signal)
@@ -47,10 +54,9 @@ describe('createFixtureApi commands/skills', () => {
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
}
})()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value).toMatchObject({ matched: true })
expect(response.result.value.commandId).toBeTruthy()
const execution = await callRemote<{ commandId: string } | undefined>(
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' })
expect(execution?.commandId).toBeTruthy()
await pump
const events = frames
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
@@ -63,22 +69,23 @@ describe('createFixtureApi commands/skills', () => {
})
it('addresses execute to the session; an unknown session errs', async () => {
const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
if (!hit.result.ok) throw new Error('execute failed')
expect(hit.result.value.matched).toBe(true)
const { rpc } = createFixtureFaces()
const hit = await callRemote<{ commandId: string } | undefined>(
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' })
expect(hit?.commandId).toBeTruthy()
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
const missing = await rpc.call('/api', 'commands/execute', {
args: { agentId: sid('fx-nope'), line: '/goal ship' },
})
expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('falls to matched:false on unknown names and non-command lines', async () => {
const api = createFixtureApi()
it('answers no execution for unknown names and non-command lines', async () => {
const { rpc } = createFixtureFaces()
for (const line of ['/nope', 'plain text', '/']) {
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
if (!response.result.ok) throw new Error('execute failed')
// Pure admission value: the matched bit is the whole response shape.
expect(response.result.value).toEqual({ matched: false })
// Absence is the whole answer: nothing matched, so no lifecycle id exists.
expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line }))
.toBeUndefined()
}
})
@@ -94,14 +101,13 @@ describe('createFixtureApi commands/skills', () => {
})
describe('FixtureApiClient command/skill dispatch', () => {
it('routes the three method keys through the in-memory dispatch table', async () => {
it('routes the Remote commands face and the legacy skill row through one state graph', async () => {
const client = new FixtureApiClient()
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
if (!list.result.ok) throw new Error('command.list failed')
expect(list.result.value.commands.length).toBeGreaterThan(0)
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
if (!executed.result.ok) throw new Error('command.execute failed')
expect(executed.result.value.matched).toBe(true)
const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') })
expect(commands.length).toBeGreaterThan(0)
const executed = await callRemote<{ commandId: string } | undefined>(
client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' })
expect(executed?.commandId).toBeTruthy()
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
if (!skills.result.ok) throw new Error('skill.list failed')
expect(skills.result.value.skills.length).toBeGreaterThan(0)

View File

@@ -0,0 +1,49 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/api-path.ts",
"src/client/api.ts",
"src/client/connection.ts",
"src/client/fixture.ts",
"src/client/index.ts",
"src/client/random-uuid.ts",
"src/client/rpc.ts",
"src/client/web-api-client.ts",
"src/loopback-hostname.ts",
"src/rpc.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/brand"
}
]
}

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/api-path.ts",
"src/api-request-trust.ts",
"src/http-bridge.ts",
"src/index.ts",
"src/invariant.ts",
"src/loopback-hostname.ts",
"src/rpc-host.ts",
"src/rpc.ts",
"src/websocket-downlink.ts"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,46 +1,11 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
],
"files": [],
"references": [
{
"path": "../../attachment/attachment"
"path": "./tsconfig.host.json"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../util/brand"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../interaction/user-interaction"
},
{
"path": "../../support/invariants"
"path": "./tsconfig.client.json"
}
],
"exclude": [
"**/*.legacy.*"
]
}

View File

@@ -34,7 +34,7 @@
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web",
"immediately": true
@@ -59,19 +59,19 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-typert-registry": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1"
},
"files": [

View File

@@ -17,7 +17,7 @@
*/
import { Context as CordisContext } from '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */

View File

@@ -1,5 +1,5 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;

View File

@@ -10,7 +10,8 @@
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -75,9 +76,9 @@ export interface ISession {
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
* @returns the admission result, or the Remote face's error branch.
*/
command(line: string): Promise<RpcResult<{ matched: boolean }>>
command(line: string): Promise<RemoteResult<{ matched: boolean }>>
}
/**

View File

@@ -7,7 +7,7 @@
* dependency.
*/
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client'
import type { ObservableSnapshot } from './store.ts'
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */

View File

@@ -10,7 +10,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentContext } from '../agents/scope.ts'
import type { SessionSearchResultItem } from '../sessions/manager.ts'

View File

@@ -6,7 +6,7 @@
* the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain.
*/
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts'

View File

@@ -1,10 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -179,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert', 'remote']
export const inject = ['connection', 'typert', 'remote', 'remote.commands']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -191,7 +191,7 @@ export function apply(ctx: Context): void {
views: new ConversationViewRegistry(ctx),
}
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api, conversation)
const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation)
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})

View File

@@ -13,7 +13,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type {

View File

@@ -2,7 +2,7 @@
// The input order is authoritative; lineage only makes each child adjacent to its parent.
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { PendingInteractionStatus } from './pending.ts'

View File

@@ -5,7 +5,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import { Session } from './session.ts'
import type { SessionRemotes } from './remotes.ts'
/**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
@@ -165,6 +166,7 @@ export class SessionManager {
*/
constructor(
private readonly api: IApiClient,
private readonly remote: SessionRemotes,
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
private readonly conversation?: ConversationRuntime,
@@ -305,7 +307,7 @@ export class SessionManager {
private createSession(sessionId: SessionId): Session {
const address = this.addresses.get(sessionId)
return new Session(sessionId, this.api, {
return new Session(sessionId, this.api, this.remote, {
...(address === undefined ? {} : {
address,
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,

View File

@@ -4,7 +4,7 @@
import type {
ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
export interface PendingPayloads {

View File

@@ -1,5 +1,5 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client'
import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { QueuedMessage } from './conversation.ts'

View File

@@ -0,0 +1,12 @@
/**
* Remote namespaces the Session cluster calls. One parameter for one concept:
* the generated surface a Session and its manager reach the Host through.
*
* @module @deepseek-ai/dsh-client-runtime/client/sessions/remotes
*/
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
/** The generated Remote namespaces a Session and its manager call. */
export type SessionRemotes = Pick<Context['remote'], 'commands'>

View File

@@ -17,7 +17,7 @@
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts'
import { SessionProvideChannel } from './provide.ts'
@@ -271,11 +272,13 @@ export class SessionsService implements ISessions {
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
* @param remote - generated Remote namespaces shared with every Session.
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
*/
constructor(
private readonly rootCtx: Context,
api: IApiClient,
remote: SessionRemotes,
conversationRuntime?: ConversationRuntime,
) {
this.selection = createSnapshotStore<SessionSelection>(
@@ -291,6 +294,7 @@ export class SessionsService implements ISessions {
)
this.manager = new SessionManager(
api,
remote,
restored.sessionId,
restored.subagentAddress,
conversation,

View File

@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -21,6 +21,8 @@ import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { SessionRemotes } from './remotes.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
@@ -134,11 +136,13 @@ export class Session implements SessionFace {
/**
* @param sessionId - Host session identity (client sessions are always Host-born).
* @param api - shared wire client.
* @param remote - generated Remote namespaces this session calls.
* @param options - optional manager-owned state observers.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
private readonly remote: SessionRemotes,
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
@@ -351,12 +355,10 @@ export class Session implements SessionFace {
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
try {
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
} catch (error) {
return transportError(error)
}
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
const result = await this.remote.commands.execute(this.sessionId, line)
if (!result.ok) return result
return { ok: true, value: { matched: result.value !== undefined } }
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */

View File

@@ -4,7 +4,7 @@
* uninterrupted subagent subtree.
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
*/
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from './service.ts'
/** Descendant counts projected for one possible parent session. */

View File

@@ -2,7 +2,7 @@
import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { Notifier } from '../sessions/notifier.ts'
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'

View File

@@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'

View File

@@ -2,7 +2,7 @@
import type {
IApiClient, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'

View File

@@ -5,8 +5,8 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
@@ -14,7 +14,7 @@ import type { ConversationNodeDefinition } from '../src/client/contract/conversa
import { Session } from '../src/client/sessions/session.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
interface Bench {
ctx: Context
@@ -45,6 +45,7 @@ async function mount(): Promise<Bench> {
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
ctx.reflect.provide('remote.commands', fakeRemote().commands)
await ctx.plugin(RuntimeClient).await()
return bench
}

View File

@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
import type {
@@ -8,7 +8,7 @@ import type {
} from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
return {
@@ -145,7 +145,7 @@ describe('Conversation registries', () => {
api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
}) as never)
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
await sessions.refresh()
await Promise.resolve()
sessions.scope(sessionId)

View File

@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {

View File

@@ -1,13 +1,13 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
} from '@deepseek-ai/dsh-api-remotes/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
@@ -55,6 +55,20 @@ interface StreamConn<F> {
feed(item: StreamItem<F>): void
}
/**
* Commands Remote double: the generated face delivers the carrier's outcome, so
* a test that programs nothing sees an empty catalog and an unmatched line.
* @returns the Remote namespaces the session cluster calls.
*/
export function fakeRemote(): SessionRemotes {
return {
commands: {
list: () => Promise.resolve({ ok: true, value: [] }),
execute: () => Promise.resolve({ ok: true, value: undefined }),
},
}
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
@@ -210,19 +224,10 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address
// skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>

View File

@@ -4,7 +4,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({

View File

@@ -4,10 +4,10 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
@@ -28,7 +28,7 @@ describe('instances', () => {
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
const session = manager.get(S1)
expect(manager.get(S1)).toBe(session) // resident: same instance forever
@@ -37,7 +37,7 @@ describe('instances', () => {
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
// Uninstantiated: approval buffers, plain session/event drops.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -50,7 +50,7 @@ describe('instances', () => {
it('retains every live answerable request and compacts resolutions before instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
@@ -67,7 +67,7 @@ describe('instances', () => {
})
it('drops buffered answerable requests on session removal', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -80,7 +80,7 @@ describe('list lifecycle', () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
@@ -96,7 +96,7 @@ describe('list lifecycle', () => {
const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const hydration = manager.refreshList()
manager.handleHostEnvelope({
rpcId: 'during-first' as never,
@@ -116,7 +116,7 @@ describe('list lifecycle', () => {
it('advances list activity only for direct user messages', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
// Both a new prompt and an admitted steer land as a user-sourced message.
@@ -156,7 +156,7 @@ describe('list lifecycle', () => {
it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending.
@@ -165,7 +165,7 @@ describe('list lifecycle', () => {
it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready')
@@ -184,7 +184,7 @@ describe('list lifecycle', () => {
it('merges create into the list immediately without waiting for a refresh', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const result = await manager.create()
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
@@ -192,7 +192,7 @@ describe('list lifecycle', () => {
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const titleFrame = (rpcId: string, title: string, seq: number) => {
manager.handleMuxEnvelope({
rpcId: rpcId as never,
@@ -219,7 +219,7 @@ describe('list lifecycle', () => {
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
// A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleMuxEnvelope({
rpcId: 'push-newer' as never,
@@ -242,7 +242,7 @@ describe('list lifecycle', () => {
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
const frame = (rpcId: string, payload: object) => {
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
@@ -270,7 +270,7 @@ describe('search', () => {
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
@@ -286,7 +286,7 @@ describe('search', () => {
it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
api.onSearch = () => Promise.resolve(err({
code: 'internal',
message: 'index unavailable',
@@ -309,7 +309,7 @@ describe('search', () => {
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
@@ -343,7 +343,7 @@ describe('subagent catalogs', () => {
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
@@ -408,7 +408,7 @@ describe('subagent catalogs', () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve()
@@ -458,7 +458,7 @@ describe('subagent catalogs', () => {
] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(root)
manager.handleHostEnvelope({
@@ -487,7 +487,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
@@ -528,7 +528,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
@@ -569,7 +569,7 @@ describe('subagent catalogs', () => {
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(S1)
manager.handleHostEnvelope({
@@ -587,7 +587,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
@@ -606,7 +606,7 @@ describe('subagent catalogs', () => {
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, root)
const manager = new SessionManager(api, fakeRemote(), root)
const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced
@@ -664,7 +664,7 @@ describe('subagent catalogs', () => {
})
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
@@ -711,7 +711,7 @@ describe('subagent catalogs', () => {
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
@@ -730,14 +730,14 @@ describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.reject(new Error('list wire down'))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
})
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const session = manager.get(S1)
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
await manager.refreshList()
@@ -747,7 +747,7 @@ describe('remaining branches', () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
@@ -767,7 +767,7 @@ describe('remaining branches', () => {
message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
@@ -781,7 +781,7 @@ describe('remaining branches', () => {
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
@@ -794,7 +794,7 @@ describe('remaining branches', () => {
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([])
@@ -815,7 +815,7 @@ describe('remaining branches', () => {
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
let notified = 0
const unsubscribe = manager.subscribe(() => { notified++ })
await manager.refreshList()
@@ -830,7 +830,7 @@ describe('remaining branches', () => {
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
@@ -845,7 +845,7 @@ describe('remaining branches', () => {
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
const before = manager.getListSnapshot()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
@@ -861,7 +861,7 @@ describe('remaining branches', () => {
it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({
rpcId: 'h2' as never,
@@ -885,7 +885,7 @@ describe('connected generation', () => {
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const openedSession = manager.get(S1)
await openedSession.open()
manager.get(S2) // instantiated but never opened
@@ -903,7 +903,7 @@ describe('connected generation', () => {
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const manager = new SessionManager(api, S2, address)
const manager = new SessionManager(api, fakeRemote(), S2, address)
manager.handleConnected()
@@ -916,7 +916,7 @@ describe('connected generation', () => {
describe('pending-interaction list status', () => {
it('tracks approval requests through replay and resolution without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -929,7 +929,7 @@ describe('pending-interaction list status', () => {
})
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
rpcId: 'q1' as never,
@@ -962,7 +962,7 @@ describe('pending-interaction list status', () => {
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
rpcId: 'q-plan' as never,
@@ -979,7 +979,7 @@ describe('pending-interaction list status', () => {
})
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({
@@ -998,7 +998,7 @@ describe('pending-interaction list status', () => {
})
it('drops stale status at generation death before replay re-adds live interactions', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
@@ -1013,7 +1013,7 @@ describe('pending-interaction list status', () => {
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
// Buffered pre-instantiation: an approval pair and a queued row.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -1040,7 +1040,7 @@ describe('completed reminder', () => {
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
@@ -1054,7 +1054,7 @@ describe('completed reminder', () => {
})
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S2)
@@ -1069,7 +1069,7 @@ describe('completed reminder', () => {
})
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
@@ -1084,7 +1084,7 @@ describe('completed reminder', () => {
})
it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
@@ -1100,7 +1100,7 @@ describe('completed reminder', () => {
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
@@ -1112,7 +1112,7 @@ describe('completed reminder', () => {
it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
@@ -1125,7 +1125,7 @@ describe('completed reminder', () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
@@ -1139,7 +1139,7 @@ describe('completed reminder', () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
@@ -1160,7 +1160,7 @@ describe('background-task mirror', () => {
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession
@@ -1173,7 +1173,7 @@ describe('background-task mirror', () => {
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, []))
@@ -1181,7 +1181,7 @@ describe('background-task mirror', () => {
})
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({
rpcId: 's' as never,
@@ -1191,7 +1191,7 @@ describe('background-task mirror', () => {
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
@@ -1199,7 +1199,7 @@ describe('background-task mirror', () => {
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
const seen = vi.fn()
manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))

View File

@@ -4,7 +4,7 @@
*/
import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client'
import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
import { PartialAccumulator } from '../src/client/sessions/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk

View File

@@ -8,12 +8,12 @@
* list rows' title projection).
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
import { entries, plainTurn } from './event-script.client.ts'
// Test-domain keys merged into the projection map (the Service Definition package's
// pure-type outlet), the same way domain host plugins merge theirs.
@@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => {
describe('Session tail-page seeding', () => {
it('seeds the store from a history response carrying a projections block', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
@@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => {
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
@@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => {
it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
@@ -141,7 +141,7 @@ describe('manager frame routing', () => {
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
manager.handleMuxEnvelope({
rpcId: 'p1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
@@ -158,7 +158,7 @@ describe('manager frame routing', () => {
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
@@ -181,7 +181,7 @@ describe('manager frame routing', () => {
it('projects every retained value into list rows with stable snapshot identity', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({
items: [{
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
@@ -211,7 +211,7 @@ describe('manager frame routing', () => {
it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)

View File

@@ -7,10 +7,10 @@ import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
@@ -42,7 +42,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
}
function makeSession(): Session {
return new Session(SID, new FakeApiClient())
return new Session(SID, new FakeApiClient(), fakeRemote())
}
describe('queue snapshot intake', () => {
@@ -198,7 +198,7 @@ describe('queue snapshot intake', () => {
describe('queue operation transport', () => {
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
const session = new Session(SID, api, fakeRemote())
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
@@ -251,14 +251,14 @@ describe('queue reconnect semantics', () => {
describe('manager buffering of queue snapshots', () => {
it('replays only the latest snapshot for an uninstantiated session', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
const manager = new SessionManager(new FakeApiClient())
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
manager.handleMuxEnvelope({
rpcId: rid('g1b'),

View File

@@ -8,7 +8,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId

View File

@@ -9,7 +9,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session } from '../src/client/sessions/session.ts'
import type {
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
@@ -17,8 +17,8 @@ import type {
ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot,
ConversationViewDefinition,
} from '../src/client/index.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
@@ -159,7 +159,7 @@ const TEST_CONVERSATION: ConversationRuntime = {
}
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) }
return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) }
}
function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] {
@@ -343,7 +343,7 @@ describe('live event path', () => {
entries: () => [testViewDefinition()],
} as unknown as ConversationRuntime['views'],
}
const session = new Session(SID, api, { conversation })
const session = new Session(SID, api, fakeRemote(), { conversation })
await session.open()
const snapshots: ConversationSnapshot[] = []
session.subscribe(() => { snapshots.push(session.getSnapshot()) })
@@ -448,7 +448,7 @@ describe('paging', () => {
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
@@ -487,7 +487,7 @@ describe('prompt and cancel errors', () => {
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
@@ -501,7 +501,7 @@ describe('prompt and cancel errors', () => {
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
})
await session.open()

View File

@@ -8,9 +8,9 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -23,7 +23,7 @@ interface Bench {
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const svc = new SessionsService(ctx, api)
const svc = new SessionsService(ctx, api, fakeRemote())
return { ctx, api, svc }
}

View File

@@ -6,14 +6,14 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
// Type-only: the api-remotes facade carries both the allowlist's selection seat
// and the owner packages' `./types` declarations, which together give `$on` its
// key face and per-event listener signatures.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
/**
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
@@ -74,6 +74,7 @@ async function mount(): Promise<Bench> {
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote.commands', fakeRemote().commands)
await ctx.plugin(RuntimeClient).await()
return bench
}

View File

@@ -1,10 +1,10 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
@@ -191,7 +191,7 @@ describe('WorkspacesService', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
@@ -219,7 +219,7 @@ describe('WorkspacesService', () => {
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
@@ -278,7 +278,7 @@ describe('WorkspacesService', () => {
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
api.onList = () => Promise.resolve(ok({
@@ -298,7 +298,7 @@ describe('WorkspacesService', () => {
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceCreate = () => Promise.resolve(ok({
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
@@ -317,7 +317,7 @@ describe('WorkspacesService', () => {
it('passes native directory selection and cancellation through without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
@@ -331,7 +331,7 @@ describe('WorkspacesService', () => {
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
@@ -352,7 +352,7 @@ describe('WorkspacesService', () => {
it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
@@ -363,7 +363,7 @@ describe('WorkspacesService', () => {
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
await workspaces.refresh()
@@ -379,7 +379,7 @@ describe('WorkspacesService', () => {
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('one'), workspace('two')] as never[],
}))
@@ -402,7 +402,7 @@ describe('WorkspacesService', () => {
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
@@ -435,7 +435,7 @@ describe('WorkspacesService', () => {
const emptyCtx = new Context()
const emptyApi = new FakeApiClient()
const emptySessions = new SessionsService(emptyCtx, emptyApi)
const emptySessions = new SessionsService(emptyCtx, emptyApi, fakeRemote())
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
const clear = vi.spyOn(emptySessions, 'clear')
emptyWorkspaces.startSession()
@@ -445,7 +445,7 @@ describe('WorkspacesService', () => {
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [
@@ -491,7 +491,7 @@ describe('WorkspacesService', () => {
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
@@ -525,7 +525,7 @@ describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces }
}

View File

@@ -20,9 +20,6 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../../host/apiproxy"
},
@@ -60,7 +57,7 @@
"path": "../../typert/registry"
},
{
"path": "../../api/gateway"
"path": "../../api/remotes/tsconfig.client.json"
}
],
"exclude": [

View File

@@ -11,7 +11,7 @@
* before-the-fact, while the header only reports what a session already runs.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face

View File

@@ -10,7 +10,7 @@
* deployment default again, matching the workspace picker beside it.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -14,7 +14,7 @@
* more than the row it targeted.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'

View File

@@ -7,7 +7,7 @@
* namespace's `default` field, which is what the host resolves at creation.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The agent-preset settings namespace on the host wire. */

View File

@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'

View File

@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
} from '../src/client/settings-store.ts'

Some files were not shown because too many files have changed in this diff Show More