Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

The Client API carrier's `agentPresets` member was the one member of its class
without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry`
into the emitted declaration by the specifier TS picks — the host `index.ts` —
dragging the whole gateway, and with it the host `Context` merges, into every
Client program importing the carrier. Annotated like its siblings.

`ApiRemoteAgentOptions.setup` now takes the inspected session rather than its
header alone: this layer resolves a resumed session's preset from the LOG,
because a session that switched while blank ran its turns under the newer
composition and the header is written once at creation.

Conflicts:
	apps/web/tests/snapshots/*/*.expected.md
	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
	packages/host/apiproxy/src/api-proxy.ts
	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-08 15:00:31 +08:00
649 changed files with 21091 additions and 2838 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-api-remotes
English | [中文](README.zh.md)
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
`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.
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.
## Build boundary
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
## Model Experience
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
#### KV Cache effect
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
## Known Limitations and Deferred Work
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-api-remotes
[English](README.md) | 中文
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
`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。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
## 构建边界
仓库中的普通包只属于一个 TypeScript faceHost 包登记在根 `tsconfig.host.json`Client 包登记在根 `tsconfig.client.json``api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json``tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle不得因一个包同时存在 `src/index.ts``src/client/index.ts` 就复制本包的拆分。
## 模型体验
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
#### KV Cache 影响
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
## 已知限制与暂缓事项
- 能力集合由构建时显式导入的值固定确定Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。

View File

@@ -0,0 +1,64 @@
{
"name": "@deepseek-ai/dsh-api-remotes",
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,211 @@
/** Host BFF policy for resolving Remote Agent and Session identities. */
import type { Context } from 'cordis'
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import type {} from '@deepseek-ai/dsh-typert-registry'
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
export type ApiRemoteLookupError =
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
/** Result of resolving one session identity to its live Agent. */
export type ApiRemoteAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiRemoteLookupError }
/** Resume configuration supplied by the owning Host composition. */
export interface ApiRemoteAgentOptions {
/** Read the per-Agent defaults when a cold identity must resume. */
readonly agentOptions?: () => AgentOptions
/**
* Build the Host-specific Agent-scope composition completed before
* publication. Keyed by the resumed session itself because what a Host
* installs may depend on what that session recorded: an agent preset fixes
* the tools its history was produced under, so rebuilding it under another
* composition would replay tool calls the agent can no longer make. The
* events come along because a session's own record of such a choice may be
* an event rather than a header field.
* @param session - the resumed session's persisted header and event log.
* @returns the Agent-scope setup to run before publication.
*/
readonly setup?: (
session: { meta: SessionHeader; events: readonly SessionEvent[] },
) => AgentSetup | Promise<AgentSetup>
}
/** Cold identity absent from the durable session store. */
export class ApiRemoteSessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing. */
export class ApiRemoteSubagentSessionOwnership extends Error {
/**
* Construct the ownership fence.
* @param sessionId - identity reserved to subagent routing.
*/
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/**
* Test whether generic Host routing must leave an identity to subagent routing.
* @param ctx - Host Context carrying the live Agent registry.
* @param session - attached or live Session metadata.
* @param agent - live Agent when one is registered.
* @returns whether generic Remote and legacy API calls must reject the identity.
*/
export function hasApiRemoteSubagentOwner(
ctx: Context,
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/**
* Build the stable caller-facing ownership rejection.
* @param sessionId - identity reserved to subagent routing.
* @returns the existing `agent-busy` RPC shape.
*/
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/**
* Inspect one cold served session without repairing, resuming, or publishing it.
* @param ctx - Host Context carrying the optional persistence provider.
* @param sessionId - durable identity to inspect.
* @returns detached metadata and events for a servable session.
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
*/
export async function inspectApiRemoteSession(
ctx: Context,
sessionId: SessionId,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
if (meta === undefined || meta.cwd === undefined) {
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
}
const inspected = await persistence.inspect(sessionId)
if (inspected.meta.cwd === undefined) {
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: inspected.meta, events: [...inspected.events] }
}
/**
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
* Live Agents are reused, ordinary cold sessions resume once per identity, and
* subagent-owned identities retain the legacy `agent-busy` fence.
* @param ctx - owning Host Context.
* @param options - defaults and Agent-scope setup used only for cold resume.
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
*/
export function createApiRemoteAgentResolver(
ctx: Context,
options: ApiRemoteAgentOptions,
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
const resumes = new Map<SessionId, Promise<Agent>>()
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
const live = ctx.agents.get(sessionId)
if (live === undefined) return undefined
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
return { agent: live }
}
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
const inspected = await inspectApiRemoteSession(ctx, sessionId)
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
throw new ApiRemoteSubagentSessionOwnership(sessionId)
}
// Built from the inspected session before the published re-checks
// below, so those stay adjacent to `resume` and a Host setup that
// awaits (composing a preset, say) does not widen the collision
// window.
const setup = options.setup === undefined ? undefined : await options.setup(inspected)
const publishedSession = ctx.sessions.get(sessionId)
const publishedAgent = ctx.agents.get(sessionId)
if (publishedSession !== undefined
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
throw new ApiRemoteSubagentSessionOwnership(sessionId)
}
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
...setup === undefined ? {} : { setup },
})
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof ApiRemoteSessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
if (error instanceof ApiRemoteSubagentSessionOwnership) {
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
}
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
return {
error: {
code: 'internal',
message: `resume failed for session "${sessionId}": ${String(error)}`,
details: {},
},
}
}
}
ctx.inject(['typert'], (typeCtx) => {
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
const found = await agentFor(sessionId)
if ('error' in found) throw new TypeRTLookupFailure(found.error)
return found.agent
}
typeCtx.typert.lookups.configure('agent', resolveAgent)
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
})
return agentFor
}

View File

@@ -0,0 +1,27 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from 'cordis'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-goal/remote'
declare module 'cordis' {
interface Context {
/** Generated Remote namespaces selected by this Client assembly. */
remote: TypeRTClientRemote
}
}
/** Required service: the typed Client Remote contribution mount. */
export const inject = ['remote']
/**
* Mount the Host capabilities explicitly selected for this Client assembly.
* @param ctx - Client Cordis root carrying the typed API service.
* @returns disposer after every selected Remote namespace is ready.
*/
export async function apply(ctx: Context): Promise<() => Promise<void>> {
return await ctx.remote.$mount(goalsRemote)
}

View File

@@ -0,0 +1,18 @@
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
export {
ApiRemoteSessionNotFound,
ApiRemoteSubagentSessionOwnership,
apiRemoteSubagentOwnershipError,
createApiRemoteAgentResolver,
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from './agent-lookup.ts'
export type {
ApiRemoteAgentOptions,
ApiRemoteAgentResult,
ApiRemoteLookupError,
} from './agent-lookup.ts'
/** Host plugin body; the selected contributions mount only in Client environments. */
export function apply(): void {}

View File

@@ -0,0 +1,24 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
/** Cordis companion plugin name. */
export const name = 'api-remotes-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
const sid = (value: string): SessionId => value as SessionId
function header(id: SessionId): SessionHeader {
return { version: 0, id, createdAt: 1, cwd: '/proj' }
}
async function createContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
return ctx
}
function provideSession(
ctx: Context,
meta: SessionHeader,
inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>,
): void {
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
}
function stubAgent(ctx: Context, session: Session): Agent {
return { id: session.id, session, status: 'idle', ctx } as Agent
}
describe('API Remote Agent resolver races', () => {
it('maps an inspected session without a cwd to session-not-found', async () => {
const ctx = await createContext()
const sessionId = sid('missing-after-inspect')
const meta = header(sessionId)
provideSession(ctx, meta, () => Promise.resolve({
meta: { ...meta, cwd: undefined } as unknown as SessionHeader,
events: [],
}))
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } })
await ctx.fiber.dispose()
})
it('resumes through a concurrently attached ordinary Session without optional defaults', async () => {
const ctx = await createContext()
const sessionId = sid('ordinary-attach-race')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() }
})
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ agent: { id: sessionId } })
expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId })
await ctx.fiber.dispose()
})
it('rejects a subagent Session published after durable inspection', async () => {
const ctx = await createContext()
const sessionId = sid('owned-attach-race')
const meta = header(sessionId)
provideSession(ctx, meta, () => {
ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
return Promise.resolve({ meta, events: [] })
})
const resume = vi.spyOn(ctx.agents, 'resume')
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
expect(resume).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('reclassifies failed resumes after a live or attached subagent wins publication', async () => {
for (const winner of ['agent', 'session'] as const) {
const ctx = await createContext()
const sessionId = sid(`owned-${winner}-resume-race`)
const meta = header(sessionId)
provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] }))
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session))
throw new Error('session id already published')
})
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
await ctx.fiber.dispose()
}
})
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-cold-resume')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const agentCtx = ctx.extend()
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
})
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
await ctx.fiber.dispose()
})
it('applies the subagent ownership fence to the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-owned-subagent')
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
ctx.agents.register(stubAgent(ctx.extend(), session))
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
const resolution = provider.resolve(sessionId)
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,224 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* Built-artifact smoke for the first generated Remote: plain Node boots the
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
*/
const packageDir = fileURLToPath(new URL('..', import.meta.url))
const root = resolve(packageDir, '../../..')
const artifact = (path: string): string => join(root, path)
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
const requiredArtifacts = [
'packages/client/connection/lib/client.js',
'packages/client/connection/lib/index.js',
'packages/api/remotes/lib/client.js',
'packages/core/agent/lib/index.js',
'packages/core/session/lib/index.js',
'packages/goal/goal/lib/index.js',
'packages/goal/goal/lib/typert.host.js',
'packages/api/gateway/lib/client.js',
'packages/api/gateway/lib/index.js',
'packages/typert/registry/lib/client.js',
'packages/typert/registry/lib/index.js',
].every(path => existsSync(artifact(path)))
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
const urls = Object.fromEntries(Object.entries({
agent: 'packages/core/agent/lib/index.js',
apiGatewayClient: 'packages/api/gateway/lib/client.js',
apiGatewayHost: 'packages/api/gateway/lib/index.js',
connectionClient: 'packages/client/connection/lib/client.js',
connectionHost: 'packages/client/connection/lib/index.js',
goal: 'packages/goal/goal/lib/index.js',
goalTypert: 'packages/goal/goal/lib/typert.host.js',
registryClient: 'packages/typert/registry/lib/client.js',
registryHost: 'packages/typert/registry/lib/index.js',
remotesClient: 'packages/api/remotes/lib/client.js',
session: 'packages/core/session/lib/index.js',
}).map(([key, path]) => [key, artifactUrl(path)]))
const script = `
import { createServer } from 'node:http'
import * as cordis from 'cordis'
const urls = ${JSON.stringify(urls)}
const { Context } = cordis
const { default: AgentRegistry } = await import(urls.agent)
const connectionHost = await import(urls.connectionHost)
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
const { default: GoalService } = await import(urls.goal)
const { TYPERT } = await import(urls.goalTypert)
const { default: TypertRegistry } = await import(urls.registryHost)
const { Session, SessionId } = await import(urls.session)
const routes = []
const host = new Context()
host.provide('httpServer', {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex() { return () => {} },
port: 0,
})
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
await host.plugin(TypertRegistry)
await host.plugin(AgentRegistry)
await host.plugin(TypertGatewayService)
await host.plugin(GoalService)
host.typert.register(TYPERT)
const makeAgent = rawId => {
const session = new Session(SessionId(rawId))
return {
id: session.id,
options: {},
session,
ctx: host.extend(),
status: 'idle',
acceptsNextStep: false,
send() {},
updateInbox() { return 'not-found' },
followup() {},
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
reserveTurnAdmission() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
}
const rootAgent = makeAgent('built-root-agent')
const scopedAgent = makeAgent('built-scoped-agent')
host.agents.register(rootAgent)
host.agents.register(scopedAgent)
if (routes.length !== 1 || routes[0].path !== '/api') {
throw new Error('Connection did not register exactly one /api route')
}
const server = createServer((request, response) => { void routes[0].handler(request, response) })
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
const origin = 'http://127.0.0.1:' + String(address.port)
const handoffs = new Map()
globalThis.window = {
__ModuleLoader__: {
load(handoff) { handoffs.set(handoff.id, handoff) },
},
}
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
await import(urls.registryClient)
await import(urls.connectionClient)
await import(urls.apiGatewayClient)
await import(urls.remotesClient)
const instantiate = id => {
const handoff = handoffs.get(id)
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
return handoff.factory(specifier => {
if (specifier === 'cordis') return cordis
throw new Error('unexpected Client external ' + specifier)
})
}
const client = new Context()
for (const id of [
'@deepseek-ai/dsh-typert-registry',
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-api-gateway',
'@deepseek-ai/dsh-api-remotes',
]) {
const plugin = instantiate(id)
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
}
client.typert.contexts.registerClient('agent', {
identity: candidate => candidate.builtAgentId,
})
let invalidRejected = false
try {
await client.remote.goals.create(rootAgent.id, { objective: 1 })
} catch {
invalidRejected = true
}
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.remote.goals.edit(
rootAgent.id,
rootResult.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,
rootGoal: host.goals.get(rootAgent)?.objective,
scopedGoal: host.goals.get(scopedAgent)?.objective,
rootEvents: rootAgent.session.events.length,
scopedEvents: scopedAgent.session.events.length,
}
await client.fiber.dispose()
await new Promise((resolveClose, rejectClose) => server.close(error => {
if (error === undefined) resolveClose()
else rejectClose(error)
}))
await host.fiber.dispose()
console.log(JSON.stringify(result))
`
const result = await runPlainNode(script)
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
invalidRejected: boolean
rootResult: { ref: { id: string; revision: number } }
rootEdit: { objective: string; revision: number }
scopedResult: { ref: { id: string; revision: number } }
rootGoal: string
scopedGoal: string
rootEvents: number
scopedEvents: number
}
expect(output).toMatchObject({
invalidRejected: true,
rootResult: { ref: { revision: 1 } },
rootEdit: { objective: 'edited root goal', revision: 2 },
scopedResult: { ref: { revision: 1 } },
rootGoal: 'edited root goal',
scopedGoal: 'scoped goal',
rootEvents: 2,
scopedEvents: 1,
})
expect(output.rootResult.ref.id).toMatch(/^goal-/)
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
}, 60_000)
})
/** Execute one ESM script without tsx or a TypeScript loader. */
function runPlainNode(script: string): Promise<{
readonly exitCode: number | null
readonly stdout: string
readonly stderr: string
}> {
return new Promise((resolveRun) => {
execFile(process.execPath, ['--input-type=module', '-e', script], {
cwd: packageDir,
encoding: 'utf8',
timeout: 55_000,
}, (error, stdout, stderr) => {
resolveRun({
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
stdout,
stderr,
})
})
})
}

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": "../../goal/goal"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/registry"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.host.json"
},
{
"path": "./tsconfig.client.json"
}
]
}

View File

@@ -0,0 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(
'@deepseek-ai/dsh-api-remotes',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)