Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	apps/web/tests/assembled-boot.ts
#	apps/web/tests/settings-chrome.e2e.ts
#	docs/module-graph.md
#	packages/client/runtime/tsconfig.json
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 00:21:45 +08:00
345 changed files with 14916 additions and 1478 deletions

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/connection/README.md
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -16,12 +16,13 @@
import type { IncomingHttpHeaders } from 'node:http'
import { isLoopbackHostname } from './loopback-hostname.ts'
/** The request facts the fence reads (structural subset of IncomingMessage). */
/** The request facts the fence reads from either HTTP representation. */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
headers: IncomingHttpHeaders | Headers
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param request - Node HTTP or Fetch request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/

View File

@@ -35,10 +35,12 @@ import type {
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
import { randomUuid } from './random-uuid.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(crypto.randomUUID()), payload }
return { rpcId: RpcId(randomUuid()), payload }
}
function text(t: string): ContentBlock[] {
@@ -1328,6 +1330,16 @@ class FxInbox<F> implements StreamConn<F> {
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return createFixtureWorld(options).api
}
interface FixtureWorld {
readonly api: ApiProxy
readonly rpc: ClientConnectionRpc
}
/** 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.
const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
@@ -1506,31 +1518,141 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return backscanGoal(log) as FxGoalProjection
}
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
const fxMutateGoal = (
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
ref: { id: string; revision: number },
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
const missing = requireSession(request)
type FxGoalRef = { id: string; revision: number }
type FxGoalView = FxGoalProjection['goal'] & {
roundsStarted: number
createdAt: number
updatedAt: number
activation: 'armed' | 'disarmed'
}
const goalFailure = <T>(message: string): RpcResult<T> => ({
ok: false,
error: { code: 'internal', message, details: {} },
})
const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
summaryOf(id) === undefined
? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
: undefined
)
const goalView = (projection: FxGoalProjection): FxGoalView => ({
...projection.goal,
roundsStarted: projection.roundsStarted,
createdAt: projection.createdAt,
updatedAt: projection.updatedAt,
activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed',
})
/** Canonical fixture implementation of the generated Goal Remote contract. */
const goalRemotes = {
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
const current = backscanGoal(logOf(id))
if (current !== null && current.goal.phase !== 'complete') {
return goalFailure(`goal "${current.goal.id}" already exists`)
}
const now = Date.now()
const projection = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: {
id: `fx-goal-${logOf(id).length}`,
revision: 1,
objective: request.objective,
phase: 'active',
maxGoalRounds: request.maxGoalRounds ?? 256,
},
roundsStarted: 0, createdAt: now, updatedAt: now,
})
return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } }
},
edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult<FxGoalView> {
return mutateGoal(id, ref, current => ({
...current.goal,
revision: current.goal.revision + 1,
...request.objective === undefined ? {} : { objective: request.objective },
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds },
}))
},
pause(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
return mutateGoal(id, ref, current => (
current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
: undefined
))
},
resume(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
return mutateGoal(id, ref, current => (
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
: undefined
))
},
complete(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
return mutateGoal(id, ref, current => (
current.goal.phase === 'complete'
? undefined
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
))
},
clear(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalRef> {
const resolved = resolveGoal(id, ref)
if (!resolved.ok) return resolved
const current = resolved.value
const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 }
appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(),
})
return { ok: true, value: tombstone }
},
}
/** Resolve one current goal revision for a canonical Remote mutation. */
function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalProjection> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
return goalFailure('stale or missing goal revision')
}
return { ok: true, value: current }
}
/** Shared CAS mutation path behind the canonical Remote verbs. */
function mutateGoal(
id: SessionId,
ref: FxGoalRef,
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
): RpcResult<FxGoalView> {
const resolved = resolveGoal(id, ref)
if (!resolved.ok) return resolved
const current = resolved.value
const goal = next(current)
if (goal === undefined) {
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
return goalFailure(`invalid goal transition from "${current.goal.phase}"`)
}
const projection = appendGoalChange(id, {
kind: 'goal/change', version: 1,
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
})
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
return { ok: true, value: goalView(projection) }
}
const mapGoalResult = <T, U>(result: RpcResult<T>, map: (value: T) => U): RpcResult<U> => (
result.ok ? { ok: true, value: map(result.value) } : result
)
const goalRefResult = (result: RpcResult<FxGoalView>): RpcResult<{ ref: { id: never; revision: number } }> => (
mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } }))
)
const legacyGoalResponse = <P, T>(request: RpcRequest<P>, result: RpcResult<T>): Promise<RpcResponse<T>> => (
Promise.resolve({ rpcId: request.rpcId, result })
)
/** At most one in-flight replay per session; cancel clears it. */
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
@@ -1776,7 +1898,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.set(id, { timer: setTimeout(tick, 80), finish })
}
return {
const api: ApiProxy = {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
search: (request, signal) => {
@@ -2333,60 +2455,44 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
goals: {
// Mutation-only mirror of the host handlers: each verb CAS-checks the
// projected current goal, appends the whole-value change (the mux
// stream and projection frame ride the shared append path), and
// acknowledges with the new ref only.
create: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current !== null && current.goal.phase !== 'complete') {
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
}
const projection = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
},
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
...current.goal,
revision: current.goal.revision + 1,
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
})),
pause: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
: undefined
)),
resume: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
: undefined
)),
complete: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'complete'
? undefined
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
)),
clear: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
}
appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'clear',
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
})
return ok(request, { cleared: true as const })
},
// Compatibility face only: old API Proxy payloads and acknowledgements
// adapt to the canonical fixture Remote implementation above.
create: request => legacyGoalResponse(
request,
mapGoalResult(
goalRemotes.create(request.payload.sessionId, {
objective: request.payload.objective,
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
}),
value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }),
),
),
edit: request => legacyGoalResponse(
request,
goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, {
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
})),
),
pause: request => legacyGoalResponse(
request,
goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)),
),
resume: request => legacyGoalResponse(
request,
goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)),
),
complete: request => legacyGoalResponse(
request,
goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)),
),
clear: request => legacyGoalResponse(
request,
mapGoalResult(
goalRemotes.clear(request.payload.sessionId, request.payload.ref),
() => ({ cleared: true as const }),
),
),
},
events: {
async *mux(_request, signal) {
@@ -2547,6 +2653,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return Promise.resolve({ accepted: true })
},
}
const rpc: ClientConnectionRpc = {
call(channel, endpoint, payload) {
if (channel !== '/api') {
return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`))
}
const args = (payload as {
args: {
agentId: SessionId
ref?: { id: string; revision: number }
request?: { objective?: string; maxGoalRounds?: number }
}
}).args
const sessionId = args.agentId
switch (endpoint) {
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
objective: args.request?.objective as string,
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
}))
case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {}))
case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef))
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
default:
return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
}
},
}
return { api, rpc }
}
/**
@@ -2558,10 +2694,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
*/
export class FixtureApiClient extends AbstractApiClient {
private readonly api: ApiProxy
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
readonly rpc: ClientConnectionRpc
constructor() {
super()
this.api = createFixtureApi(fixtureOptionsFromLocation())
const world = createFixtureWorld(fixtureOptionsFromLocation())
this.api = world.api
this.rpc = world.rpc
}
protected doFetch(): Promise<Response> {

View File

@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { createWebConnectionRpc } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -36,6 +38,7 @@ export {
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
/** Required services (none — this is the wire root). */
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -69,11 +74,14 @@ export interface ConnectionHandle {
export function apply(ctx: Context): void {
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
const fixtureClient = fixture ? new FixtureApiClient() : undefined
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
rpc,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -0,0 +1,14 @@
/** Browser-safe UUID generation for client-side wire correlation. */
/**
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
*/
export function randomUuid(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

View File

@@ -0,0 +1,63 @@
/** Browser caller for generic Connection unary RPC channels. */
import {
RpcId,
serverResponseSchema,
type ClientRequest,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ClientConnectionRpc } from '../rpc.ts'
import { randomUuid } from './random-uuid.ts'
const INTERNAL_BASE = 'http://dsh.internal'
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
/**
* Create the browser-backed generic RPC caller.
* @returns caller that owns request correlation and response-envelope validation.
*/
export function createWebConnectionRpc(): ClientConnectionRpc {
return {
async call(channel, endpoint, payload, signal) {
assertTarget(channel, endpoint)
const rpcId = RpcId(randomUuid())
const message: ClientRequest = {
type: 'client-request',
rpcId,
method: endpoint,
payload,
}
const response = await globalThis.fetch(
new URL(`${channel}/${endpoint}`, resolveBase()),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(message),
...signal === undefined ? {} : { signal },
},
)
if (!response.ok) {
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
}
const full = serverResponseSchema.parse(await response.json())
if (full.rpcId !== rpcId) {
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
}
return full.result
},
}
}
function resolveBase(): string {
const location = (globalThis as { location?: { origin?: string } }).location
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
}
function assertTarget(channel: string, endpoint: string): void {
const segments = endpoint.split('/')
if (!CHANNEL_PATTERN.test(channel)
|| segments.some(segment =>
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
}
}

View File

@@ -5,6 +5,16 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Transport-independent request handler consumed by the Host HTTP bridge. */
export interface FetchHandler {
/**
* Handle one standard Fetch request.
* @param request - request produced by the active transport bridge.
* @returns complete or streaming Fetch response.
*/
fetch(request: Request): Promise<Response>
}
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
@@ -12,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is

View File

@@ -7,15 +7,26 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { HostConnectionService } from './rpc-host.ts'
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
export type {
ConnectionRpcAuthority,
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
HostConnectionHandle,
HostConnectionRpc,
} from './rpc.ts'
export { HostConnectionService } from './rpc-host.ts'
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy']
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
@@ -83,49 +94,61 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
const connection = new HostConnectionService(ctx, trustedHosts)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
const pathname = new URL(request.url).pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
if (method !== undefined
&& PRIVILEGED_METHODS.has(method)
&& !isTrustedApiRequest(request, [])) {
return new Response('forbidden', { status: 403 })
}
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
return new Response('upgrade required', {
status: 426,
headers: { connection: 'Upgrade', upgrade: 'websocket' },
})
}
const apiProxy = ctx.get('apiProxy')
if (apiProxy === undefined) return new Response('not found', { status: 404 })
return toFetchHandler(apiProxy).fetch(request)
},
})
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
? isTrustedApiRequest(req, [])
: isTrustedApiRequest(req, trustedHosts)
if (!allowed) {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
res.end('upgrade required')
return
}
await bridge(req, res, apiHandler)
await bridge(req, res, fetchHandler)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
const registerDownlink = (
path: string,
handle: WebUpgradeRoute['handler'],
): void => {
ctx.effect(() => ctx.httpServer.registerUpgrade({
path,
handler: (req, socket, head) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
rejectWebSocketUpgrade(socket)
return
}
return handle(req, socket, head)
},
}), `client-connection: ${path} WebSocket`)
}
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
ctx.inject(['apiProxy'], (apiCtx) => {
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
const registerDownlink = (
path: string,
handle: WebUpgradeRoute['handler'],
): void => {
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
path,
handler: (req, socket, head) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
rejectWebSocketUpgrade(socket)
return
}
return handle(req, socket, head)
},
}), `client-connection: ${path} WebSocket`)
}
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
})
}

View File

@@ -0,0 +1,224 @@
/** Host registry and HTTP adapter for generic Connection RPC channels. */
import { Context, Service } from 'cordis'
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import {
clientRequestSchema,
RpcId,
type ClientRequest,
type RpcError,
type RpcErrorDetailsMap,
type RpcId as RpcIdType,
type ServerResponse as RpcServerResponse,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { bridge, type FetchHandler } from './http-bridge.ts'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { API_PATH } from './api-path.ts'
import type {
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
HostConnectionHandle,
HostConnectionRpc,
} from './rpc.ts'
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
interface ConnectionRpcInterceptor {
readonly matches: ConnectionRpcEndpointMatcher
readonly fetchHandler: FetchHandler
readonly options: ConnectionRpcHandlerOptions
}
declare module 'cordis' {
interface Context {
/** Host Connection transport and RPC registrations. */
connection: HostConnectionHandle
}
}
/** Host Connection service whose channel registrations belong to the caller fiber. */
export class HostConnectionService extends Service implements HostConnectionHandle {
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
/**
* Provide the Host half over the active HTTP server.
* @param ctx - owning Connection plugin context.
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
*/
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
super(ctx, 'connection')
}
/** Generic channel registry scoped to the Context reading this service. */
get rpc(): HostConnectionRpc {
const owner = this.ctx
return {
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
intercept: (channel, matches, handler, options) =>
this.registerInterceptor(owner, channel, matches, handler, options),
}
}
/**
* Compose one shared-channel Fetch handler from its interceptor and fallback.
* @param channel - shared channel mounted by Connection.
* @param fallback - handler for endpoints not claimed by the interceptor.
* @returns Fetch handler that selects exactly one target for each request.
*/
createSharedFetchHandler(
channel: '/api',
fallback: FetchHandler,
): FetchHandler {
return {
fetch: (request) => {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
const interceptor = this.interceptors.get(channel)
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
return fallback.fetch(request)
}
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
return Promise.resolve(new Response('forbidden', { status: 403 }))
}
return interceptor.fetchHandler.fetch(request)
},
}
}
private register(
owner: Context,
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
assertChannel(channel)
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
const fetchHandler = rpcFetchHandler(channel, handler)
const route: WebRoute = {
kind: 'prefix',
path: channel,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
await bridge(req, res, fetchHandler)
},
}
return owner.effect(
() => owner.httpServer.register(route),
`client-connection: ${channel} rpc channel`,
)
}
private registerInterceptor(
owner: Context,
channel: string,
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
if (channel !== API_PATH) {
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
}
const interceptor: ConnectionRpcInterceptor = {
matches,
fetchHandler: rpcFetchHandler(channel, handler),
options,
}
return owner.effect(() => {
if (this.interceptors.has(channel)) {
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
}
this.interceptors.set(channel, interceptor)
return () => {
this.interceptors.delete(channel)
}
}, `client-connection: ${channel} rpc interceptor`)
}
}
function rpcFetchHandler(
channel: string,
handler: ConnectionRpcHandler,
): FetchHandler {
return {
async fetch(request: Request): Promise<Response> {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
if (request.method !== 'POST' || endpoint === undefined) {
return new Response('not found', { status: 404 })
}
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await request.json()
} catch {
return new Response('body is not JSON', { status: 400 })
}
const envelope = clientRequestSchema.safeParse(body)
if (!envelope.success) {
return invalidEnvelopeResponse(body, envelope.error.issues)
}
const message: ClientRequest = envelope.data
if (message.method !== endpoint) {
return errorResponse(message.rpcId, {
code: 'bad-request',
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
details: { issues: [] },
})
}
try {
const result = await handler(endpoint, message.payload, request.signal)
return fullResponse(message.rpcId, result)
} catch (error) {
return new Response(`handler failure: ${String(error)}`, { status: 500 })
}
},
}
}
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
return errorResponse(rpcId, {
code: 'bad-request',
message: 'invalid client-request message',
details: { issues },
})
}
function endpointFromPath(channel: string, pathname: string): string | undefined {
if (!pathname.startsWith(`${channel}/`)) return undefined
const endpoint = pathname.slice(channel.length + 1)
const segments = endpoint.split('/')
if (segments.some(segment =>
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
return undefined
}
return endpoint
}
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
return fullResponse(rpcId, { ok: false, error })
}
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
return Response.json(body)
}
function assertChannel(channel: string): void {
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
}
}

View File

@@ -0,0 +1,77 @@
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/** Trust fence applied before a Host RPC channel reaches its handler. */
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
/** Registration policy for one logical RPC channel. */
export interface ConnectionRpcHandlerOptions {
/** Browser authority accepted by every endpoint in this channel. */
readonly authority: ConnectionRpcAuthority
}
/** Handler invoked after Connection has decoded the transport envelope. */
export type ConnectionRpcHandler = (
endpoint: string,
payload: unknown,
signal: AbortSignal,
) => Promise<RpcResult<unknown>>
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
/** Host registry for logical RPC channels carried by the current transport. */
export interface HostConnectionRpc {
/**
* Register one absolute channel prefix and its trust policy.
* @param channel - absolute logical channel such as `/rpc`.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - channel trust policy.
* @returns asynchronous disposer removing the channel and its physical route.
*/
handle(
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
/**
* Intercept owned endpoints on the shared `/api` channel before its fallback.
* @param channel - reserved shared channel; currently `/api`.
* @param matches - synchronous endpoint ownership test.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - trust policy for every endpoint claimed by this interceptor.
* @returns asynchronous disposer removing the interceptor.
*/
intercept(
channel: '/api',
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
}
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
export interface HostConnectionHandle {
/** Generic RPC channel registry. */
readonly rpc: HostConnectionRpc
}
/** Client caller for logical RPC channels carried by the current transport. */
export interface ClientConnectionRpc {
/**
* Call one endpoint through an already registered logical channel.
* @param channel - absolute logical channel such as `/api`.
* @param endpoint - channel-relative endpoint such as `goals/create`.
* @param payload - channel-owned request payload.
* @param signal - optional caller cancellation.
* @returns the existing RPC success/error result; correlation stays inside Connection.
*/
call(
channel: string,
endpoint: string,
payload: unknown,
signal?: AbortSignal,
): Promise<RpcResult<unknown>>
}

View File

@@ -203,4 +203,119 @@ describe('connection client apply', () => {
expect(sockets).toHaveLength(1)
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
})
it('carries RPC calls without requiring secure-context randomUUID', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
vi.stubGlobal('crypto', {
getRandomValues(bytes: Uint8Array) {
return bytes.fill(0)
},
})
const handle = await mount()
const original = globalThis.fetch
const seen: { url: string; body: unknown }[] = []
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
const body = JSON.parse(init.body) as { rpcId: string }
seen.push({ url, body })
return Response.json({
type: 'server-response',
rpcId: body.rpcId,
result: { ok: true, value: { ref: 'goal-1' } },
})
}
try {
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
} finally {
globalThis.fetch = original
vi.unstubAllGlobals()
}
expect(seen).toHaveLength(1)
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
expect(seen[0]?.body).toMatchObject({
type: 'client-request',
rpcId: '00000000-0000-4000-8000-000000000000',
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
})
})
it('validates generic RPC transport failures, correlation, and targets', async () => {
;(globalThis as Win).location = {
hostname: 'harness.example', search: '', origin: 'https://harness.example',
}
const handle = await mount()
const original = globalThis.fetch
const abort = new AbortController()
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
try {
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
.rejects.toThrow('HTTP 503')
expect(globalThis.fetch).toHaveBeenCalledWith(
new URL('https://harness.example/api/goals/create'),
expect.objectContaining({ signal: abort.signal }),
)
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
type: 'server-response',
rpcId: 'different-rpc',
result: { ok: true, value: null },
}))
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
const fetch = vi.mocked(globalThis.fetch)
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
} finally {
globalThis.fetch = original
}
for (const [channel, endpoint] of [
['api2', 'goals/create'],
['/api/path', 'goals/create'],
['/api', ''],
['/api', '.'],
['/api', '..'],
['/api', 'goals//create'],
['/api', 'goals/create?unsafe'],
] as const) {
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
}
})
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const created = await handle.rpc.call('/api', 'goals/create', {
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
})
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
if (!created.ok) throw new Error('fixture Goal create failed')
const ref = (created.value as { ref: { id: string; revision: number } }).ref
const edited = await handle.rpc.call('/api', 'goals/edit', {
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
})
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
const editedRef = { id: ref.id, revision: 2 }
const paused = await handle.rpc.call('/api', 'goals/pause', {
args: { agentId: 'fx-alpha', ref: editedRef },
})
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
const resumed = await handle.rpc.call('/api', 'goals/resume', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
})
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
const completed = await handle.rpc.call('/api', 'goals/complete', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
})
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
await expect(handle.rpc.call('/api', 'goals/clear', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
.rejects.toThrow(/endpoint.*unavailable/)
})
})

View File

@@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => {
let carrierSignal: AbortSignal | undefined
const pending = bridge(request, response, {
fetch: async (input) => {
const fetchRequest = input as Request
const fetchRequest = input
carrierSignal = fetchRequest.signal
resolveStarted()
if (!fetchRequest.signal.aborted) {

View File

@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
/** Structural httpServer fake recording both route registries. */
function fakeHttpServer(
@@ -17,6 +18,9 @@ function fakeHttpServer(
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
return {
register(route) {
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
throw new Error(`duplicate route ${route.path}`)
}
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
return request
}
/** JSON POST carrying a complete client-request envelope. */
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
return request
}
/** Raw POST for malformed-body and media-type boundary cases. */
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
Object.assign(request, { url, method: 'POST', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
const chunks: Buffer[] = []
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
write() { return true },
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
this.writableEnded = true
return this
},
@@ -173,6 +194,211 @@ describe('connection node half', () => {
expect(declared.state.status).toBe(404)
await dispose()
})
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
}, { authority: 'trusted-host' })
const route = routes.find(candidate => candidate.path === '/rpc')
expect(route).toBeDefined()
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-dedicated'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const result = fakeResponse()
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
expect(result.state.status).toBe(200)
expect(JSON.parse(String(result.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-dedicated',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
endpoint: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}])
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
authority: 'trusted-host',
})).toThrow(/duplicate route/)
await remove()
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
await fiber.dispose()
expect(routes).toHaveLength(0)
})
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
},
{ authority: 'trusted-host' },
)
expect(() => connection.rpc.intercept(
'/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('already has an interceptor')
expect(() => connection.rpc.intercept(
'/rpc' as '/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('invalid shared RPC channel')
const route = routes.find(candidate => candidate.path === API_PATH)!
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-shared'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const claimed = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
expect(JSON.parse(String(claimed.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-shared',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
endpoint: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}])
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
expect(calls).toHaveLength(1)
const unclaimed = fakeResponse()
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
expect(unclaimed.state.status).toBe(404)
await remove()
const withdrawn = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
expect(withdrawn.state.status).toBe(404)
expect(calls).toHaveLength(1)
const removeLoopback = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async () => ({ ok: true, value: null }),
{ authority: 'loopback' },
)
const loopbackOnly = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
expect(loopbackOnly.state.status).toBe(403)
await removeLoopback()
await fiber.dispose()
})
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
if (endpoint === 'fail') throw new Error('handler broke')
return { ok: true, value: null }
}, {
authority: 'trusted-host',
})
const route = routes.find(candidate => candidate.path === '/rpc')!
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
const methodMismatch = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
}), methodMismatch.response)
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
rpcId: 'rpc-bad',
result: { ok: false, error: { code: 'bad-request' } },
})
for (const [request, status] of [
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
] as const) {
const response = fakeResponse()
await route.handler(request, response.response)
expect(response.state.status).toBe(status)
}
for (const [body, rpcId] of [
[{ rpcId: 'retained-id' }, 'retained-id'],
[{ rpcId: 42 }, 'invalid-request'],
[null, 'invalid-request'],
] as const) {
const response = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
expect(JSON.parse(String(response.state.body))).toMatchObject({
rpcId,
result: { ok: false, error: { code: 'bad-request' } },
})
}
const failed = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
}), failed.response)
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
const publicResponse = fakeResponse()
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
}), publicResponse.response)
expect(publicResponse.state.status).toBe(403)
await removeLoopback()
await remove()
await fiber.dispose()
})
})
describe('connection node half over a real HTTP server', () => {

View File

@@ -24,7 +24,9 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection"
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
"immediately": true
@@ -48,11 +50,17 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"

View File

@@ -18,6 +18,12 @@
import { Context as CordisContext } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
export type AgentContext = Omit<Context, 'remote'> & {
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'>
}
/** Context tag written by {@link createScope}. */
const kScope = Symbol('dsh.client.scope')
@@ -29,7 +35,7 @@ export interface AgentScopeHandle {
* through it (passing it as the dispatch subject routes to this agent's
* tagged listeners plus every untagged one).
*/
ctx: Context
ctx: AgentContext
/** Backing fiber (dispose tears down every scope-owned registration). */
fiber: Fiber
}
@@ -48,15 +54,16 @@ function agentScope(): void {}
*/
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
const fiber = ctx.plugin(agentScope)
const scoped = fiber.ctx.extend({
[kScope]: key,
[CordisContext.filter](listenerCtx: Context): boolean {
const tag = scopeOf(listenerCtx)
return tag === undefined || tag === key
},
}) as AgentContext
return {
fiber,
ctx: fiber.ctx.extend({
[kScope]: key,
[CordisContext.filter](listenerCtx: Context): boolean {
const tag = scopeOf(listenerCtx)
return tag === undefined || tag === key
},
}),
ctx: scoped,
}
}

View File

@@ -12,6 +12,7 @@ import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/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'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
@@ -19,6 +20,8 @@ import type {
import type { SessionFace } from './session.ts'
import type { ObservableSnapshot } from './store.ts'
export type { AgentContext } from '../agents/scope.ts'
/** The sessions-service face injected as `ctx.sessions`. */
export interface ISessions {
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
@@ -95,7 +98,7 @@ export interface ISessions {
* @param id - session id.
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined
scope(id: SessionId): AgentContext | undefined
/**
* Read the Agent scope tag off a context (service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions).

View File

@@ -1,6 +1,8 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/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'
import { SessionsService } from './sessions/service.ts'
@@ -28,7 +30,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session.
export type {
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
} from './contract/session-history.ts'
export type { ISessions } from './contract/sessions.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
@@ -77,6 +79,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTContextMap {
/** Client Agent scope identity; the agent and session share one wire id. */
agent: TypeRTContext<SessionId>
}
}
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
@@ -172,8 +181,8 @@ declare module 'cordis' {
}
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
export const inject = ['remote', 'connection', 'typert']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -182,6 +191,9 @@ export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})
const sessionHistory = new SessionHistoryService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(

View File

@@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
@@ -127,7 +127,7 @@ export interface SessionBinding {
readonly sessionId: SessionId
/** The outward session face only — feature code never sees the concrete class. */
readonly session: SessionFace
readonly ctx: Context
readonly ctx: AgentContext
}
// Scope primitives live in ../agents/scope.ts (the client mirror of host
@@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string {
interface ScopeRecord {
fiber: Fiber
ctx: Context
ctx: AgentContext
binding: SessionBinding
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
session: Session
@@ -483,7 +483,7 @@ export class SessionsService implements ISessions {
* @param id - session id (the agent identity — 1:1 same axis).
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
scope(id: SessionId): AgentContext | undefined {
return this.resolve(id)?.ctx
}

View File

@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/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'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
@@ -22,17 +23,22 @@ interface Bench {
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
isLoopback: true,
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
await ctx.plugin(RuntimeClient).await()
return bench
}

View File

@@ -6,6 +6,7 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
@@ -16,17 +17,22 @@ interface Bench {
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
isLoopback: true,
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
await ctx.plugin(RuntimeClient).await()
return bench
}

View File

@@ -23,6 +23,9 @@
{
"path": "../schema-form"
},
{
"path": "../../api/remotes"
},
{
"path": "../../host/apiproxy"
},
@@ -46,6 +49,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
},
{
"path": "../../typert/registry"
}
],
"exclude": [

View File

@@ -3,7 +3,7 @@ import type { Context } from 'cordis'
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -134,7 +134,7 @@ interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<ConversationSnapshot>
session: FixtureSession
scope: Context | undefined
scope: AgentContext | undefined
scopeFiber: { dispose(): Promise<void> } | undefined
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
provideInfo: SessionProvideInfo | undefined
@@ -144,7 +144,7 @@ interface SessionRecord {
export interface TestSessionBinding {
readonly sessionId: SessionId
readonly session: FixtureSession
readonly ctx: Context
readonly ctx: AgentContext
}
/**
@@ -345,7 +345,7 @@ export class TestSessions implements ISessions {
* @param id - session id.
* @returns the scoped context, or undefined for unknown sessions.
*/
scope(id: string): Context | undefined {
scope(id: string): AgentContext | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
if (record.scope === undefined) {

View File

@@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
@@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution`
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},

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/ui-conversation/README.md
README.md: f59ea54f56318cf10381a3f4e6754e20a2866bab
README.zh.md: 621e6ba5a97501dddaad8168db4b20e95510bf51
README.md: bf4cc11fa8e5d512f6cd802321dd835135af03f4
README.zh.md: 2dc09eb4a9408dde615bd2fc982e01291de3840b

View File

@@ -16,7 +16,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).

View File

@@ -14,7 +14,7 @@
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。

View File

@@ -44,7 +44,7 @@ export const zh = {
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.headline': '探索未知之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -184,7 +184,7 @@ export const en = {
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.headline': 'Into the Unknown',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',

View File

@@ -47,7 +47,7 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,

View File

@@ -96,7 +96,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -184,7 +184,7 @@ describe('terminal card assembly', () => {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -203,7 +203,7 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -273,7 +273,7 @@ describe('resident composer', () => {
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)

View File

@@ -24,7 +24,7 @@ const CHILD = 'child-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })

View File

@@ -62,7 +62,7 @@ const LAYOUT_CHILDREN = {
*/
async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layout)
const locale = new LocaleService(runtime.ctx)
@@ -194,7 +194,7 @@ describe('keyed toolview hole through the real machinery', () => {
describe('registrant declaration injection', () => {
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)

View File

@@ -255,7 +255,7 @@ function mount(
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
expect(view.getByText('Let\'s start building')).toBeTruthy()
expect(view.getByText('Into the Unknown')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
})
})
@@ -351,7 +351,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -375,7 +375,7 @@ describe('ConversationRoot resident composer', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -400,7 +400,7 @@ describe('ConversationRoot resident composer', () => {
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
@@ -418,7 +418,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})

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/ui-goal/README.md
README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde
README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9
README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
## Model Experience
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
#### KV Cache effect

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear `goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear `ctx.remote.goals` 调用——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## 模型体验
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
间接影响:条带通过调用 `goals/edit``goals/pause``goals/resume``goals/clear` Remote 方法提交变更;每次被接受变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
#### KV Cache 影响

View File

@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -36,8 +37,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -48,8 +49,8 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

View File

@@ -4,19 +4,19 @@
* arrives through `useProjection('goal')` (seeded by the history tail page,
* updated by session/projection frames), so this plugin owns no store, no
* refresh chain, and no event listener. The inject face carries only the
* three mutation verbs (edit/resume/clear over the goal.* wire domain);
* four mutation verbs through the generated Goal Remote API;
* their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command.
*/
import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import { GoalDock } from './GoalBar.tsx'
import { en, zh, type GoalKey } from './locales.ts'
@@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */
export const inject = ['slots', 'sessions', 'connection', 'locale']
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
/** Map one settled RPC result onto the strip's inline-render shape. */
function settle<T>(result: RpcResult<T>): GoalActionResult {
if (result.ok) return { ok: true }
return { ok: false, error: { code: result.error.code, message: result.error.message } }
/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
try {
await invoke()
return { ok: true }
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined
if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } }
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : 'goal mutation failed',
},
}
}
}
function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } {
return value !== null
&& typeof value === 'object'
&& typeof (value as { code?: unknown }).code === 'string'
&& typeof (value as { message?: unknown }).message === 'string'
}
/**
@@ -51,8 +70,6 @@ function settle<T>(result: RpcResult<T>): GoalActionResult {
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
const { goals } = (ctx.get('connection') as ConnectionHandle).api
const sessions = ctx.sessions
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
@@ -77,22 +94,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective }))
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.pause({ sessionId, ref })).result)
return settle(() => ctx.remote.goals.pause(sessionId, ref))
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.resume({ sessionId, ref })).result)
return settle(() => ctx.remote.goals.resume(sessionId, ref))
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.clear({ sessionId, ref })).result)
return settle(() => ctx.remote.goals.clear(sessionId, ref))
},
}),
}, GoalDock))

View File

@@ -1,16 +1,16 @@
// @vitest-environment jsdom
/**
* ui-goal browser half on a real cordis Context with fake slots/connection/
* ui-goal browser half on a real cordis Context with fake slots/api/
* sessions faces: the plugin registers the GoalBar dock entry at
* conversation.input.dock, the inject face's three verbs read the CAS ref
* conversation.input.dock, the inject face's four verbs read the CAS ref
* from the session's CURRENT projected value at call time (no fence — the
* RPC's compare-and-set is the guard), a missing projection short-circuits
* to the no-current-goal error without touching the wire, and RPC errors
* Remote method's compare-and-set is the guard), a missing projection short-circuits
* to the no-current-goal error without touching the wire, and Remote errors
* map onto the inline-render result shape. Registration disposal rides the
* plugin fiber (HMR safety). The node half and the invariant companion are
* exercised over the same Context.
*/
import { Context } from 'cordis'
import { Context, Service } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
@@ -44,27 +44,45 @@ function makeProjection(revision = 3): GoalProjection {
}
}
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
async function bench(options: {
projection?: GoalProjection | null | undefined
failWith?: { code: string; message: string }
rejectWith?: unknown
} = {}) {
const ctx = new Context()
const calls: { method: string; payload: unknown }[] = []
const calls: { method: string; args: unknown[] }[] = []
function answer<T>(method: string, value: T) {
return (payload: unknown) => {
calls.push({ method, payload })
return Promise.resolve({
result: options.failWith === undefined
? { ok: true as const, value }
: { ok: false as const, error: { ...options.failWith, details: {} } },
})
return (...args: unknown[]) => {
calls.push({ method, args })
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test.
if ('rejectWith' in options) return Promise.reject(options.rejectWith)
if (options.failWith !== undefined) {
return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith }))
}
return Promise.resolve(value)
}
}
const ref = { id: 'g-1', revision: 3 }
ctx.provide('connection', { api: { goals: {
edit: answer('goal.edit', { ref }),
pause: answer('goal.pause', { ref }),
resume: answer('goal.resume', { ref }),
clear: answer('goal.clear', { cleared: true as const }),
} } })
const goals = (prefix: string) => ({
edit: answer(`${prefix}/edit`, { ref }),
pause: answer(`${prefix}/pause`, { ref }),
resume: answer(`${prefix}/resume`, { ref }),
clear: answer(`${prefix}/clear`, ref),
})
let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
ctx.provide('remote.goals', {
get edit() { return activeGoals?.edit },
get pause() { return activeGoals?.pause },
get resume() { return activeGoals?.resume },
get clear() { return activeGoals?.clear },
})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
@@ -85,6 +103,8 @@ async function bench(options: { projection?: GoalProjection | null | undefined;
ctx,
fiber,
calls,
remountGoals: () => { activeGoals = goals('remounted-goals') },
unmountGoals: () => { activeGoals = undefined },
entry: () => {
const entry = ctx.slots.entries('conversation.input.dock')[0]
if (entry === undefined) return undefined
@@ -113,12 +133,34 @@ describe('ui-goal browser plugin', () => {
expect(await verbs.onPause()).toEqual({ ok: true })
expect(await verbs.onResume()).toEqual({ ok: true })
expect(await verbs.onClear()).toEqual({ ok: true })
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
const ref = { id: 'g-1', revision: 5 }
expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
expect(b.calls[1]?.args).toEqual(['s1', ref])
expect(b.calls[2]?.args).toEqual(['s1', ref])
expect(b.calls[3]?.args).toEqual(['s1', ref])
})
it('verbs read a remounted Remote namespace at action time', async () => {
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
b.remountGoals()
expect(await verbs.onPause()).toEqual({ ok: true })
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
})
it('settles every verb when the Remote namespace is temporarily absent', async () => {
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
b.unmountGoals()
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
}
expect(b.calls).toHaveLength(0)
})
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
@@ -133,13 +175,26 @@ describe('ui-goal browser plugin', () => {
}
})
it('maps a settled RPC error onto the inline-render shape', async () => {
it('maps a Remote error onto the inline-render shape', async () => {
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
})
it.each([
[new Error('connection closed'), 'connection closed'],
['connection closed', 'goal mutation failed'],
[new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'],
])('maps an unstructured rejection onto an internal error', async (rejection, message) => {
const b = await bench({ projection: makeProjection(), rejectWith: rejection })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } })
})
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
const b = await bench()
await b.fiber.await()

View File

@@ -12,10 +12,10 @@
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
"path": "../locale"
},
{
"path": "../locale"
"path": "../../api/remotes"
},
{
"path": "../runtime"

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/ui-layout/README.md
README.md: 5cb8f01efb2e18109e917225dbce088ea77394af
README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64
README.md: fa60520a20ac8a7f25d494879c68efb06a28998f
README.zh.md: 6ca04c56c29a55f84fc7a6399a7feeb81d249899

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes.
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景保持为唯一颜色真源;呈现器在资源释放时会移除其自有的元数据节点,并一并清除其写入的其他全局状态
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。

View File

@@ -1,10 +1,11 @@
/**
* Global theme DOM applier: projects the resolved ThemeSnapshot onto the
* document — `html { color-scheme }` for native UA chrome (scrollbars, form
* controls), `body[data-ds-dark-theme]` for the token palette, and the active
* theme's alias-token overrides as inline CSS variables on body. Pure DOM
* writes, no React involvement; the presenter only ever retracts what it wrote
* itself, so foreign attributes and inline styles survive apply/dispose.
* controls), `body[data-ds-dark-theme]` for the token palette, the active
* theme's alias-token overrides as inline CSS variables on body, and one
* presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure
* DOM writes, no React involvement; the presenter only ever retracts what it
* wrote itself, so foreign attributes, metadata, and inline styles survive.
*/
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -15,12 +16,22 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
export class ThemePresenter {
/** Token names this presenter wrote in the last apply (its retraction set). */
private appliedTokens: string[] = []
/** The single metadata node this presenter inserts and removes. */
private readonly themeColorMeta: HTMLMetaElement
/** Create the presenter-owned metadata node before the first snapshot arrives. */
constructor() {
this.themeColorMeta = document.createElement('meta')
this.themeColorMeta.name = 'theme-color'
}
/**
* Project a snapshot onto the document: set root `color-scheme` and the body
* palette attribute from `active.colorScheme` (never the id — `system` is
* resolved upstream), then replace the previously applied token variables
* with `active.tokens`.
* with `active.tokens`. Browser theme-color metadata follows the computed
* body background after those writes, so the rendered palette remains the
* color authority.
* @param snapshot - resolved theme snapshot from ctx.theme.
*/
apply(snapshot: ThemeSnapshot): void {
@@ -35,14 +46,17 @@ export class ThemePresenter {
body.style.setProperty(name, value)
this.appliedTokens.push(name)
}
this.themeColorMeta.content = getComputedStyle(body).backgroundColor
if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta)
}
/** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
/** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */
dispose(): void {
document.documentElement.style.removeProperty('color-scheme')
const body = document.body
body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
this.themeColorMeta.remove()
}
}

View File

@@ -7,7 +7,7 @@
// coverage gate still requires exercised.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
beforeEach(() => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
})
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
@@ -66,13 +70,17 @@ describe('ui-layout client apply', () => {
// Initial getter application: jsdom has no matchMedia, system resolves light.
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
const themeColorMeta = document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
expect(themeColorMeta).not.toBeNull()
const theme = ctx.get('theme') as ThemeService
theme.setTheme('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta)
await fiber.dispose()
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
expect(themeColorMeta?.isConnected).toBe(false)
// Listener is off: further theme changes no longer reach the document.
theme.setTheme('light')
theme.setTheme('dark')

View File

@@ -1,40 +1,68 @@
// @vitest-environment jsdom
// ThemePresenter behavior account: root color-scheme and the palette attribute
// follow active.colorScheme only, token variables replace the previous apply's
// set, and dispose retracts everything the presenter wrote.
// set, theme-color metadata follows the rendered body background, and dispose
// retracts everything the presenter wrote.
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts'
const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)'
const DARK_THEME_COLOR = 'rgb(21, 21, 23)'
function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string> = {}): ThemeSnapshot {
// The presenter must key off colorScheme, not the id — keep them distinct.
const active = { id: `${colorScheme}-test`, colorScheme, tokens }
return { preference: colorScheme, active, themes: [active], revision: 1 }
}
function clearThemePresentation(): void {
document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() })
}
function themeColorMeta(): HTMLMetaElement | null {
return document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
}
beforeEach(() => {
clearThemePresentation()
document.documentElement.style.removeProperty('color-scheme')
document.body.removeAttribute(DARK_ATTRIBUTE)
document.body.removeAttribute('style')
const style = document.createElement('style')
style.dataset.themePresenterTest = ''
style.textContent = `
body { background-color: ${LIGHT_THEME_COLOR}; }
body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; }
`
document.head.append(style)
})
afterEach(clearThemePresentation)
describe('ThemePresenter', () => {
it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('light'))
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR)
})
it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark'))
const meta = themeColorMeta()
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
expect(meta?.content).toBe(DARK_THEME_COLOR)
presenter.apply(snapshot('light'))
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(themeColorMeta()).toBe(meta)
expect(meta?.content).toBe(LIGHT_THEME_COLOR)
expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1)
})
it('applies tokens as inline variables and clears the previous set on theme change', () => {
@@ -52,10 +80,12 @@ describe('ThemePresenter', () => {
document.body.style.setProperty('--foreign', 'kept')
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
const meta = themeColorMeta()
presenter.dispose()
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
expect(meta?.isConnected).toBe(false)
})
})

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/ui-primitives/README.md
README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4
README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687
README.md: c54759f98a944565959ef21ce538eb9b12fccdf1
README.zh.md: 32275c19bca9d6e8aa510e982d535a72eb1a06a7

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Hover cards
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md).
## Markdown rendering

View File

@@ -6,7 +6,7 @@
## 悬浮卡片
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel``copiedLabel` 采用 label prop是因为这个 zero-cordis 原子组件无法读取应用 locale省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel``copiedLabel` 采用 label prop是因为这个 zero-cordis 原子组件无法读取应用 locale省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。
## Markdown 渲染