Merge origin/master into codex/bounded-background-tasks-v2

This commit is contained in:
pku-xht
2026-08-12 15:54:49 +08:00
829 changed files with 11975 additions and 4727 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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e
README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a
README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6
README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946

View File

@@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.

View File

@@ -6,8 +6,7 @@
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。

View File

@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
@@ -78,6 +79,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",

View File

@@ -1,11 +1,16 @@
/** 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 pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -17,12 +22,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 +55,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, pluginInventoryRemote]) {
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"
@@ -23,6 +26,9 @@
{
"path": "../../goal/goal"
},
{
"path": "../../host/plugin-inventory"
},
{
"path": "../../interaction/commands"
},

View File

@@ -199,6 +199,74 @@ describe('Loader config interpolation', () => {
})
})
describe('Loader entry disabled interpolation', () => {
it('evaluates a !!js disabled expression against the loader context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: expr-off',
' name: ./noop.mjs',
' disabled: !!js process.version.length > 0',
'- id: expr-on',
' name: ./noop.mjs',
' disabled: !!js process.version.length === 0',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const off = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-off')
const on = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-on')
expect(off?.disabled).toBe(true)
expect(off?.fiber).toBeUndefined()
expect(on?.disabled).toBe(false)
expect(on?.fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('keeps the raw expression in the options so write-back preserves the !!js form', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.platform === "win32"\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
// The evaluated boolean drives the mount decision; the serialized
// expression node stays in the options for the file-backed tree.
expect(entry?.options.disabled).toEqual({ __jsExpr: 'process.platform === "win32"' })
expect(entry?.disabled).toBe(process.platform === 'win32')
} finally {
await ctx.fiber.dispose()
}
})
it('re-evaluates when update() replaces the expression, mounting and unmounting', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.version.length === 0\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
expect(entry?.disabled).toBe(false)
expect(entry?.fiber).toBeDefined()
// The expression form is the file dialect; the typed programmatic API
// carries booleans. Include reapplication feeds the raw node through
// the untyped file path — simulated here with the serialized shape.
const disabledTrue = { __jsExpr: 'process.version.length > 0' } as unknown as boolean
const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
await entry?.update({ disabled: disabledTrue })
expect(entry?.disabled).toBe(true)
expect(entry?.fiber).toBeUndefined()
await entry?.update({ disabled: disabledFalse })
expect(entry?.disabled).toBe(false)
expect(entry?.fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
})
describe('boot with user patches', () => {
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
const dir = tmp()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd
README.md: 33125014539e801dbd2952a3b4513cafc80bdcee
README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559

View File

@@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones
## Ordinary providers and injected config
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
const program = webCommand()
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
parseCmdline(ctx, program)
}
```
@@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc
port: !!js ctx.webStartup.port ?? 3080
```
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate.
### How injection orders config

View File

@@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
## 普通提供方与注入配置
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有:
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
const program = webCommand()
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
parseCmdline(ctx, program)
}
```
@@ -45,7 +46,7 @@ export function apply(ctx: Context): void {
port: !!js ctx.webStartup.port ?? 3080
```
`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。
### 注入如何排列配置求值

View File

@@ -8,7 +8,8 @@
* text, and its parse errors instead of the launcher knowing them.
*
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
* provider may publish the parsed values as its own service, and ordinary rows
* provider may publish the parsed values as its own service from its program's
* commander action, and ordinary rows
* can inject that service and read it from lazily resolved config —
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
* beside it. No row has launcher-level command-line status.
@@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
stderr: process.stderr,
}
/**
* Resolve parsed arguments into an app-owned value. Call
* `program.error(...)` to reject the invocation with a usage message instead
* of throwing.
* @param program - the parsed commander program.
* @param ctx - the plugin context that received the command line.
* @returns the value an ordinary provider plugin may publish.
*/
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
/**
* Parse the launcher's immutable argument snapshot with an app's commander
* program. The caller decides whether and how to publish the returned value;
* this helper has no Loader-row or service ownership semantics.
* program. Commander runs the program's own synchronous action handler on a
* successful parse; app code there publishes its service and rejects an
* invalid invocation with `program.error(...)`. This helper has no Loader-row
* or service ownership semantics.
*
* Help, version, and rejected arguments are terminal for the process: commander
* writes the text, the helper requests `ctx.appExit`, and it returns
* `undefined` so the caller publishes nothing.
* Help, version, and rejected arguments — from the grammar or from an action
* — are terminal for the process: commander writes the text and the helper
* requests `ctx.appExit`. The action never runs on help, version, or a
* grammar rejection; an action must reject before it publishes, because
* statements before its `program.error(...)` have already run.
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
* @param program - the app's commander program, with its flags and description already declared.
* @param plan - this invocation's resolved value; omitted returns an empty object.
* @returns the resolved value, or `undefined` when the app asked to exit.
* @throws when the launcher did not provide the command line and exit request.
* @param program - the app's commander program, with its flags, description,
* actions, and any subcommands already declared.
* @throws when the launcher did not provide the command line and exit request,
* or when no command in the program declares an action.
*/
export function parseCmdline<T>(
ctx: Context,
program: Command,
plan: CmdlinePlan<T> = (() => ({}) as T),
): T | undefined {
export function parseCmdline(ctx: Context, program: Command): void {
// Read through the global service store, not the property proxy: appExit is
// an optional host value and the plugin only needs to inject cmdlineArgs.
const args = ctx.get('cmdlineArgs')
@@ -112,23 +103,54 @@ export function parseCmdline<T>(
if (args === undefined || exit === undefined) {
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
}
program
if (!hasAction(program)) {
throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`)
}
configureExitAndOutput(program)
try {
program.parse(args.get(), { from: 'user' })
} catch (error) {
// exitOverride turns help, version, a parse error, and the action's own
// program.error() into a CommanderError; commander has already written the
// text through the output configured above.
if (!isCommanderError(error)) throw error
exit(error.exitCode)
}
}
/**
* Whether any command in the tree declares an action handler.
*
* The `Command` type cannot express the action precondition, so the handler is
* read structurally (as {@link isCommanderError} reads commander's control-flow
* errors): without this guard, a program that forgot its action would parse
* successfully, publish nothing, and surface only as dependent rows pending on
* the absent service.
* @param command - the command whose tree is inspected.
* @returns true when the command or any registered subcommand has an action.
*/
function hasAction(command: Command): boolean {
if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
return command.commands.some(hasAction)
}
/**
* Route every command's exit and output through the launcher adapter.
*
* Commander copies `exitOverride` and output configuration into a subcommand
* only at registration, so a root-only override would let an
* already-registered subcommand's rejection write to the process streams and
* call `process.exit` directly, bypassing `ctx.appExit`.
* @param command - the root of the command tree to configure.
*/
function configureExitAndOutput(command: Command): void {
command
.exitOverride()
.configureOutput({
writeOut: text => void internals.stdout.write(text),
writeErr: text => void internals.stderr.write(text),
})
try {
program.parse(args.get(), { from: 'user' })
return plan(program, ctx)
} catch (error) {
// exitOverride turns help, version, a parse error, and a plan's own
// program.error() into a CommanderError; commander has already written the
// text through the output configured above.
if (!isCommanderError(error)) throw error
exit(error.exitCode)
return undefined
}
for (const child of command.commands) configureExitAndOutput(child)
}
/**

View File

@@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { afterEach, describe, expect, it } from 'vitest'
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
/** Every value one boot of the fixture tree observed. */
interface Observed {
@@ -43,8 +43,8 @@ function demoCommand(): Command {
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
}
/** The fixture app's plan: the resolved values its rows read. */
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
/** The fixture app's action body: the resolved values its rows read. */
const resolveDemo = (program: Command): { port?: number } => {
const port = program.opts<{ port?: string }>().port
if (port === undefined) return {}
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
@@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
* Mount a two-row composition the way a profile boot does: both rows at once,
* with Loader ordering config resolution from their injections.
* @param args - the invocation's inner arguments.
* @param plan - the app's plan; defaults to the fixture's own.
* @param resolve - the app's action body; defaults to the fixture's own.
* @returns the booted fixture.
*/
async function bootFixture(
args: string[],
plan: CmdlinePlan = demoPlan,
resolve: (program: Command) => unknown = resolveDemo,
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
): Promise<Fixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
@@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
globals.__observed = observed
globals.__provideDemoArgs = (ctx: Context) => {
const values = parseCmdline(ctx, demoCommand(), plan)
if (values !== undefined) ctx.provide('demoStartup', values)
const program = demoCommand()
program.action(() => { ctx.provide('demoStartup', resolve(program)) })
parseCmdline(ctx, program)
}
// The composition, exactly as a profile delivers one: include patches whose
@@ -133,7 +134,7 @@ describe('parseCmdline', () => {
})
it('recognizes the Loader object form of a provider-service injection', async () => {
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
expect(observed.started).toEqual({ port: 8080 })
})
@@ -144,31 +145,35 @@ describe('parseCmdline', () => {
expect(observed.exits).toEqual([0])
})
it('rejects the invocation from the plan without starting the app', async () => {
it('rejects the invocation from the action without starting the app', async () => {
const { observed } = await bootFixture(['--port', 'abc'])
expect(observed.out).toContain('--port must be a number')
expect(observed.started).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('rethrows a plan failure that is not commander asking to exit', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
it('rethrows an action failure that is not commander asking to exit', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
const program = demoCommand().action(() => { throw new Error('action exploded') })
expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
})
it('rethrows a thrown value that is not an object at all', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => {
const thrown: unknown = 'plan threw a string'
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
const program = demoCommand().action(() => {
const thrown: unknown = 'action threw a string'
throw thrown
}
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
})
expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
})
it('returns values without inspecting Loader rows or owning a service', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
expect(parseCmdline(ctx, demoCommand())).toEqual({})
it('runs the action without inspecting Loader rows or owning a service', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
let values: unknown
const program = demoCommand()
program.action(() => { values = resolveDemo(program) })
parseCmdline(ctx, program)
expect(values).toEqual({})
expect(ctx.get('demoStartup')).toBeUndefined()
})
})
@@ -182,6 +187,28 @@ describe('provideCmdline', () => {
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
})
it('refuses at load a program in which no command declares an action', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
expect(() => { parseCmdline(ctx, demoCommand()) })
.toThrow('no command in the program declares an action')
})
it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
const ctx = new Context()
const exits: number[] = []
let err = ''
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
// The root declares no action of its own: the tree-wide guard accepts the
// subcommand's, and the subcommand inherits the exit and output routing.
const program = new Command().name('demo')
const child = program.command('serve')
child.action(() => { child.error('error: serve rejected') })
parseCmdline(ctx, program)
expect(err).toContain('serve rejected')
expect(exits).toEqual([1])
})
it('fails loud when a parser runs without the launcher values', () => {
const ctx = new Context()
expect(() => { parseCmdline(ctx, demoCommand()) })
@@ -191,8 +218,15 @@ describe('provideCmdline', () => {
it('lets multiple parsers read the same immutable snapshot', () => {
const ctx = new Context()
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
const parseOnce = (): unknown => {
let values: unknown
const program = demoCommand()
program.action(() => { values = resolveDemo(program) })
parseCmdline(ctx, program)
return values
}
expect(parseOnce()).toEqual({ port: 8080 })
expect(parseOnce()).toEqual({ port: 8080 })
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/base/README.md
README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca
README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782
README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b
README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled.
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.

View File

@@ -4,7 +4,7 @@
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。
patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。

View File

@@ -172,9 +172,14 @@
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
config:
timeoutMs: 60000
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
@@ -199,6 +204,11 @@
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'

View File

@@ -23,7 +23,6 @@
"default": "./lib/invariant.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -31,7 +30,6 @@
"lib/index.js",
"lib/invariant.js",
"cordis.patch.yml",
"windows.cordis.patch.yml",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",

View File

@@ -3,12 +3,13 @@
* field must name a real, parseable patch list.
*/
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
describe('dsh-base bundle', () => {
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
@@ -39,34 +40,37 @@ describe('dsh-base bundle', () => {
})
})
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
it('gates each shell stack by platform with a symmetric disabled expression', () => {
const root = fileURLToPath(new URL('..', import.meta.url))
const parsed = yaml.load(
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'),
{ schema: entryListSchema },
) as {
id?: string
disabled?: boolean
insert?: { id?: string; name?: string }[]
config?: { policy?: string }
}[]
const disables = parsed
.filter(patch => patch.disabled === true)
.map(patch => patch.id)
// Only the POSIX bash stack is disabled: the Windows roster confines the
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
// the permission switcher, fs-sandbox, and the approval service all stay
// enabled exactly as on POSIX — only the shell is swapped.
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
const inserted = parsed
.flatMap(patch => patch.insert ?? [])
.map(row => row.id)
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
// The patch no longer touches the permission/approval surface at all.
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
)
if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list')
const rows = parsed.flatMap((patch): Record<string, unknown>[] =>
typeof patch === 'object' && patch !== null
? (patch as { insert?: Record<string, unknown>[] }).insert ?? []
: [],
)
// Symmetric gating: each stack's executor and tool rows carry the same
// platform fact, inverted between the bash and pwsh twins, so exactly one
// shell stack mounts per host. Evaluate with a platform-scoped context
// (the `with` scope shadows the global `process`) so both outcomes pin on
// every host.
for (const [id, win32, linux] of [
['bash-sandbox', true, false],
['tool-bash', true, false],
['pwsh-sandbox', false, true],
['tool-pwsh', false, true],
] as const) {
const row = rows.find(candidate => candidate.id === id)
if (row === undefined) throw new Error(`base patch must mount ${id}`)
const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr
if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`)
expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux)
}
// The platform layer folded into these rows: no separate patch file ships.
expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false)
})
})

View File

@@ -1,31 +0,0 @@
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
# hosts, between the bundle layers and the user layers. Windows confines
# through the ACL restricted-token runner (the win32 chain of
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
# stack is the SANDBOXED PowerShell executor plus the full permission
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
# permission switcher and the approval service run exactly as on POSIX, and
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
# Only the POSIX bash
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
# A Windows host that prefers the unconfined local pwsh executor or full
# access overrides these rows through its profile or home cordis.patch.yml.
# The bash-restore recipe must be complete: disable pwsh-sandbox and
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
# families register the same 'bash' service, so re-enabling the bash rows
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
# registration.
- id: bash-sandbox
disabled: true
- id: tool-bash
disabled: true
- insert:
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'

View File

@@ -41,22 +41,17 @@ Examples:
}
/**
* Turn the parsed command line into the runner's task.
* @param program - the parsed headless command.
* @returns the runner's service value.
*/
function planHeadlessStartup(program: Command): HeadlessStartupValues {
const task = program.args.join(' ')
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
return { task }
}
/**
* Parse and provide the one-shot task as an ordinary Cordis service.
* Parse and provide the one-shot task as an ordinary Cordis service. The
* command's action publishes the task; a missing or whitespace-only task is a
* usage error, so on rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
* @returns nothing once the task is provided, or when the command requested exit.
*/
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup)
if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values)
const program = headlessCommand()
program.action(() => {
const task = program.args.join(' ')
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues)
})
parseCmdline(ctx, program)
}

View File

@@ -80,6 +80,10 @@
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-auto'
# Read-only projection of current Loader entries for trusted client RPCs.
- id: plugin-inventory
name: '@deepseek-ai/dsh-host-plugin-inventory'
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. The base layer's agent-default-model service owns the default model.
- id: api-gateway
@@ -172,6 +176,9 @@
- id: ui-models
name: '@deepseek-ai/dsh-client-ui-models'
- id: ui-plugins
name: '@deepseek-ai/dsh-client-ui-plugins'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
@@ -262,6 +269,9 @@
- id: tool-bash
disabled: true
- id: tool-pwsh
disabled: true
# The background-task REGISTRY stays on the host plane; only the model-facing
# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a
# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it
@@ -374,12 +384,15 @@
disabled: true
# The preset roster. `config/agent-presets/` ships with the deployment and is
# read-only (its entries carry `system` trust);
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
# carries the same trust as shell access because a preset IS a composition.
# `roots` is an assembly fact, not user config: the shipped preset directory
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
# same treatment `distIndex` gets on the webserver row.
# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
# where a person — or an agent — authors their own, and carries the same trust
# as shell access because a preset IS a composition.
#
# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
# same treatment `distIndex` gets on the webserver row. The writable root is
# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
# never reaches that patch still finds a person's presets.
- insert:
- id: agent-presets
name: '@deepseek-ai/dsh-agent-presets'

View File

@@ -56,10 +56,13 @@
"@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:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-plugins": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
@@ -84,6 +87,7 @@
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",

View File

@@ -57,28 +57,24 @@ Examples:
}
/**
* Turn the parsed flags into the value injected rows read.
* @param program - the parsed web command.
* @returns this invocation's immutable Web options.
*/
function planWebStartup(program: Command): WebStartupValues {
const options = program.opts<WebOptions>()
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
return {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
trustedHosts: options.trustedHost ?? [],
}
}
/**
* Parse and provide the Web invocation as an ordinary Cordis service.
* Parse and provide the Web invocation as an ordinary Cordis service. The
* command's action publishes the flags this invocation named; a non-numeric
* `--port` is a usage error, so on rejection (and on `--help`) nothing is
* provided.
* @param ctx - plugin context carrying the command line.
* @returns nothing once values are provided, or when the command requested exit.
*/
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values)
const program = webCommand()
program.action(() => {
const options = program.opts<WebOptions>()
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
ctx.provide(WEB_STARTUP_SERVICE, {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
trustedHosts: options.trustedHost ?? [],
} satisfies WebStartupValues)
})
parseCmdline(ctx, program)
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca
README.md: 236531281c17ef982982e97caad99491584bd0b5
README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64

View File

@@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.

View File

@@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。

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,
@@ -2407,6 +2513,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
emitHost({ type: 'host/workspace-removed', workspaceId })
return ok(request, { deleted: true as const })
},
insertBefore: (request) => {
const { workspaceId, beforeWorkspaceId } = request.payload
const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
const anchor = beforeWorkspaceId === undefined
? workspaces.length
: workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId)
const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined
if (missing !== undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${missing}`,
details: { workspaceId: missing },
})
}
if (beforeWorkspaceId !== workspaceId) {
const previousOrder = workspaces.map(candidate => candidate.workspaceId)
const [workspace] = workspaces.splice(source, 1)
/* v8 ignore next -- source was resolved from the same array immediately above. */
if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`)
const at = beforeWorkspaceId === undefined
? workspaces.length
: workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId)
workspaces.splice(at, 0, workspace)
if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) {
emitHost({
type: 'host/workspace-order-changed',
workspaceIds: workspaces.map(candidate => candidate.workspaceId),
})
}
}
return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) })
},
insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
@@ -2446,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.
@@ -2852,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 },
@@ -2948,10 +2991,9 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.delete': return this.api.workspace.delete(request)
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,10 +1,9 @@
// 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,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -159,6 +158,9 @@ export class FakeApiClient implements IApiClient {
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({
workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId],
}))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
@@ -169,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

@@ -264,7 +264,12 @@ describe('createFixtureApi', () => {
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
expect(seen).toHaveLength(1)
const added = seen[0]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(added).toEqual({
type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture',
})
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
@@ -699,7 +704,11 @@ describe('createFixtureApi', () => {
await consuming
// The session lands with the workspace's path as cwd, and the account
// write pushes the fresh workspace snapshot after session-added.
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
const added = seen[0]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(added).toEqual({
type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture',
})
expect(seen[1]).toMatchObject({
type: 'host/workspace-changed',
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
@@ -728,7 +737,12 @@ describe('createFixtureApi', () => {
expect(frames[0]).toMatchObject({
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
})
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
const added = frames[1]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(added).toEqual({
type: 'host/session-added', sessionId: preallocated, blank: true,
cwd: made.result.value.workspace.path,
})
const retried = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,

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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d
README.zh.md: ce8117fc4c95071a6db8592302030a8a63b5478b
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294

View File

@@ -16,7 +16,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.

View File

@@ -16,7 +16,7 @@
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。
@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。

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'
@@ -21,9 +21,11 @@ export interface IWorkspaces {
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* The New Session flow: connect the target (or recent) Workspace and open
* the resulting session; failures surface on the session list state.
* @param workspaceId - explicit target; omitted uses the recency projection.
* The New Session flow: connect the explicit, current-Session, or recent
* Workspace and open the resulting session; failures surface on the session
* list state.
* @param workspaceId - explicit target; omitted inherits the current
* Session's Workspace before falling back to the recency projection.
*/
startSession(workspaceId?: WorkspaceId): void
/**
@@ -68,6 +70,12 @@ export interface IWorkspaces {
* @param workspaceId - target workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move a Workspace within the registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
/**
* Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace.

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:
@@ -71,6 +72,7 @@ type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
| { kind: 'activity'; sessionId: SessionId; updatedAt: number }
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
@@ -164,6 +166,7 @@ export class SessionManager {
*/
constructor(
private readonly api: IApiClient,
private readonly remote: SessionRemotes,
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
private readonly conversation?: ConversationRuntime,
@@ -304,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,
@@ -680,6 +683,16 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (
frame.type === 'session/event'
&& frame.event.type === 'user/message'
&& frame.event.data.source.kind === 'user'
) {
// session.list supplies the cold baseline, while a direct prompt or an
// admitted steer advances it between pulls. Max keeps replayed or
// repaired older user messages from moving the row backwards.
this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time })
}
if (frame.type === 'session/projection') {
// Finished host-computed value: land it in the resident store whether or
// not the Session is instantiated (list rows read the 'title' key). The
@@ -1099,6 +1112,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
: summary)
case 'activity':
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& mutation.updatedAt > summary.updatedAt
? { ...summary, updatedAt: mutation.updatedAt }
: summary)
case 'engaged':
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
? { ...summary, blank: 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,9 +2,8 @@
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 { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts'
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
@@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot {
type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId }
| { type: 'order'; workspaceIds: readonly WorkspaceId[] }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
@@ -51,6 +51,12 @@ export class WorkspaceManager {
* mirror of replaying refreshFrames over the item baseline.
*/
private archivedSupersedesRefresh = false
/** Latest local reorder request; only its unary echo may install order. */
private orderRequestGeneration = 0
/** Increments on order frames so a later remote commit outranks an older unary echo. */
private orderFrameGeneration = 0
/** Last complete order accepted from a Host baseline, frame, or current unary echo. */
private committedOrder: WorkspaceId[] = []
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -72,16 +78,15 @@ export class WorkspaceManager {
/**
* Refresh from workspace.list. The first successful response establishes
* Host order; later responses update membership and values without moving
* identities already visible to the client. Frames arriving during the RPC
* are replayed over its response.
* Host order; later responses re-establish the durable order so reconnects
* adopt reorders committed while this client was offline. Frames arriving
* during the RPC are replayed over its response.
* @returns the shared in-flight refresh.
*/
refresh(): Promise<void> {
if (this.inflight !== null) return this.inflight
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceDelta[] = []
this.refreshFrames = frames
this.notifier.markDirty()
@@ -89,9 +94,7 @@ export class WorkspaceManager {
try {
const { result } = await this.api.workspace.list({})
if (result.ok) {
let items = this.phase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
let items = result.value.items
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
@@ -157,6 +160,44 @@ export class WorkspaceManager {
return result
}
/**
* Move a Workspace within the registry display order and install the full
* returned order without waiting for the Host frame.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
* @returns the wire result.
*/
async insertBefore(
workspaceId: WorkspaceId,
beforeWorkspaceId?: WorkspaceId,
): Promise<RpcResult<{ workspaceIds: WorkspaceId[] }>> {
const requestGeneration = ++this.orderRequestGeneration
const frameGeneration = this.orderFrameGeneration
const localOrder = this.itemViews().map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
try {
;({ result } = await this.api.workspace.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
}))
} catch (error) {
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(this.committedOrder)
}
throw error
}
if (result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.value.workspaceIds, true)
} else if (!result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(this.committedOrder)
}
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
@@ -198,6 +239,10 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/workspace-order-changed') {
this.orderFrameGeneration++
this.installOrder(envelope.payload.workspaceIds, true)
}
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
}
@@ -249,6 +294,24 @@ export class WorkspaceManager {
this.notifier.markDirty()
}
/** Reorder known Workspace objects, optionally recording a Host-committed sequence. */
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
if (committed) {
this.refreshFrames?.push({ type: 'order', workspaceIds })
this.committedOrder = [...workspaceIds]
}
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
const items = [...this.items].sort((left, right) => {
const leftId = left.getSnapshot().view?.workspaceId
const rightId = right.getSnapshot().view?.workspaceId
return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
- (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
})
if (items.every((item, index) => item === this.items[index])) return
this.items = items
this.notifier.markDirty()
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return
@@ -259,6 +322,9 @@ export class WorkspaceManager {
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (!this.committedOrder.includes(view.workspaceId)) {
this.committedOrder = [view.workspaceId, ...this.committedOrder]
}
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
@@ -276,6 +342,7 @@ export class WorkspaceManager {
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
@@ -309,6 +376,7 @@ export class WorkspaceManager {
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
this.committedOrder = views.map(view => view.workspaceId)
}
private itemViews(): readonly WorkspaceView[] {
@@ -332,7 +400,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
return delta.type === 'upsert'
? upsertWorkspace(items, delta.workspace)
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace)
if (delta.type === 'remove') {
return items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
}
const rank = new Map(delta.workspaceIds.map((id, index) => [id, index]))
return [...items].sort((left, right) =>
(rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER)
- (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER))
}
/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */
function insertIdBefore(
ids: readonly WorkspaceId[],
id: WorkspaceId,
beforeId?: WorkspaceId,
): WorkspaceId[] {
if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) {
return [...ids]
}
const without = ids.filter(candidate => candidate !== id)
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
return [...without.slice(0, at), id, ...without.slice(at)]
}

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'
@@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces {
/**
* The shared New Session action behind the shell entry points (sidebar
* button, workspace browser): resolve the target Workspace — explicit wins,
* else the recent-Workspace projection — connect its blank session and
* navigate there; with no Workspace at all, clear the selection into the
* New Session view state. Connect failures are non-fatal (console
* diagnostics; the current view stays usable).
* then the current Session's Workspace, then the recent-Workspace
* projection — connect its blank session and navigate there; with no
* Workspace at all, clear the selection into the New Session view state.
* Connect failures are non-fatal (console diagnostics; the current view
* stays usable).
* @param workspaceId - explicit target Workspace for scoped actions.
*/
startSession(workspaceId?: WorkspaceId): void {
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
const workspace = this.list.getSnapshot()
const current = this.sessions.list.getSnapshot().current
const currentWorkspaceId = current === undefined
? undefined
: workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId
if (target === undefined) {
this.sessions.clear()
return
@@ -265,6 +271,16 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a Workspace within the durable registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId)
if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local

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 }[] = []
@@ -181,6 +195,9 @@ export class FakeApiClient implements IApiClient {
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(ok({ workspaceIds: [] }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
@@ -196,6 +213,8 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertBefore: (payload: unknown) =>
this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
archiveSession: (payload: unknown) =>
@@ -205,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, 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,
@@ -113,10 +113,50 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
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, fakeRemote())
await manager.refreshList()
// Both a new prompt and an admitted steer land as a user-sourced message.
const activity = { ...ev.user(10, 'new'), time: 500 }
manager.handleMuxEnvelope({
rpcId: 'activity' as never,
payload: { type: 'session/event', sessionId: S1, event: activity },
})
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
manager.handleMuxEnvelope({
rpcId: 'older' as never,
payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } },
})
manager.handleMuxEnvelope({
rpcId: 'assistant' as never,
payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } },
})
const injected = ev.user(12, 'context')
if (injected.type !== 'user/message') throw new Error('user builder returned another event type')
manager.handleMuxEnvelope({
rpcId: 'injected' as never,
payload: {
type: 'session/event',
sessionId: S1,
event: {
...injected,
time: 700,
data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } },
},
},
})
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
})
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.
@@ -125,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')
@@ -144,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])
@@ -152,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,
@@ -179,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,
@@ -202,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 })
@@ -230,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({
@@ -246,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',
@@ -269,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)
@@ -303,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' })
@@ -368,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()
@@ -418,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({
@@ -447,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({
@@ -488,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({
@@ -529,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({
@@ -547,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)
@@ -566,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
@@ -624,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
@@ -671,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 })
@@ -690,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()
@@ -707,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' })
@@ -727,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 })])
@@ -741,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({
@@ -754,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([])
@@ -775,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()
@@ -790,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 })
@@ -805,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 } })
@@ -821,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,
@@ -845,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
@@ -863,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()
@@ -876,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' } })
@@ -889,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,
@@ -922,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,
@@ -939,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({
@@ -958,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')
@@ -973,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' } })
@@ -1000,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)
@@ -1014,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)
@@ -1029,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)
@@ -1044,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)
@@ -1060,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)
@@ -1072,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)
@@ -1085,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.
@@ -1099,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
@@ -1120,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
@@ -1133,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, []))
@@ -1141,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,
@@ -1151,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 } })
@@ -1159,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()

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