Trim redundant source comments

This commit is contained in:
Turtle
2026-07-25 13:02:37 +08:00
parent f7b36bd36d
commit fac6c35e9a
83 changed files with 219 additions and 748 deletions

View File

@@ -1,10 +1,6 @@
/** /**
* Browser stand-in for `node:module`, mapped by the vite alias in * Browser stand-in for `node:module`. `createRequire` is unreachable in the
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports * configured loader path and fails loud if that assumption changes.
* `createRequire` at module scope but only calls it inside
* `ModuleLoader.fromInternal()`, whose version probe is compiled to the
* `"0.0.0"` define in the browser build — so this throw is a fail-loud
* tripwire for any path that would genuinely need Node's module machinery.
*/ */
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ /** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */

View File

@@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.` const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
await input.fill(prompt) await input.fill(prompt)
await input.press('Enter') await input.press('Enter')
// startSession chain: session mounts, composer moves to the bottom. // The first send must keep the session tree mounted; a near-empty body
// Regression pin (P0, 585671106): this send used to white-screen the tree // reveals a duplicate runtime bundle with incompatible scope tags.
// (scope tag lost to a duplicate inlined runtime instance) — body going
// near-empty here means that class of bug is back.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 }) await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([]) expect(pageErrors).toEqual([])
await page.waitForFunction( await page.waitForFunction(

View File

@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`) Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:285`](../packages/ui/acp/src/index.ts) Source: [`packages/ui/acp/src/index.ts:275`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo` ## `@deepseek-ai/dsh-acp-demo`
@@ -710,12 +710,12 @@ Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src
Requires: `tools` Requires: `tools`
```ts config-catalog ```ts config-catalog
/** Discriminated union of all supported MCP transport configurations. */ /** Configuration for one stdio or Streamable HTTP MCP server. */
export type Config = StdioConfig | StreamableHttpConfig export type Config = StdioConfig | StreamableHttpConfig
/** Config for connecting to an MCP server via a spawned child process over stdio. */ /** Config for connecting to an MCP server via a spawned child process over stdio. */
export interface StdioConfig { export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */ /** Selects child-process stdio transport. */
transport: 'stdio' transport: 'stdio'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -723,21 +723,21 @@ export interface StdioConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** Executable to spawn. */ /** Executable used to start the server. */
command: string command: string
/** Arguments passed to the command. */ /** Arguments passed directly, without shell interpolation. */
args: string[] args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */ /** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string> env: Record<string, string>
/** Working directory for the child process. */ /** Working directory for the child process. */
cwd: string cwd: string
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ /** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig { export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ /** Selects Streamable HTTP transport. */
transport: 'streamable-http' transport: 'streamable-http'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -745,11 +745,11 @@ export interface StreamableHttpConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** MCP server URL. */ /** MCP endpoint URL. */
url: string url: string
/** Extra headers (e.g. auth tokens). */ /** Additional headers attached to MCP requests. */
headers: Record<string, string> headers: Record<string, string>
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
``` ```

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 subagent.md: fda4b4b738c648c893c65a633e4a0d6a1761424f
subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b subagent.zh.md: ba43789a3e4efe59b197f6454c977db52d90aca1

View File

@@ -22,13 +22,9 @@ A provider advertises its **start-time** features on a static descriptor the ser
* is the capability. * is the capability.
*/ */
interface SubagentCapabilities { interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
``` ```
@@ -45,16 +41,11 @@ The tool layer builds this request from the model input and its own config; the
* passes it to {@link SubagentProvider.start}. * passes it to {@link SubagentProvider.start}.
*/ */
interface SubagentStartRequest { interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/** /**
* The spawning ("parent") agent — the one whose tool call started this * The spawning agent. In-process providers derive workspace, lineage, and
* subagent. REQUIRED: in-process backends read `parent.session.header` for * delegation depth from its durable session state; ACP uses only its cwd.
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/ */
readonly parent: Agent readonly parent: Agent
/** /**
@@ -65,7 +56,6 @@ interface SubagentStartRequest {
* afterward. * afterward.
*/ */
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -135,15 +125,12 @@ interface SubagentResult {
* non-`completed` result to an `isError` tool result. * non-`completed` result to an `isError` tool result.
*/ */
interface SubagentStopReasonMap { interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */ /** Cancelled through the request signal or disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */ /** Model or transport failure. */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal' refusal: 'refusal'
} }
``` ```
@@ -180,9 +167,8 @@ interface SubagentRun {
*/ */
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/** /**
* Cancel remaining work, reach child quiescence, and release the run's * Cancel remaining work, reach child quiescence, and release resources.
* resources (in-process: dispose the owned agent and remove its session; * Idempotent.
* ACP: kill and reap the subprocess). Idempotent.
*/ */
dispose(): Promise<void> dispose(): Promise<void>
/** /**
@@ -206,12 +192,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi
```ts type-equiv ```ts type-equiv
/** /**
* A subagent backend: one transport for running a child agent (in-process * One registered transport for running child agents. Providers are trusted
* spawn/fork, ACP to another process, …). Implementations register under a * same-process implementations; callers treat descriptors and returned values
* unique name via {@link SubagentService.registerProvider}; multiple providers * as borrowed immutable data.
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/ */
interface SubagentProvider { interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -22,13 +22,9 @@ subagent seam一个 agent智能体将工作委派给子 agent。与 [ba
* is the capability. * is the capability.
*/ */
interface SubagentCapabilities { interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
``` ```
@@ -45,16 +41,11 @@ interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}. * passes it to {@link SubagentProvider.start}.
*/ */
interface SubagentStartRequest { interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/** /**
* The spawning ("parent") agent — the one whose tool call started this * The spawning agent. In-process providers derive workspace, lineage, and
* subagent. REQUIRED: in-process backends read `parent.session.header` for * delegation depth from its durable session state; ACP uses only its cwd.
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/ */
readonly parent: Agent readonly parent: Agent
/** /**
@@ -65,7 +56,6 @@ interface SubagentStartRequest {
* afterward. * afterward.
*/ */
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -135,15 +125,12 @@ interface SubagentResult {
* non-`completed` result to an `isError` tool result. * non-`completed` result to an `isError` tool result.
*/ */
interface SubagentStopReasonMap { interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */ /** Cancelled through the request signal or disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */ /** Model or transport failure. */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal' refusal: 'refusal'
} }
``` ```
@@ -182,9 +169,8 @@ interface SubagentRun {
*/ */
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/** /**
* Cancel remaining work, reach child quiescence, and release the run's * Cancel remaining work, reach child quiescence, and release resources.
* resources (in-process: dispose the owned agent and remove its session; * Idempotent.
* ACP: kill and reap the subprocess). Idempotent.
*/ */
dispose(): Promise<void> dispose(): Promise<void>
/** /**
@@ -208,12 +194,9 @@ interface SubagentRun {
```ts type-equiv ```ts type-equiv
/** /**
* A subagent backend: one transport for running a child agent (in-process * One registered transport for running child agents. Providers are trusted
* spawn/fork, ACP to another process, …). Implementations register under a * same-process implementations; callers treat descriptors and returned values
* unique name via {@link SubagentService.registerProvider}; multiple providers * as borrowed immutable data.
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/ */
interface SubagentProvider { interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -114,11 +114,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
}) })
expect(['end_turn', 'max_tokens']).toContain(res.stopReason) expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// Verify the WORLD, not the agent's self-report: read the file from disk. // Assert the filesystem effect independently of the model response.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('ACP_OK') expect(proof).toContain('ACP_OK')
// And the client saw tool-call activity stream through.
const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call') const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
expect(toolCalls.length).toBeGreaterThan(0) expect(toolCalls.length).toBeGreaterThan(0)

View File

@@ -62,7 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook
// the model, not a turn failure). // the model, not a turn failure).
expect(['end_turn', 'max_tokens']).toContain(res.stopReason) expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// Verify that the denied hook left no filesystem effect. // Assert the denied operation independently of the model response.
await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow()
// A blocked call is still streamed with the hook's reason as an error. // A blocked call is still streamed with the hook's reason as an error.

View File

@@ -1,10 +1,7 @@
/** /**
* Browser half of the wire consumer layer (contract: api-contracts v3 * Browser wire client. The plugin selects fixture or HTTP transport, provides
* section 3; export inventory = v3 §3.2). The wire is this package's client * the shared API client, and lets the runtime object layer start the stream
* half in its entirety — apply mounts ctx.connection: the shared api client * controller with its sinks.
* plus the connection controller handle. Mode selection (?fixture) happens
* here so the rest of the client tree is mode-blind; the controller's sinks
* are wired by the runtime plugin (object layer), which injects this service.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { IApiClient } from './api.ts' import type { IApiClient } from './api.ts'
@@ -23,9 +20,8 @@ export type {
} from './api.ts' } from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract; // Connection loop types are public through ConnectionHandle.start; the
// the controller class itself stays package-internal — apply owns the loop, // controller remains package-internal.
// tests reach it via src) ----
export type { ConnectionConfig, ConnectionSinks, ConnectionState } export type { ConnectionConfig, ConnectionSinks, ConnectionState }

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser wire client exported from `./client`. */
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
*/
/** Host plugin body — no host-side behavior for the connection plugin. */ /** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {} export function apply(_ctx: unknown): void {}

View File

@@ -1,15 +1,10 @@
/** /**
* i18n plugin, browser half: namespace x locale dictionary registry with a * Browser-side locale registry. Bound translation functions retain stable
* bound translate function whose reference is stable (safe for inject * identity for injected consumers.
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
// The snapshot-store engine lives in runtime (store relocation): framework // Snapshot stores are framework-neutral; React consumers bind hooks at their
// data stores like this locale cell use it directly. The store carries no // rendering boundary.
// hook — a React consumer binds a selector hook via web-react's
// bindSnapshotSelector at its own seam (none exists today; the current
// consumers are translate() reads and test-side subscribe/set).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts' import { en } from '../locales/en.ts'

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser implementation exported from `./client`. */
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Everything else —
* I18nService, Translate, LocaleDict — lives in the client half; consumers
* import the /client subpath. Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the i18n plugin. */ /** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {} export function apply(): void {}

View File

@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
} }
} }
// ---- defineStore shell (slot terminal design §4) ---- // ui-slots owns the contract; this module supplies the engine implementation.
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
/** A live engine instance: the contract instance plus the raw engine store. */ /** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> { export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {

View File

@@ -1,12 +1,7 @@
/** /**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) — * Browser runtime services for slots, sessions, and connection-stream
* SlotsService (declaration ledger + renderer seam + store axis, built-in * delivery. The web shell mounts this static client entry through the host
* 'root'), SessionsService (list store + current selection + scope tree + * plugin graph.
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -17,15 +12,11 @@ import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts' export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts' export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts' export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts' export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data // Runtime owns the snapshot store; web-react only binds it to React.
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type { export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
@@ -35,21 +26,11 @@ export type {
RunningToolCall, SteeringMessageNode, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts' export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain: /** Client-side Cordis context after declaration merging. */
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
export type ClientContext = Context export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
@@ -69,14 +50,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* every session-scope slot component receives these from the framework. * every session-scope slot component receives these from the framework.
*/ */
interface SessionStandardProps { interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot> useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */ /** The framework-resolved session id (owners never pass it). */
sessionId: SessionId sessionId: SessionId
} }
/** Global standard kit, real members: the session-list hook every slot component receives. */ /** Props injected into every global slot component. */
interface GlobalStandardProps { interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState> useSessions: SnapshotSelectorHook<SessionListState>
} }
} }
@@ -99,9 +78,8 @@ declare module 'cordis' {
/** Required services: the wire handle mounted by the connection plugin. */ /** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection'] export const inject = ['connection']
/** /** Mounts the browser runtime services and connection stream.
* Client plugin body: mount slots + sessions, start the stream loop. * @param ctx - Client Cordis context.
* @param ctx - client cordis context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.plugin(SlotsService) ctx.plugin(SlotsService)

View File

@@ -24,10 +24,10 @@ export interface CallIndexEntry {
callView: ToolCallView | null callView: ToolCallView | null
} }
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch). /** Non-surface sentinel used to preserve paged-window sequence offsets.
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would * `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one * surface behavior; this cast is the only synthetic event entry point.
* place a synthetic event enters the window). */ */
function paddingEvent(seq: number): SessionEvent { function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
} }

View File

@@ -291,8 +291,7 @@ export class SessionsService {
fiber, fiber,
ctx, ctx,
binding: { sessionId: id, session, ctx }, binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the // Session is the observable; React binds a selector hook at its own seam.
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session }, cell: { sessionId: id, session },
} }
this.scopes.set(id, record) this.scopes.set(id, record)

View File

@@ -1,7 +1,4 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this // Sessions remain resident after creation so they continue consuming mux frames off-screen.
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -22,14 +19,12 @@ import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts' import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts' import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ /** Messages requested per history page. */
export const PAGE_MESSAGES = 50 export const PAGE_MESSAGES = 50
/** /**
* Per-session state owner: event window + fold + partial, snapshot out via * Owns a session's event window, derived conversation state, and observable
* subscribe/getSnapshot (see the web client architecture RFC). Bare source * snapshot. React bindings remain outside this data layer.
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
*/ */
export class Session implements ObservableSnapshot<ConversationSnapshot> { export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- Window and derived state (all private; the snapshot is the only read surface) ---- // ---- Window and derived state (all private; the snapshot is the only read surface) ----
@@ -54,8 +49,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = [] private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>() private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2, // Revision counters preserve array identity when derived content is unchanged, so
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no // tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
@@ -69,9 +63,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false private removed = false
private promptError: PromptError | null = null private promptError: PromptError | null = null
private lastAgentError: string | null = null private lastAgentError: string | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */ /** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */ /** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null private subscribedLastSeq: number | null = null
@@ -292,8 +286,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty() this.notifier.markDirty()
} }
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed /** No-op because session instances remain resident. */
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
dispose(): void {} dispose(): void {}
// ---- 私有 ---- // ---- 私有 ----

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host plugin body — no host-side behavior for the runtime plugin. */ /** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {} export function apply(_ctx: unknown): void {}

View File

@@ -187,8 +187,7 @@ describe('cell (render-layer session kit)', () => {
const cell = b.svc.cell('s1') const cell = b.svc.cell('s1')
expect(cell).toBeDefined() expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1') expect(cell?.sessionId).toBe('s1')
// Bare-source form (store migration): the cell carries the Session // Hook binding happens in React; the cell carries the observable itself.
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1'))) expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell) expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined() expect(b.svc.cell('ghost')).toBeUndefined()

View File

@@ -1,14 +1,4 @@
/** /** Registers the conversation components, shared store, and service callbacks. */
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,7 +15,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx' import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ /** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions'] export const inject = ['slots', 'layout', 'sessions']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
@@ -37,24 +27,17 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation return conversation
} }
/** /** Mounts the conversation plugin.
* Client plugin body. * @param ctx - Client root context.
* @param ctx - client root context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
const sessions = ctx.sessions const sessions = ctx.sessions
const layout = ctx.layout const layout = ctx.layout
const slots = ctx.slots const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with // Apply-time construction keeps store identity bound to this fiber.
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
const chatStore = createChatStore() const chatStore = createChatStore()
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => { const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = [] const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) { for (const entry of slots.entries('conversation.view')) {

View File

@@ -1,9 +1,4 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284 // Settled-node identity prevents stream-delta updates from rerendering this row.
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -1,15 +1,4 @@
/** /** Conversation slot declarations and their composed component props. */
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts' import type { createChatStore } from '../stores.ts'
@@ -93,15 +82,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ /** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore> export type ChatStore = ReturnType<typeof createChatStore>
/** /** Business callbacks injected into the conversation slot. */
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
export interface ConversationInjected { export interface ConversationInjected {
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */ /** Views projected from the `conversation.view` slot ledger. */
views: { views: {
list(): readonly ViewTab[] list(): readonly ViewTab[]
subscribe(fn: () => void): () => void subscribe(fn: () => void): () => void
@@ -111,7 +94,6 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void open(id: SessionId): void
} }
@@ -123,7 +105,6 @@ export interface ConversationInjected {
* with zero owner changes. * with zero owner changes.
*/ */
export interface ComposerChainProps { export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[] interactions: readonly PendingInteraction[]
} }
@@ -139,7 +120,6 @@ export type ConversationSlotProps =
export interface ChatViewInjected { export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */ /** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void loadOlder(): void
} }

View File

@@ -1,14 +1,4 @@
/** /** Shared conversation view, selection, and store-state contracts. */
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
/** Tool call identity as carried on the wire (branded upstream in connection). */ /** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string export type CallId = string
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
export interface ViewTab { id: string; label: string } export interface ViewTab { id: string; label: string }
/** /**
* Chat store state (slot terminal design §4): the per-session store shared by * Per-session state shared by conversation, chat-view, and details slots.
* the conversation, chat-view, and details registrations. `createChatStore` * Unknown persisted view ids fall back to the first registered view.
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
*/ */
export interface ChatStoreState { export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */ /** Details-linkage channel (conversation writes, details reads). */

View File

@@ -1,12 +1,7 @@
/** /**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer), * Browser conversation plugin. `contract/` is the shared type boundary
* the 'conversation.view' slot ring (chat entry here; other plugins * between the independently implemented skeleton and chat domains; `apply.ts`
* contribute view tabs through ctx.slots), the chat view's keyed * owns their slot assembly.
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
*/ */
import type { ConversationService } from './service.ts' import type { ConversationService } from './service.ts'

View File

@@ -1,17 +1,11 @@
/** /**
* ConversationService implementation: scope-addressed send/cancel and the * Scope-addressed conversation send, cancel, and empty-state session startup.
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
* *
* Scope addressing rides the cordis Service tracker: property access through * Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
* read the session tag with scopeOf (same mechanism as the host tool * read the session tag with `scopeOf`. Mutable state must remain reachable
* registry). Mutable state lives in plain objects reached by one property * through one property read; assignment through the tracker proxy and `#`
* read — field assignment through the tracker's shadow proxy is off-limits, * private fields bypass that rebinding.
* as are `#` hard-private fields.
*/ */
import { Service } from 'cordis' import { Service } from 'cordis'
import type { Context } from 'cordis' import type { Context } from 'cordis'

View File

@@ -1,12 +1,5 @@
// InputBar: the one composer input (figma Input_Bottom). The same component // Shared empty-state and resident composer. Running retains the draft, locks
// serves the empty state (variant='hero': centered launch card) and the // the textarea, and exposes only Stop. Bottom controls are local visual state.
// resident composer (variant='composer') — the empty→content transition is a
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
//
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
// local native <select> state, no host wiring.
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
@@ -25,10 +18,8 @@ export interface InputBarProps {
running: boolean running: boolean
disabled: boolean disabled: boolean
error: InputBarError | null error: InputBarError | null
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer' variant: 'hero' | 'composer'
placeholder?: string placeholder?: string
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode accessory?: ReactNode
onDraftChange: (text: string) => void onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void onSend: (mode: 'queue' | 'steer') => void

View File

@@ -1,21 +1,11 @@
/** /**
* Chat store factory (slot terminal design §4): selection + draft + active * Per-session chat store shared by conversation and details registrations.
* view for one session, shared by the conversation and details registrations * The plugin creates its handle at apply time so identity follows the fiber.
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
*/ */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts' import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/** /** Declared action shape used to give the exported factory a stable return type. */
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type ChatActions = { type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void setDraft: (draft: ChatStoreState, text: string) => void
@@ -25,18 +15,11 @@ type ChatActions = {
} }
/** /**
* Declare the per-session chat store. `selection` is the details-linkage * Declares the per-session chat state and write surface.
* channel (conversation writes, details reads); `draft` is the composer text * @returns the store handle.
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
*/ */
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> { export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({ return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat', persist: 'dsh.conversation.chat',
actions: { actions: {

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only conversation plugin. */
* Conversation plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 7.
*/
/** Host plugin body — no host-side behavior for the conversation plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -137,7 +137,6 @@ describe('conversation slot inject surface', () => {
expect(injected.views.list().map(v => v.id)).toEqual(['chat']) expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.open(ROOT) injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT) const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder() chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)

View File

@@ -1,10 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /** Chat-store actions, scoped persistence, and instance isolation. */
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts' import { createChatStore } from '../src/client/stores.ts'

View File

@@ -146,8 +146,6 @@ describe('keyed toolview hole through the real machinery', () => {
it('a duplicate key registration fails loud at load', async () => { it('a duplicate key registration fails loud at load', async () => {
const b = await bench([]) const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register( expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' }, { name: 'conversation.chat.toolview', key: 'bash' },
() => null, () => null,

View File

@@ -69,7 +69,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
}) })
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ /** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() { function emptySessions() {
const store = createSnapshotStore<SessionListState>( const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState) { ids: [], byId: {}, current: undefined } as SessionListState)

View File

@@ -1,9 +1,4 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react' import { cleanup, render } from '@testing-library/react'

View File

@@ -1,12 +1,6 @@
/** /**
* Test-local selector-hook binder: the engine carries no hook since the store * Test-local selector binding through the production uSES implementation.
* migration (runtime is React-free); the renderer binds in production, specs * Runtime remains React-free, so specs bind observable sources here.
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/ */
import { bindSnapshotSelector } from '../../web-react/src/bind.ts' import { bindSnapshotSelector } from '../../web-react/src/bind.ts'

View File

@@ -1,13 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* Selection survival across the store seat (terminal design §4): the chat * Exercises selection persistence through the real SlotsService store axis;
* store now carries what the per-scope selection account used to — this pins * component stubs cannot prove per-session identity or disposal.
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
*/ */
import { Context } from 'cordis' import { Context } from 'cordis'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
@@ -15,8 +9,7 @@ import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/c
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts' import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through // Use the runtime's programmable fake to drive the real session service.
// the src path (same pattern the runtime specs use — test-support material).
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId const sid = (s: string): SessionId => s as SessionId

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only layout plugin. */
* Layout plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 5.
*/
/** Host plugin body — no host-side behavior for the layout plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -42,7 +42,7 @@ class ResizeObserverStub {
let frameWidth = 1920 let frameWidth = 1920
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */ /** Test-local selector hook over a framework-neutral store instance. */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
} }

View File

@@ -1,7 +1,5 @@
/** /**
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input, * Cordis-free React primitives styled only through `--dsw-*` tokens.
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
* token vars only. Contract: api-contracts v3 section 8.
*/ */
export { StateDot } from './StateDot.tsx' export { StateDot } from './StateDot.tsx'

View File

@@ -16,9 +16,7 @@ async function bench() {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SlotsService).await() await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-conversation's conversation entry: the composer slot only // The composer slot exists only while its declaring entry is live.
// exists while a live entry declares it in children (declaration account:
// design §2.2).
slots.register( slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null, () => null,

View File

@@ -1,12 +1,5 @@
/** /**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace * Collapse is a slide plus crossfade: content freezes at its expanded
* section header with the group-by menu, search, session tree list, Settings
* foot. Pure presentational — the session list arrives through the standard
* useSessions hook, viewing state (expansion, search) is local component
* state, and rows are derived in render via useMemo (slot design section 6:
* derived data is a pure function, no materializing store).
*
* Collapse is a slide + crossfade: the content freezes at its expanded
* width (inline style) and fades out in place while the sliding column * width (inline style) and fades out in place while the sliding column
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
* the wide-only content (brand, labels, input, tree) unmounts, dropping * the wide-only content (brand, labels, input, tree) unmounts, dropping
@@ -35,7 +28,7 @@ const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [ const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' }, { id: 'workspace', label: 'WorkSpace' },
// Update/Status grouping has no design yet (figma §3) — visible, disabled. // Only workspace grouping is implemented.
{ id: 'update', label: 'Update', disabled: true }, { id: 'update', label: 'Update', disabled: true },
{ id: 'status', label: 'Status', disabled: true }, { id: 'status', label: 'Status', disabled: true },
] ]
@@ -78,8 +71,7 @@ type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen'
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) { function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s) const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list // Selection belongs to the sessions snapshot, not layout state.
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current) const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([]) const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([]) const [expandedSessions, setExpandedSessions] = useState<string[]>([])

View File

@@ -1,30 +1,19 @@
/** /** Registers the sidebar UI into the layout-owned slot. */
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts' import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx' import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts' export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ /** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions'] export const inject = ['slots', 'layout', 'sessions']
/** /** Registers the sidebar component and its service callbacks.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject * @param ctx - Client root context.
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({ const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the // Selection belongs to the sessions service; layout owns only panel geometry.
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) }, onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => { onCreate: (cwd) => {
// Top-level New Session / New Workspace: clear selection so AppFrame // Top-level New Session / New Workspace: clear selection so AppFrame

View File

@@ -1,11 +1,4 @@
/** /** Pure derivation of flat sidebar rows from sessions and local view state. */
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for sessions without a project directory. */ /** Group key for sessions without a project directory. */

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only sidebar plugin. */
* Sidebar plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 6.
*/
/** Host plugin body — no host-side behavior for the sidebar plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -36,8 +36,7 @@ async function bench() {
ctx.provide('sessions', sessions) ctx.provide('sessions', sessions)
ctx.provide('layout', layout) ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-layout's root entry: the sidebar slot only exists while // The sidebar slot exists only while its declaring entry is live.
// a live entry declares it in children (declaration account: design §2.2).
slots.register( slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null, () => null,

View File

@@ -10,8 +10,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, useSyncExternalStore } from 'react' import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries // Runtime is React-free, so the spec binds its selector locally.
// no hook (runtime is React-free), so the spec binds the selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { SidebarRoot } from '../src/client/SidebarRoot.tsx'

View File

@@ -142,13 +142,9 @@ export interface SessionAreaProps {
} }
/** /**
* The framework-wired session area component (slot terminal design §7): * Framework-wired session area component. It subscribes to runtime-owned
* subscribes to the current-session selection internally (design fiat ① — * session selection and is injected into entries that declare session-scoped
* selection authority lives with runtime sessions) and switches between the * children; business code does not import it directly.
* session body and the empty branch. Delivered as a standard seat to every
* entry whose children declaration contains a session-scope slot (the
* derivation rides {@link PropsRenderSlots}); the value is injected by the
* installed renderer — business code never imports it.
*/ */
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
@@ -352,8 +348,7 @@ export class SlotCore {
/** /**
* Contribute a component to a declared slot and (optionally) declare child * Contribute a component to a declared slot and (optionally) declare child
* slots, a store seat, and the registrant's business face — the single * slots, a store seat, and the registrant's business face.
* composition API (the separate define API is retired).
* *
* Load-time validation (misconfiguration fails loud; the render hot path * Load-time validation (misconfiguration fails loud; the render hot path
* re-checks nothing): registering into an undeclared slot throws; declaring * re-checks nothing): registering into an undeclared slot throws; declaring

View File

@@ -1,10 +1,4 @@
/** /** React-free contracts between the slot host and an installed renderer. */
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
* web-react's machinery implements, the host surface the runtime SlotsService
* presents to the installed renderer, and the render-path authorization
* errors. Pure types plus two error classes — this package stays React-free
* at runtime (React types only).
*/
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts' import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
@@ -22,7 +16,6 @@ export interface HostObservable<T> {
* typing lands at the component seam via {@link PropsStore}. * typing lands at the component seam via {@link PropsStore}.
*/ */
export interface StoreInstanceLike { export interface StoreInstanceLike {
/** Current state snapshot (uSES getSnapshot side). */
getSnapshot(): unknown getSnapshot(): unknown
/** /**
* Subscribe to state changes (uSES subscribe side). * Subscribe to state changes (uSES subscribe side).
@@ -30,7 +23,6 @@ export interface StoreInstanceLike {
* @returns unsubscribe. * @returns unsubscribe.
*/ */
subscribe(fn: () => void): () => void subscribe(fn: () => void): () => void
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: Record<string, (...params: never[]) => void> readonly actions: Record<string, (...params: never[]) => void>
} }
@@ -97,7 +89,7 @@ export interface SlotRendererHost {
sessions: { sessions: {
/** Session list source backing the useSessions standard hook. */ /** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown> list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */ /** Current-session source used by SessionProvider. */
current: HostObservable<string | undefined> current: HostObservable<string | undefined>
/** /**
* Resolve the session standard kit. * Resolve the session standard kit.

View File

@@ -1,12 +1,4 @@
/** /** Framework-neutral store contracts for slot registrations and the runtime engine. */
* Store-seat type family (slot terminal design §4): a registrant declares its
* shared/exclusive business store as data — schema (`init`), optional
* persistence key, and the complete write set (`actions`) — and the framework
* owns instance lifecycle (scope derives from the mounting entry's slot).
* ui-slots ships the contract types only; the engine-backed `defineStore`
* value lives in web-react (the snapshot-store engine's home) and must
* satisfy {@link DefineStore}.
*/
/** /**
* Typed selector hook over a snapshot source. Canonical shape for the whole * Typed selector hook over a snapshot source. Canonical shape for the whole
@@ -41,11 +33,8 @@ export type BakedActions<T, A extends ActionsDecl<T>> = {
* and the actions write set. * and the actions write set.
*/ */
export interface StoreSpec<T, A extends ActionsDecl<T>> { export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string persist?: string
/** Complete write set: pure draft transforms. */
actions: A actions: A
} }
@@ -58,9 +47,7 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
* call create() themselves — instance lifecycle is the framework's. * call create() themselves — instance lifecycle is the framework's.
*/ */
export interface StoreInstance<T, A extends ActionsDecl<T>> { export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A> readonly actions: BakedActions<T, A>
/** Current state snapshot (uSES getSnapshot side; test assertions). */
getSnapshot(): T getSnapshot(): T
/** /**
* Subscribe to state changes (uSES subscribe side). * Subscribe to state changes (uSES subscribe side).
@@ -84,7 +71,6 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
* identity is a disguised singleton across plugin reloads. * identity is a disguised singleton across plugin reloads.
*/ */
export interface StoreHandle<T, A extends ActionsDecl<T>> { export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A> readonly spec: StoreSpec<T, A>
/** /**
* Create a live engine instance (framework machinery and tests only). * Create a live engine instance (framework machinery and tests only).

View File

@@ -1,10 +1,6 @@
/** /**
* Theme plugin, browser half: ThemeService over the --dsw-* token base * Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
* stylesheets in src/styles/ (the sole token source; components must not * update CSS variables and `body[data-ds-dark-theme]` without React renders.
* hardcode colors). apply(id) toggles body[data-ds-dark-theme] — theming is
* CSS cascade, zero React renders. Contract: api-contracts v3 section 8.
* The base stylesheets ship separately (the web shell imports them as base
* CSS); this plugin only owns the registry and the body-attribute switch.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser implementation exported from `./client`. */
* Theme plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). ThemeService and its
* types live in the client half; consumers import the /client subpath.
* Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the theme plugin. */ /** Host plugin body — no host-side behavior for the theme plugin. */
export function apply(): void {} export function apply(): void {}

View File

@@ -1,9 +1,6 @@
/** /**
* Trajectory/Waterfall plugin, browser half: contributes the two placeholder * Browser trajectory plugin contributing two entries to the conversation
* views into the conversation view ring (the 'conversation.view' list slot * view slot without defining a service.
* declared by ui-conversation). Pure consumer — no ctx service, no Context
* declaration merge; the minimal-plugin exemplar. Contract: api-contracts v3
* section 8.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's // Type-only: the 'conversation.view' SlotMap row (declared by the slot's
@@ -24,8 +21,7 @@ export const inject = ['slots', 'conversation']
/** /**
* Client plugin body: register the trajectory and waterfall view tabs. The * Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload * registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps * removes both tabs).
* the span stats header inside its body (chrome attachment retired).
* @param ctx - client root context. * @param ctx - client root context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only trajectory plugin. */
* Trajectory plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 8.
*/
/** Host plugin body — no host-side behavior for the trajectory plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -59,7 +59,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> } return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
} }
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */ /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() { function emptySessions() {
const store = createSnapshotStore<SessionListState>( const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState) { ids: [], byId: {}, current: undefined } as SessionListState)
@@ -173,7 +173,6 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
expect(screen.getByText('Turn 1')).toBeTruthy() expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy() expect(screen.getByText('Turn 2')).toBeTruthy()

View File

@@ -1,13 +1,4 @@
/** /** React bindings for the framework-neutral slot and snapshot contracts. */
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
* install-seam implementation), SessionProvider (framework-wired render
* prop, also delivered as a standard seat to session-area entries),
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
* snapshot-store engine and defineStore live in runtime (store relocation);
* contract types are ui-slots authority — this face re-exports only what its
* own values traffic in. React contexts stay in-package: business components
* see none.
*/
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
export { bindSnapshotSelector } from './bind.ts' export { bindSnapshotSelector } from './bind.ts'
@@ -20,7 +11,6 @@ export { bindSnapshotSelector } from './bind.ts'
*/ */
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap> export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type { export type {
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook, ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike, SlotRenderer, SlotRendererHost, StoreInstanceLike,
@@ -28,7 +18,6 @@ export type {
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots' export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx' export { createSlotRenderer } from './scoped-slots.tsx'
// -- session area: the framework-wired provider; binding contexts stay internal --
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx' export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export { useInvoke } from './use-invoke.ts' export { useInvoke } from './use-invoke.ts'

View File

@@ -1,19 +1,6 @@
/** /**
* createSlotRenderer(): the outlet machinery behind the runtime install seam * React renderer for declarative slots. Per-entry bindings enforce child
* (slot terminal design §8). renderRoot mounts the host channel and renders * authorization, and entry boundaries contain registrant failures.
* the built-in 'root' key; every deeper slot renders through a per-entry
* renderSlot binding synthesized from the entry's children declaration.
* Standard-kit synthesis per entry: the global useSessions hook, the session
* pair (useSession + sessionId) under SessionProvider, the store pair
* (useStore + actions) for store-declaring entries, the renderSlot binding
* (entry-identity bound, stale-checked) for children-declaring entries, and
* the renderSlotChain binding for entries declaring a chain-kind child
* (selector-routed: first non-null select elects and its value joins the
* props as `matched`; all-null falls to the owner fallback).
* Inject factories run inside the entry component bodies ON PURPOSE
* — the per-entry error boundary contains a throwing factory to its own
* entry; parameters follow the declaration (sessionId for session slots,
* baked actions when a store is declared).
*/ */
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { import {
@@ -27,10 +14,8 @@ import {
type InjectedProps = Record<string, unknown> type InjectedProps = Record<string, unknown>
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
/** /**

View File

@@ -1,11 +1,4 @@
/** /** Internal React bindings for the renderer host and active session cell. */
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
* the two internal channels the render machinery shares: the renderer host
* context (written once by createSlotRenderer's root) and the per-session
* binding context (written here, read by session-scope outlets). Both
* contexts are in-package machinery — they are NOT exported from the package
* index; business components see zero React contexts.
*/
import { createContext, useContext, type ReactNode } from 'react' import { createContext, useContext, type ReactNode } from 'react'
import type { import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook, HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
@@ -20,7 +13,7 @@ import { bindSnapshotSelector } from './bind.ts'
*/ */
export class SlotAssemblyError extends Error {} export class SlotAssemblyError extends Error {}
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */ /** In-package renderer host context. */
export const HostContext = createContext<SlotRendererHost | null>(null) export const HostContext = createContext<SlotRendererHost | null>(null)
/** /**
@@ -34,7 +27,6 @@ export function useHost(): SlotRendererHost {
return host return host
} }
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null) const BindingContext = createContext<SessionCell | null>(null)
/** /**
@@ -75,11 +67,10 @@ export interface SessionProviderProps {
/** /**
* Framework-wired session area: subscribes to the host's current-session * Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions), * source, resolves the session cell, and remounts the body under
* resolves the session cell, and remounts the body under key={sessionId} so * `key={sessionId}` so a session switch rebuilds the session subtree. This
* a session switch rebuilds the whole session subtree. Ids speak plain * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* string at this dependency-inverted layer; branding lands on the component * branded type at the component boundary.
* props seam (PropsRuntime).
*/ */
export function SessionProvider({ empty, children }: SessionProviderProps) { export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost() const host = useHost()

View File

@@ -5,10 +5,8 @@ import { act, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Local one-level equality: the engine's shallowEqual moved to runtime with // Keep equality local: this suite asserts the eq parameter contract without
// the store relocation, and web-react tests must not import runtime (the // adding a reverse dependency from web-react to runtime.
// dependency direction is runtime → web-react). The eq PARAMETER contract is
// what this suite asserts, not any specific equality implementation.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k])) Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))

View File

@@ -1,9 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* Stale renderSlot bindings (slot terminal design §9): a binding dies with * A retained render binding dies with its entry. Re-registering the same key
* its entry — a retained closure invoked after the entry's disposal throws * creates a new binding rather than reviving the stale closure.
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
* entry, same key) mints a NEW binding rather than reviving the old one.
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { act, render } from '@testing-library/react' import { act, render } from '@testing-library/react'

View File

@@ -1,13 +1,6 @@
/** /**
* App-shell assembly plugin (design §3.4): the shell's ONLY composition * App-shell assembly plugin. Its pseudo package id exists only in the host
* responsibility, packaged as a normal static-arrival entry so the host graph * graph and shell registry; there is no npm package behind it.
* stays the single composition authority. It rides the same entry lifecycle
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
* time apply runs the layout entry is mounted and its export surface is
* readable from the governance side (module loadCache, design §2.6).
*
* The pseudo package id exists only in the host graph and the shell's static
* registry; there is no npm package behind it.
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Context } from 'cordis' import type { Context } from 'cordis'
@@ -33,13 +26,11 @@ declare module 'cordis' {
/** Cordis plugin name. */ /** Cordis plugin name. */
export const name = 'app-shell' export const name = 'app-shell'
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */ /** Services required before shell assembly. */
export const inject = ['slots', 'sessions', 'layout'] export const inject = ['slots', 'sessions', 'layout']
/** /** Installs the React renderer and exposes the assembled application.
* Plugin body: install the React renderer into the slot system and provide * @param ctx - Plugin context.
* the renderApp face (one ctx-level renderSlot('root') call).
* @param ctx - plugin context (inject set active).
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
// The renderer install is shell territory (web-react is shell-bundled), // The renderer install is shell territory (web-react is shell-bundled),

View File

@@ -1,10 +1,6 @@
/** /**
* Platform singletons the shell shares into the module table. * Shared browser platform modules. Seeding, bundling externals, and Vite
* Single source of truth (design §3.3, contract C1): seed keys = tsdown * aliases consume this list so their module identities cannot drift.
* client externals = the shared surface. The three projections import this
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
* external judgement (packages/client/tsdown.client.ts), and the vite alias
* check — so the list cannot drift between them.
* @module @deepseek-ai/dsh-client-web/src/platform * @module @deepseek-ai/dsh-client-web/src/platform
*/ */

View File

@@ -188,15 +188,12 @@ describe('Agent', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Simulate an OPEN turn in the log while the agent is idle (status is not a // Status is idle while the log has an open turn; enclosure must follow the log.
// reliable open-turn signal). inject must append into that open turn, NOT
// wrap a new one.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.at(-1)!.type).toBe('user/message') expect(agent.session.events.at(-1)!.type).toBe('user/message')
// Close the turn; now inject must wrap its own one-shot injection turn.
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
const starts = agent.session.events.filter(e => e.type === 'turn/start') const starts = agent.session.events.filter(e => e.type === 'turn/start')
@@ -342,11 +339,9 @@ describe('Agent', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The message was recorded as a user-level message (send path)
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
}) })
@@ -369,12 +364,10 @@ describe('Agent', () => {
prepared.markPublished() prepared.markPublished()
const dispose = prepared.startDriver() const dispose = prepared.startDriver()
// First dispose
const firstDisposal = dispose() const firstDisposal = dispose()
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
await firstDisposal await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined() await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
}) })

View File

@@ -142,8 +142,6 @@ describe('Agent.cancel()', () => {
agent.queue([{ type: 'text', text: 'quiet' }]) agent.queue([{ type: 'text', text: 'quiet' }])
const idle = agent.whenIdle() const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
await idle await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)

View File

@@ -1096,7 +1096,6 @@ describe('step boundary publication order', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
// Append commits before observers run.
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => { ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return if (subject !== agent.session || event.type !== 'step/start') return
@@ -1704,7 +1703,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 50)) await new Promise(r => setTimeout(r, 50))
// Start disposal, then release the block, then await disposal.
const disposalDone = fiber.dispose() const disposalDone = fiber.dispose()
releasePreStep() releasePreStep()
await disposalDone await disposalDone

View File

@@ -283,20 +283,17 @@ describe('SurfaceManager', () => {
it('empty surface yields empty nodes', () => { it('empty surface yields empty nodes', () => {
const s = new Session(SessionId('empty')) const s = new Session(SessionId('empty'))
// Only turn boundaries, no surface nodes.
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 }) s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 }) s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.nodes.length).toBe(0) expect(s.surface.nodes.length).toBe(0)
// deriveMessages returns empty array
expect(s.deriveMessages()).toEqual([]) expect(s.deriveMessages()).toEqual([])
}) })
it('picks up new events incrementally (delta processing)', () => { it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession() const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2) expect(s.surface.nodes.length).toBe(2)
// Append another surface node
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
expect(s.surface.nodes.length).toBe(3) expect(s.surface.nodes.length).toBe(3)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3 expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
@@ -306,7 +303,6 @@ describe('SurfaceManager', () => {
const original = surfaceSession() const original = surfaceSession()
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events]) const replayed = new Session(SessionId('replay'), [...original.events])
// Surface rebuilds from the seeded log's markers.
expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
}) })

View File

@@ -1893,7 +1893,6 @@ describe('ToolRegistry', () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
// Register a second tool and call its returned disposer directly
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' }) const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable']) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
@@ -2055,9 +2054,7 @@ describe('defineTool / schema DSL', () => {
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } }, parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
output: { schema: { type: 'string' }, render: () => [] }, output: { schema: { type: 'string' }, render: () => [] },
async execute(args) { async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string') expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args void args
return args.a return args.a
}, },

View File

@@ -472,8 +472,8 @@ export async function writeFileAtomic(
try { try {
await replaceFile(absolutePath, tempPath) await replaceFile(absolutePath, tempPath)
} catch (error: unknown) { } catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during // If the observed target disappears during staging, the protected DACL
// staging: the temp already carries that target's protected DACL, so rename recreates it. // already copied to the temp remains authoritative for recreation.
if (!isENOENT(error)) throw error if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath) await rename(tempPath, absolutePath)
} }

View File

@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts' import { fsHarness, waitForIdle } from './harness.ts'
/** /** Key-gated smoke for a real model driving the local read/write/edit tools. */
* With-key smoke for the filesystem tools: a REAL model drives the REAL
* read/write/edit tools (over the real local backend + policy gate), and we
* verify the WORLD — the file on disk — not the agent's self-report. This is the
* "green units, broken product" guard: mocks prove the plumbing, only a real
* model proves the tools actually work end-to-end. Key-gated (self-skips without
* DEEPSEEK_API_KEY).
*/
let ctx: Context | undefined let ctx: Context | undefined
let workdir: string | undefined let workdir: string | undefined
@@ -42,7 +35,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
+ 'Tell me when done.' }]) + 'Tell me when done.' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// Verify the WORLD: the edit landed on disk. // Assert the filesystem effect independently of the model response.
const content = await readFile(join(workdir, 'note.txt'), 'utf8') const content = await readFile(join(workdir, 'note.txt'), 'utf8')
expect(content).toContain('status: final') expect(content).toContain('status: final')
expect(content).not.toContain('draft') expect(content).not.toContain('draft')

View File

@@ -321,10 +321,9 @@ describe('fold onto the downstream decision', () => {
const found = reminders(agent) const found = reminders(agent)
expect(found).toHaveLength(3) expect(found).toHaveLength(3)
// Call 1: below threshold — the downstream context passes through untouched. // Only the repeated call adds guard context; downstream provenance survives.
expect(found[0]!.text).toBe('downstream-ctx') expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' }) expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
// Call 2: reminder and downstream context retain separate provenance.
expect(found[1]!.text).toContain('repeating the exact same tool call') expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.source).toEqual(GUARD_SOURCE) expect(found[1]!.source).toEqual(GUARD_SOURCE)
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })

View File

@@ -150,7 +150,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, new MockAdapter([])) const ctx = await harness(path, new MockAdapter([]))
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm') const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
expect(ran).toBe(false) expect(ran).toBe(false)
@@ -159,7 +158,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
it('a long stderr is truncated in the hook/result summary', async () => { it('a long stderr is truncated in the hook/result summary', async () => {
const d = dir() const d = dir()
// Emit >500 chars of stderr then exit 2.
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
@@ -236,7 +234,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([])) const ctx = await harness(path, new MockAdapter([]))
// Register a fake child agent under the id the event carries.
const injected: string[] = [] const injected: string[] = []
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child) ctx.agents.register(child)
@@ -693,7 +690,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([])) ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
// Register a live child on its own session cwd; emit subagent/end with its id.
const { SessionId } = await import('@deepseek-ai/dsh-session') const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
@@ -735,7 +731,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Send immediately — do NOT wait for the session-start inject.
agent.followup([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing

View File

@@ -258,7 +258,6 @@ describe('createHostWebPluginRegistry', () => {
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1)
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
// After dispose, further fiber events no longer rescan.
registry.dispose() registry.dispose()
entries.pop() entries.pop()
ctx.emit('internal/plugin', ctx.fiber) ctx.emit('internal/plugin', ctx.fiber)

View File

@@ -51,7 +51,7 @@ const activeServerNames = new WeakMap<Context, Set<string>>()
/** Config for connecting to an MCP server via a spawned child process over stdio. */ /** Config for connecting to an MCP server via a spawned child process over stdio. */
export interface StdioConfig { export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */ /** Selects child-process stdio transport. */
transport: 'stdio' transport: 'stdio'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -59,21 +59,21 @@ export interface StdioConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** Executable to spawn. */ /** Executable used to start the server. */
command: string command: string
/** Arguments passed to the command. */ /** Arguments passed directly, without shell interpolation. */
args: string[] args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */ /** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string> env: Record<string, string>
/** Working directory for the child process. */ /** Working directory for the child process. */
cwd: string cwd: string
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ /** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig { export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ /** Selects Streamable HTTP transport. */
transport: 'streamable-http' transport: 'streamable-http'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -81,15 +81,15 @@ export interface StreamableHttpConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** MCP server URL. */ /** MCP endpoint URL. */
url: string url: string
/** Extra headers (e.g. auth tokens). */ /** Additional headers attached to MCP requests. */
headers: Record<string, string> headers: Record<string, string>
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
/** Discriminated union of all supported MCP transport configurations. */ /** Configuration for one stdio or Streamable HTTP MCP server. */
export type Config = StdioConfig | StreamableHttpConfig export type Config = StdioConfig | StreamableHttpConfig
export const Config = z.union([ export const Config = z.union([

View File

@@ -213,13 +213,11 @@ describe('apply (plugin lifecycle)', () => {
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
// Simulate the notification handler being invoked with a new tool list.
mockListTools.mockResolvedValue({ mockListTools.mockResolvedValue({
tools: [{ name: 'updated', inputSchema: { type: 'object' } }], tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
nextCursor: undefined, nextCursor: undefined,
}) })
// Extract and call the notification handler.
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void> const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
await handler() await handler()

View File

@@ -314,18 +314,16 @@ describe('server-filesystem — real filesystem operations', () => {
const filePath = join(tempDir, 'test.txt') const filePath = join(tempDir, 'test.txt')
const content = 'Hello from MCP e2e test!' const content = 'Hello from MCP e2e test!'
// Write via MCP tool
const writeResult = await ctx.tools.execute({ const writeResult = await ctx.tools.execute({
signal: testToolSignal, signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
}) })
expect(writeResult.isError).toBe(false) expect(writeResult.isError).toBe(false)
// Verify file was actually written (world verification) // Assert the filesystem effect independently of the tool result.
const onDisk = await readFile(filePath, 'utf8') const onDisk = await readFile(filePath, 'utf8')
expect(onDisk).toBe(content) expect(onDisk).toBe(content)
// Read back via MCP tool
const readResult = await ctx.tools.execute({ const readResult = await ctx.tools.execute({
signal: testToolSignal, signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
@@ -335,7 +333,6 @@ describe('server-filesystem — real filesystem operations', () => {
}) })
it('list_directory shows written file', async () => { it('list_directory shows written file', async () => {
// Ensure a file exists
await writeFile(join(tempDir, 'listed.txt'), 'listed') await writeFile(join(tempDir, 'listed.txt'), 'listed')
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({

View File

@@ -811,7 +811,6 @@ describe('tool execution — non-object args fallback', () => {
) )
await syncTools(client as never, ctx, defaultOpts, new Map()) await syncTools(client as never, ctx, defaultOpts, new Map())
// Simulate model emitting `null` as tool arguments (malformed).
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
expect(client.callTool).toHaveBeenCalledWith( expect(client.callTool).toHaveBeenCalledWith(

View File

@@ -102,7 +102,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
await run.dispose() await run.dispose()
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
// Verify the WORLD: the child process actually wrote the file in its cwd. // Assert the filesystem effect independently of the model response.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('ACP_CHILD_WAS_HERE') expect(proof).toContain('ACP_CHILD_WAS_HERE')
}, 180_000) }, 180_000)

View File

@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
import { spawnHarness, waitForIdle } from './harness.ts' import { spawnHarness, waitForIdle } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session'
/** /** Key-gated smoke for a real parent delegating filesystem work to a real child. */
* With-key smoke for the in-process spawn backend: a REAL parent agent delegates
* to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL
* bash tool to write a file, and we verify the WORLD (the file on disk) — not
* the agent's self-report. This is the "green units, broken product" guard:
* mocks prove the plumbing, only a real model proves a parent can actually drive
* a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY).
*/
let ctx: Context | undefined let ctx: Context | undefined
let workdir: string | undefined let workdir: string | undefined
@@ -37,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
+ 'After the subagent finishes, tell me it is done.' }]) + 'After the subagent finishes, tell me it is done.' }])
await waitForIdle(ctx, parent) await waitForIdle(ctx, parent)
// Verify the WORLD: the child actually wrote the file. // Assert the filesystem effect independently of the model response.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('SUBAGENT_WAS_HERE') expect(proof).toContain('SUBAGENT_WAS_HERE')

View File

@@ -1,7 +1,5 @@
/** /**
* Subagent seam vocabulary: the request/result/capability types a * Request, result, and capability contracts for {@link SubagentProvider}.
* {@link SubagentProvider} consumes and produces. No runtime code — types
* only, per the package convention.
* *
* @module @deepseek-ai/dsh-subagent/types * @module @deepseek-ai/dsh-subagent/types
*/ */
@@ -17,7 +15,7 @@ export type SubagentRunId = Branded<'SubagentRunId'>
/** /**
* Brand a string as a {@link SubagentRunId}. * Brand a string as a {@link SubagentRunId}.
* @param id - the raw id string (the service mints UUIDs; tests may pass fixtures). * @param id - the raw run id.
* @returns the same string, branded. * @returns the same string, branded.
*/ */
export function SubagentRunId(id: string): SubagentRunId { export function SubagentRunId(id: string): SubagentRunId {
@@ -33,13 +31,9 @@ export function SubagentRunId(id: string): SubagentRunId {
* is the capability. * is the capability.
*/ */
export interface SubagentCapabilities { export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
@@ -50,16 +44,11 @@ export interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}. * passes it to {@link SubagentProvider.start}.
*/ */
export interface SubagentStartRequest { export interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/** /**
* The spawning ("parent") agent — the one whose tool call started this * The spawning agent. In-process providers derive workspace, lineage, and
* subagent. REQUIRED: in-process backends read `parent.session.header` for * delegation depth from its durable session state; ACP uses only its cwd.
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/ */
readonly parent: Agent readonly parent: Agent
/** /**
@@ -70,7 +59,6 @@ export interface SubagentStartRequest {
* afterward. * afterward.
*/ */
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -110,15 +98,12 @@ export interface SubagentStartRequest {
* non-`completed` result to an `isError` tool result. * non-`completed` result to an `isError` tool result.
*/ */
export interface SubagentStopReasonMap { export interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */ /** Cancelled through the request signal or disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */ /** Model or transport failure. */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal' refusal: 'refusal'
} }
@@ -170,9 +155,8 @@ export interface SubagentRun {
*/ */
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/** /**
* Cancel remaining work, reach child quiescence, and release the run's * Cancel remaining work, reach child quiescence, and release resources.
* resources (in-process: dispose the owned agent and remove its session; * Idempotent.
* ACP: kill and reap the subprocess). Idempotent.
*/ */
dispose(): Promise<void> dispose(): Promise<void>
/** /**
@@ -188,12 +172,9 @@ export interface SubagentRun {
} }
/** /**
* A subagent backend: one transport for running a child agent (in-process * One registered transport for running child agents. Providers are trusted
* spawn/fork, ACP to another process, …). Implementations register under a * same-process implementations; callers treat descriptors and returned values
* unique name via {@link SubagentService.registerProvider}; multiple providers * as borrowed immutable data.
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/ */
export interface SubagentProvider { export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -61,26 +61,16 @@ import {
import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference' import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service. // Empty type imports activate the service and event declaration merges used
// through ctx.get() and ctx.on().
import type {} from '@deepseek-ai/dsh-permission' import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: adds the log-only session/title event translated below.
import type {} from '@deepseek-ai/dsh-session-title' import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges the exact-read service used by
// session/list for live-preferred title folding.
import type {} from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-session-query'
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
// the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-plan-mode' import type {} from '@deepseek-ai/dsh-plan-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-system-prompt'
// Side-effect type import: declaration-merges the `approval/request` waterfall
// the bridge answers for its own agents (see the approval answerer below).
import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-approval'
import { import {
UserInteractionError, UserInteractionError,
@@ -871,18 +861,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
// slot is released in `finally` so a rejected load never wedges the id. // slot is released in `finally` so a rejected load never wedges the id.
loadingIds.add(sessionId) loadingIds.add(sessionId)
try { try {
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a // Validate metadata before resume so rejection cannot leave a
// metadata-only read (no full-log parse), so this rejects a session we // registered agent. Persisted cwd is authoritative for resumed work.
// can't honor WITHOUT ever constructing/registering an agent (a
// post-resume reject would leak the registered agent — cancel() does not
// unregister it — and wedge the id against re-load). The session's bash
// workdir is derived from its persisted `header.cwd` and the request
// `cwd` does NOT override it (resume takes no cwd), so a session with no
// absolute persisted cwd would silently run bash in the SERVER's launch
// dir, not the client's workspace. A session created by this bridge
// always has a cwd (session/new requires it); reject the rest loudly.
// (An id unknown to `list()` falls through to resume, which rejects with
// the backend's not-found error.)
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId) const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) { if (meta !== undefined) {
const persistedCwd = meta.cwd const persistedCwd = meta.cwd
@@ -903,12 +883,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentOptions: agentOptions(config), agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) }, setup: (agentCtx) => { installTarget(agentCtx, target) },
}) })
// The bridge may have torn down (disposal / client disconnect) while // Closure can race resume. Dispose the unpublished agent before
// resume() was pending. Its listeners are gone, so installing a record // rejecting because quiesce() tracks only installed records.
// now would resurrect a live agent the bridge can no longer drive. Bail —
// and tear down the just-resumed agent (unregister + stop + remove its
// session) before throwing, so it does not leak: it has no SessionRecord,
// so quiesce() would never see it.
/* v8 ignore next 4 -- the in-memory test transport rejects the in-flight /* v8 ignore next 4 -- the in-memory test transport rejects the in-flight
session/load request the instant it closes (before this post-await session/load request the instant it closes (before this post-await
code runs), so the guard can't be hit in tests; it protects the real code runs), so the guard can't be hit in tests; it protects the real
@@ -937,19 +913,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
pendingSwitches: {}, pendingSwitches: {},
} }
sessions.set(sessionId, record) sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update. Use // Replay the raw log because deriveMessages omits update-bearing
// the raw event log (NOT deriveMessages, which drops assistant/chunk // chunks and trace events. A disposable presenter prevents an
// and trace events): RFC 010's load contract reconstructs the streamed // incomplete historical tool call from polluting live presentation.
// turns — user prompts (user/message → user_message_chunk), assistant
// text and reasoning (assistant/chunk), and tool calls/results.
//
// Replay through a THROWAWAY presenter, NOT `record.presenter`: a
// historical turn that was interrupted mid-tool (a `tool/call` with no
// matching `tool/result` in the persisted log) would otherwise leave a
// stale in-flight entry on the live presenter, which then serves all
// future live events for this session. The throwaway pairs call→result
// as the log replays in order (same as live) and is discarded after,
// so the record's presenter starts clean for the post-load live stream.
const replayPresenter = makePresenter(agent) const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = { const replayTerminal: TerminalRendering = {
enabled: terminalEnabled, enabled: terminalEnabled,
@@ -1081,11 +1047,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
} }
assertOpen() assertOpen()
} }
// Install the in-flight slot BEFORE followup() (followup does not synchronously // Install before followup(), which does not synchronously enter running;
// flip status to running; the session/event listener records the turn // the session listener associates and settles the message-triggered turn.
// number and settle/rejects it). Capture the log length now as the // Error turns reject because ACP has no error stop reason.
// A turn that ends in error rejects this promise (the codec never
// produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => { const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined } rec.inflight = { resolve, reject, turn: undefined }
rec.agent.followup(preparedContent, { contexts: preparedContexts }) rec.agent.followup(preparedContent, { contexts: preparedContexts })
@@ -1096,18 +1060,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> { cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId)) const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve() if (rec === undefined) return Promise.resolve()
// session/cancel maps to the queue-aware agent.cancel({ kind: 'user' }): it aborts // Agent cancellation may drop a pre-step turn without emitting
// a RUNNING step, clears the queued + steering FIFOs, and drops a // turn/end, so the bridge settles its prompt directly.
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, while a prompt accepted afterward
// remains a separate queued turn. Scoped to THIS session's
// agent — a cancel in one session never touches another's stream or
// pending prompt (multi-session isolation).
// We ALSO settle the in-flight prompt
// as cancelled directly here: do NOT rely on the resulting turn/end to
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
if (rec.promptPreparation !== undefined) { if (rec.promptPreparation !== undefined) {
rec.promptPreparation.abort(new Error('session/cancel')) rec.promptPreparation.abort(new Error('session/cancel'))
} else if (rec.commandAbort !== undefined) { } else if (rec.commandAbort !== undefined) {
@@ -1266,7 +1220,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
/** /**
* Build per-agent options from the plugin config, omitting absent fields * Build per-agent options from the plugin config, omitting absent fields
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
* Exported for unit coverage of both the present and absent branches.
* @param config - the plugin config carrying the optional provider/model target. * @param config - the plugin config carrying the optional provider/model target.
* @returns the per-agent options, with each configured target field present. * @returns the per-agent options, with each configured target field present.
*/ */
@@ -1278,22 +1231,10 @@ export function agentOptions(config: AcpConfig): { provider?: string; model?: st
} }
/** /**
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new` * Validate the workspace shape shared by `session/new` and `session/load`.
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous * `cwd` is an absolute per-session workspace; load separately requires it to
* as a workspace root). The persisted-cwd equality check for `session/load` * match persisted metadata. Additional roots are rejected because ignoring
* happens after the metadata lookup; this validator only enforces request shape: * them would desynchronize the client's displayed filesystem scope.
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
* - `session/load`: the request `cwd` must be absolute AND must match the
* PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
* the request cwd does not override it.
* Any absolute path is accepted (the per-session cwd flows to the bash executor
* — see `dsh-tool-bash`), so the server no longer has to launch in the
* workspace. `additionalDirectories` must still be empty: widening the
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
* concern (a sandbox seam), and silently ignoring extra roots would desync the
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
* `additionalDirectories?: string[]`, so one validator covers both.
*/ */
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void { function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) { if (!isAbsolute(params.cwd)) {

View File

@@ -277,15 +277,10 @@ describe('acp bridge', () => {
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
harness = await makeBridgeHarness({ storageDir }) harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// Relative cwd is still rejected (it becomes the session header / bash workdir).
await expect(harness.client.newSession({ cwd: 'relative/path', mcpServers: [] })) await expect(harness.client.newSession({ cwd: 'relative/path', mcpServers: [] }))
.rejects.toThrow(/absolute/) .rejects.toThrow(/absolute/)
// An absolute cwd that differs from the server launch dir is now ACCEPTED —
// the per-session cwd is honored (routed to the bash workdir), so the server
// no longer has to launch in the workspace.
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
expect(res.sessionId).toBeTruthy() expect(res.sessionId).toBeTruthy()
// The session header records that cwd, so its bash tools run there.
expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp') expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp')
}) })

View File

@@ -338,7 +338,7 @@ describe('acp bridge — session config options', () => {
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => { it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
h = await presetStack() h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
// Simulate a plugin calling the public knob setter inside a valid turn. // A plugin may write a valid state not represented by a named preset.
const agent = h.ctx.agents.list()[0] const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent') if (agent === undefined) throw new Error('expected an agent')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })

View File

@@ -18,14 +18,11 @@ describe('acp bridge — disposal & HMR safety', () => {
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))! const agent = harness.ctx.agents.get(SessionId(sessionId))!
// Start a prompt that hangs in the model stream.
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running') expect(agent.status).toBe('running')
// Dispose the whole context. The bridge's teardown must abort the agent and // Fiber disposal must not resolve until the running agent is quiescent.
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
// (not still running). Proves disposal waited, not just requested.
await harness.ctx.fiber.dispose() await harness.ctx.fiber.dispose()
expect(agent.status).not.toBe('running') expect(agent.status).not.toBe('running')
@@ -84,35 +81,21 @@ describe('acp bridge — disposal & HMR safety', () => {
}) })
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
// The ACP transport closes (editor quits) while a turn runs. The bridge must // Transport closure owns the agent handle; idle-but-registered is also a leak.
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
// or even idled-but-still-registered — agent whose updates are swallowed.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))! const agent = harness.ctx.agents.get(SessionId(sessionId))!
// Start a prompt that hangs in the model stream. The prompt RPC will never // The transport will sever this RPC, so do not await it.
// return (its transport is severed), so do not await it.
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running') expect(agent.status).toBe('running')
// Sever the transport — the bridge's conn.closed teardown runs and drives the
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
await harness.closeClientTransport() await harness.closeClientTransport()
await agent.whenIdle() await agent.whenIdle()
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
// Await the bridge teardown to completion WITHOUT tearing down the root // Fiber disposal joins the disconnect teardown while root services remain queryable.
// agents/sessions services (so we can still query them). acpFiber.dispose()
// invokes the SAME memoized quiesce() the disconnect started and awaits its
// promise — which resolves only after every rec.dispose() (loop exit +
// session removal) has finished, closing the whenIdle()/owned.dispose()
// microtask race. The AgentHandle dispose has run: the agent is unregistered
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
await harness.acpFiber.dispose() await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -148,8 +131,6 @@ describe('acp bridge — disposal & HMR safety', () => {
await harness.ctx.fiber.dispose() await harness.ctx.fiber.dispose()
const before = harness.updates.length const before = harness.updates.length
// Append an event directly to the (now-detached) session: the bridge's
// session/event listener should have been disposed, so no update fires.
session.append('turn/start', { turn: 99, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 99, trigger: { kind: 'message', source: { kind: 'user' } } })
await new Promise(r => setTimeout(r, 10)) await new Promise(r => setTimeout(r, 10))
expect(harness.updates.length).toBe(before) expect(harness.updates.length).toBe(before)
@@ -239,11 +220,9 @@ describe('acp bridge — disposal & HMR safety', () => {
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
await handleA.dispose() await handleA.dispose()
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined() expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed') expect(handleA.agent.status).toBe('disposed')
// B is wholly unaffected.
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed') expect(handleB.agent.status).not.toBe('disposed')

View File

@@ -1,9 +1,6 @@
/** /**
* Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013 * Property tests exercise the pure event translator so live/replay equivalence
* precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure * and per-call ordering remain deterministic rather than timing-dependent.
* `streamSessionEventUpdate` translator and assert legal update variants, call-before-result order
* per tool id, and deterministic event-to-update translation. Keeping this pure makes live and
* replay equivalence deterministic rather than a timing property.
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'

View File

@@ -3484,8 +3484,7 @@ describe('terminal mounting', () => {
await tick() await tick()
expect(result.terminal.output.length).toBe(beforeSameScheme) expect(result.terminal.output.length).toBe(beforeSameScheme)
// Simulate the terminal responding with a light color scheme report // ESC [?997;2n reports light; ESC [?997;1n reports dark.
// (ESC [?997;2n = light, ESC [?997;1n = dark).
result.terminal.send('\x1b[?997;2n') result.terminal.send('\x1b[?997;2n')
await tick() await tick()
await tick() await tick()
@@ -3497,11 +3496,9 @@ describe('terminal mounting', () => {
// uses ANSI 90 for the same header text. // uses ANSI 90 for the same header text.
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
// Switch back to dark scheme.
result.terminal.send('\x1b[?997;1n') result.terminal.send('\x1b[?997;1n')
await tick() await tick()
await tick() await tick()
// After switching back, a new write uses SGR 2 for the header detail.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await dispose(result) await dispose(result)
}) })