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
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
* `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.
* Browser stand-in for `node:module`. `createRequire` is unreachable in the
* configured loader path and fails loud if that assumption changes.
*/
/** 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}.`
await input.fill(prompt)
await input.press('Enter')
// startSession chain: session mounts, composer moves to the bottom.
// Regression pin (P0, 585671106): this send used to white-screen the tree
// (scope tag lost to a duplicate inlined runtime instance) — body going
// near-empty here means that class of bug is back.
// The first send must keep the session tree mounted; a near-empty body
// reveals a duplicate runtime bundle with incompatible scope tags.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([])
await page.waitForFunction(

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073
subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b
subagent.md: fda4b4b738c648c893c65a633e4a0d6a1761424f
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.
*/
interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
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}.
*/
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[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* 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.
* The spawning agent. In-process providers derive workspace, lineage, and
* delegation depth from its durable session state; ACP uses only its cwd.
*/
readonly parent: Agent
/**
@@ -65,7 +56,6 @@ interface SubagentStartRequest {
* afterward.
*/
readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* 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.
*/
interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */
/** Cancelled through the request signal or disposal. */
aborted: 'aborted'
/** The child failed (model error, transport error). */
/** Model or transport failure. */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal'
}
```
@@ -180,9 +167,8 @@ interface SubagentRun {
*/
readonly result: Promise<SubagentResult>
/**
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
* Cancel remaining work, reach child quiescence, and release resources.
* Idempotent.
*/
dispose(): Promise<void>
/**
@@ -206,12 +192,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi
```ts type-equiv
/**
* A subagent backend: one transport for running a child agent (in-process
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* 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.
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
*/
interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -22,13 +22,9 @@ subagent seam一个 agent智能体将工作委派给子 agent。与 [ba
* is the capability.
*/
interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean
}
```
@@ -45,16 +41,11 @@ interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}.
*/
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[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* 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.
* The spawning agent. In-process providers derive workspace, lineage, and
* delegation depth from its durable session state; ACP uses only its cwd.
*/
readonly parent: Agent
/**
@@ -65,7 +56,6 @@ interface SubagentStartRequest {
* afterward.
*/
readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* 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.
*/
interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */
/** Cancelled through the request signal or disposal. */
aborted: 'aborted'
/** The child failed (model error, transport error). */
/** Model or transport failure. */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal'
}
```
@@ -182,9 +169,8 @@ interface SubagentRun {
*/
readonly result: Promise<SubagentResult>
/**
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
* Cancel remaining work, reach child quiescence, and release resources.
* Idempotent.
*/
dispose(): Promise<void>
/**
@@ -208,12 +194,9 @@ interface SubagentRun {
```ts type-equiv
/**
* A subagent backend: one transport for running a child agent (in-process
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* 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.
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
*/
interface SubagentProvider {
/** 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)
// 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')
expect(proof).toContain('ACP_OK')
// And the client saw tool-call activity stream through.
const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
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).
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()
// 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
* section 3; export inventory = v3 §3.2). The wire is this package's client
* half in its entirety — apply mounts ctx.connection: the shared api client
* 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.
* Browser wire client. The plugin selects fixture or HTTP transport, provides
* the shared API client, and lets the runtime object layer start the stream
* controller with its sinks.
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
@@ -23,9 +20,8 @@ export type {
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract;
// the controller class itself stays package-internal — apply owns the loop,
// tests reach it via src) ----
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }

View File

@@ -1,10 +1,4 @@
/**
* 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 loader entry for the browser wire client exported from `./client`. */
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}

View File

@@ -1,15 +1,10 @@
/**
* i18n plugin, browser half: namespace x locale dictionary registry with a
* bound translate function whose reference is stable (safe for inject
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers.
*/
import type { Context } from 'cordis'
// The snapshot-store engine lives in runtime (store relocation): framework
// data stores like this locale cell use it directly. The store carries no
// 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).
// Snapshot stores are framework-neutral; React consumers bind hooks at their
// rendering boundary.
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'

View File

@@ -1,11 +1,4 @@
/**
* 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 loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {}

View File

@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
}
}
// ---- defineStore shell (slot terminal design §4) ----
// 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.
// ui-slots owns the contract; this module supplies the engine implementation.
/** A live engine instance: the contract instance plus the raw engine store. */
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) —
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* 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).
* Browser runtime services for slots, sessions, and connection-stream
* delivery. The web shell mounts this static client entry through the host
* plugin graph.
*/
import type { Context } from 'cordis'
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'
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 { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
@@ -35,21 +26,11 @@ export type {
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// 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.
*/
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context
/** 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.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
/** Props injected into every global slot component. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
}
}
@@ -99,9 +78,8 @@ declare module 'cordis' {
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/**
* Client plugin body: mount slots + sessions, start the stream loop.
* @param ctx - client cordis context.
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)

View File

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

View File

@@ -291,8 +291,7 @@ export class SessionsService {
fiber,
ctx,
binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
// Session is the observable; React binds a selector hook at its own seam.
cell: { sessionId: id, session },
}
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
// 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.
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/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 { 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
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* 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.
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- 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. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// Revision counters preserve array identity when derived content is unchanged, so
// 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
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
@@ -69,9 +63,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false
private promptError: PromptError | 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 }[] = []
/** 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
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
@@ -292,8 +286,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
}
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
/** No-op because session instances remain resident. */
dispose(): void {}
// ---- 私有 ----

View File

@@ -1,11 +1,4 @@
/**
* 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 loader entry for the browser runtime exported from `./client` and `./loader`. */
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}

View File

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

View File

@@ -1,14 +1,4 @@
/**
* 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.
*/
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
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 { 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']
/** 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
}
/**
* Client plugin body.
* @param ctx - client root context.
/** Mounts the conversation plugin.
* @param ctx - Client root context.
*/
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const layout = ctx.layout
const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with
// 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.
// Apply-time construction keeps store identity bound to this fiber.
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 tabs: ViewTab[] = []
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
// 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).
// Settled-node identity prevents stream-delta updates from rerendering this row.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -1,15 +1,4 @@
/**
* 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.
*/
/** Conversation slot declarations and their composed component props. */
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 { 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). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* 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.
*/
/** Business callbacks injected into the conversation slot. */
export interface ConversationInjected {
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
/** Views projected from the `conversation.view` slot ledger. */
views: {
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
@@ -111,7 +94,6 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
}
@@ -123,7 +105,6 @@ export interface ConversationInjected {
* with zero owner changes.
*/
export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[]
}
@@ -139,7 +120,6 @@ export type ConversationSlotProps =
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
}

View File

@@ -1,14 +1,4 @@
/**
* 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).
*/
/** Shared conversation view, selection, and store-state contracts. */
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
export interface ViewTab { id: string; label: string }
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation, chat-view, and details registrations. `createChatStore`
* 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).
* Per-session state shared by conversation, chat-view, and details slots.
* Unknown persisted view ids fall back to the first registered view.
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */

View File

@@ -1,12 +1,7 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* the 'conversation.view' slot ring (chat entry here; other plugins
* contribute view tabs through ctx.slots), the chat view's keyed
* '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.
* Browser conversation plugin. `contract/` is the shared type boundary
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
import type { ConversationService } from './service.ts'

View File

@@ -1,17 +1,11 @@
/**
* ConversationService implementation: scope-addressed send/cancel and the
* 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-addressed conversation send, cancel, and empty-state session startup.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
* read the session tag with scopeOf (same mechanism as the host tool
* registry). Mutable state lives in plain objects reached by one property
* read — field assignment through the tracker's shadow proxy is off-limits,
* as are `#` hard-private fields.
* read the session tag with `scopeOf`. Mutable state must remain reachable
* through one property read; assignment through the tracker proxy and `#`
* private fields bypass that rebinding.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'

View File

@@ -1,12 +1,5 @@
// InputBar: the one composer input (figma Input_Bottom). The same component
// serves the empty state (variant='hero': centered launch card) and the
// 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.
// Shared empty-state and resident composer. Running retains the draft, locks
// the textarea, and exposes only Stop. Bottom controls are local visual state.
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
@@ -25,10 +18,8 @@ export interface InputBarProps {
running: boolean
disabled: boolean
error: InputBarError | null
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void

View File

@@ -1,21 +1,11 @@
/**
* Chat store factory (slot terminal design §4): selection + draft + active
* view for one session, shared by the conversation and details registrations
* (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).
* Per-session chat store shared by conversation and details registrations.
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
@@ -25,18 +15,11 @@ type ChatActions = {
}
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (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).
* Declares the per-session chat state and write surface.
* @returns the store handle.
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
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 }),
persist: 'dsh.conversation.chat',
actions: {

View File

@@ -1,10 +1,4 @@
/**
* 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 loader entry for the browser-only conversation plugin. */
/** Host plugin body — no host-side behavior for the conversation plugin. */
/** Provides no host-side behavior. */
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'])
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)

View File

@@ -1,10 +1,5 @@
// @vitest-environment jsdom
/**
* 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).
*/
/** Chat-store actions, scoped persistence, and instance isolation. */
import { beforeEach, describe, expect, it } from 'vitest'
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 () => {
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(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => 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,
})
/** 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() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)

View File

@@ -1,9 +1,4 @@
// @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 { 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
* migration (runtime is React-free); the renderer binds in production, specs
* 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.
* Test-local selector binding through the production uSES implementation.
* Runtime remains React-free, so specs bind observable sources here.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'

View File

@@ -1,13 +1,7 @@
// @vitest-environment jsdom
/**
* Selection survival across the store seat (terminal design §4): the chat
* store now carries what the per-scope selection account used to — this pins
* 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.
* Exercises selection persistence through the real SlotsService store axis;
* component stubs cannot prove per-session identity or disposal.
*/
import { Context } from 'cordis'
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 { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
// Use the runtime's programmable fake to drive the real session service.
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId

View File

@@ -1,10 +1,4 @@
/**
* 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 loader entry for the browser-only layout plugin. */
/** Host plugin body — no host-side behavior for the layout plugin. */
/** Provides no host-side behavior. */
export function apply(): void {}

View File

@@ -42,7 +42,7 @@ class ResizeObserverStub {
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 }) {
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,
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
* token vars only. Contract: api-contracts v3 section 8.
* Cordis-free React primitives styled only through `--dsw-*` tokens.
*/
export { StateDot } from './StateDot.tsx'

View File

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

View File

@@ -1,12 +1,5 @@
/**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
* 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
* Collapse is a slide plus crossfade: content freezes at its expanded
* width (inline style) and fades out in place while the sliding column
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
* the wide-only content (brand, labels, input, tree) unmounts, dropping
@@ -35,7 +28,7 @@ const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ 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: '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. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
// Selection belongs to the sessions snapshot, not layout state.
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])

View File

@@ -1,30 +1,19 @@
/**
* 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.
*/
/** Registers the sidebar UI into the layout-owned slot. */
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
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']
/**
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
* 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.
/** Registers the sidebar component and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the
// list snapshot); layout keeps only panel geometry.
// Selection belongs to the sessions service; layout owns only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Top-level New Session / New Workspace: clear selection so AppFrame

View File

@@ -1,11 +1,4 @@
/**
* 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.
*/
/** Pure derivation of flat sidebar rows from sessions and local view state. */
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for sessions without a project directory. */

View File

@@ -1,10 +1,4 @@
/**
* 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 loader entry for the browser-only sidebar plugin. */
/** Host plugin body — no host-side behavior for the sidebar plugin. */
/** Provides no host-side behavior. */
export function apply(): void {}

View File

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

View File

@@ -10,8 +10,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries
// no hook (runtime is React-free), so the spec binds the selector locally.
// Runtime is React-free, so the spec binds its selector locally.
import { createSnapshotStore } 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'

View File

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

View File

@@ -1,10 +1,4 @@
/**
* 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).
*/
/** React-free contracts between the slot host and an installed renderer. */
import type { ReactNode } from 'react'
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}.
*/
export interface StoreInstanceLike {
/** Current state snapshot (uSES getSnapshot side). */
getSnapshot(): unknown
/**
* Subscribe to state changes (uSES subscribe side).
@@ -30,7 +23,6 @@ export interface StoreInstanceLike {
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: Record<string, (...params: never[]) => void>
}
@@ -97,7 +89,7 @@ export interface SlotRendererHost {
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
/** Current-session source used by SessionProvider. */
current: HostObservable<string | undefined>
/**
* Resolve the session standard kit.

View File

@@ -1,12 +1,4 @@
/**
* 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}.
*/
/** Framework-neutral store contracts for slot registrations and the runtime engine. */
/**
* 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.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string
/** Complete write set: pure draft transforms. */
actions: A
}
@@ -58,9 +47,7 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
* call create() themselves — instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A>
/** Current state snapshot (uSES getSnapshot side; test assertions). */
getSnapshot(): T
/**
* 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.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A>
/**
* 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
* stylesheets in src/styles/ (the sole token source; components must not
* 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.
* Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
* update CSS variables and `body[data-ds-dark-theme]` without React renders.
*/
import type { Context } from 'cordis'

View File

@@ -1,11 +1,4 @@
/**
* 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 loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the theme plugin. */
export function apply(): void {}

View File

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

View File

@@ -1,10 +1,4 @@
/**
* 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 loader entry for the browser-only trajectory plugin. */
/** Host plugin body — no host-side behavior for the trajectory plugin. */
/** Provides no host-side behavior. */
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> }
}
/** 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() {
const store = createSnapshotStore<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'])
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.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy()

View File

@@ -1,13 +1,4 @@
/**
* 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.
*/
/** React bindings for the framework-neutral slot and snapshot contracts. */
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
export { bindSnapshotSelector } from './bind.ts'
@@ -20,7 +11,6 @@ export { bindSnapshotSelector } from './bind.ts'
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
@@ -28,7 +18,6 @@ export type {
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
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 { useInvoke } from './use-invoke.ts'

View File

@@ -1,19 +1,6 @@
/**
* createSlotRenderer(): the outlet machinery behind the runtime install seam
* (slot terminal design §8). renderRoot mounts the host channel and renders
* 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).
* React renderer for declarative slots. Per-entry bindings enforce child
* authorization, and entry boundaries contain registrant failures.
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
@@ -27,10 +14,8 @@ import {
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
/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
/**

View File

@@ -1,11 +1,4 @@
/**
* 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.
*/
/** Internal React bindings for the renderer host and active session cell. */
import { createContext, useContext, type ReactNode } from 'react'
import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
@@ -20,7 +13,7 @@ import { bindSnapshotSelector } from './bind.ts'
*/
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)
/**
@@ -34,7 +27,6 @@ export function useHost(): SlotRendererHost {
return host
}
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null)
/**
@@ -75,11 +67,10 @@ export interface SessionProviderProps {
/**
* Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions),
* resolves the session cell, and remounts the body under key={sessionId} so
* a session switch rebuilds the whole session subtree. Ids speak plain
* string at this dependency-inverted layer; branding lands on the component
* props seam (PropsRuntime).
* source, resolves the session cell, and remounts the body under
* `key={sessionId}` so a session switch rebuilds the session subtree. This
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* branded type at the component boundary.
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
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 type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Local one-level equality: the engine's shallowEqual moved to runtime with
// the store relocation, and web-react tests must not import runtime (the
// dependency direction is runtime → web-react). The eq PARAMETER contract is
// what this suite asserts, not any specific equality implementation.
// Keep equality local: this suite asserts the eq parameter contract without
// adding a reverse dependency from web-react to runtime.
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]))

View File

@@ -1,9 +1,7 @@
// @vitest-environment jsdom
/**
* Stale renderSlot bindings (slot terminal design §9): a binding dies with
* its entry — a retained closure invoked after the entry's disposal throws
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
* entry, same key) mints a NEW binding rather than reviving the old one.
* A retained render binding dies with its entry. Re-registering the same key
* creates a new binding rather than reviving the stale closure.
*/
import { describe, expect, it } from 'vitest'
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
* responsibility, packaged as a normal static-arrival entry so the host graph
* 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.
* App-shell assembly plugin. Its pseudo package id exists only in the host
* graph and shell registry; there is no npm package behind it.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
@@ -33,13 +26,11 @@ declare module 'cordis' {
/** Cordis plugin name. */
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']
/**
* Plugin body: install the React renderer into the slot system and provide
* the renderApp face (one ctx-level renderSlot('root') call).
* @param ctx - plugin context (inject set active).
/** Installs the React renderer and exposes the assembled application.
* @param ctx - Plugin context.
*/
export function apply(ctx: Context): void {
// 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.
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
* 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.
* Shared browser platform modules. Seeding, bundling externals, and Vite
* aliases consume this list so their module identities cannot drift.
* @module @deepseek-ai/dsh-client-web/src/platform
*/

View File

@@ -188,15 +188,12 @@ describe('Agent', () => {
const ctx = await harness(adapter)
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
// reliable open-turn signal). inject must append into that open turn, NOT
// wrap a new one.
// Status is idle while the log has an open turn; enclosure must follow the log.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
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.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.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
const starts = agent.session.events.filter(e => e.type === 'turn/start')
@@ -342,11 +339,9 @@ describe('Agent', () => {
const ctx = await harness(adapter)
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' } })
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(adapter.requests).toHaveLength(1)
})
@@ -369,12 +364,10 @@ describe('Agent', () => {
prepared.markPublished()
const dispose = prepared.startDriver()
// First dispose
const firstDisposal = dispose()
expect(agent.status).toBe('disposed')
await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed')
})

View File

@@ -142,8 +142,6 @@ describe('Agent.cancel()', () => {
agent.queue([{ type: 'text', text: 'quiet' }])
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' })
await idle
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 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 }[] = []
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
@@ -1704,7 +1703,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
// Start disposal, then release the block, then await disposal.
const disposalDone = fiber.dispose()
releasePreStep()
await disposalDone

View File

@@ -283,20 +283,17 @@ describe('SurfaceManager', () => {
it('empty surface yields empty nodes', () => {
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('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.nodes.length).toBe(0)
// deriveMessages returns empty array
expect(s.deriveMessages()).toEqual([])
})
it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession()
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' })
expect(s.surface.nodes.length).toBe(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()
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])
// Surface rebuilds from the seeded log's markers.
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
})

View File

@@ -1893,7 +1893,6 @@ describe('ToolRegistry', () => {
const ctx = await setup()
ctx.tools.register(echoTool)
// Register a second tool and call its returned disposer directly
const dispose = ctx.tools.register({ ...echoTool, name: '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 } },
output: { schema: { type: 'string' }, render: () => [] },
async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args
return args.a
},

View File

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

View File

@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts'
/**
* 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).
*/
/** Key-gated smoke for a real model driving the local read/write/edit tools. */
let ctx: Context | 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.' }])
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')
expect(content).toContain('status: final')
expect(content).not.toContain('draft')

View File

@@ -321,10 +321,9 @@ describe('fold onto the downstream decision', () => {
const found = reminders(agent)
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]!.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]!.source).toEqual(GUARD_SOURCE)
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([]))
let ran = false
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 result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
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 () => {
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 path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
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 path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
// Register a fake child agent under the id the event carries.
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]
ctx.agents.register(child)
@@ -693,7 +690,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
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 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' })
@@ -735,7 +731,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
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' }])
await waitForIdle(ctx, agent)
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(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
// After dispose, further fiber events no longer rescan.
registry.dispose()
entries.pop()
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. */
export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */
/** Selects child-process stdio transport. */
transport: 'stdio'
/**
* Stable local namespace for this server's model-facing tool names
@@ -59,21 +59,21 @@ export interface StdioConfig {
* unique across live mcp-client instances.
*/
serverName: string
/** Executable to spawn. */
/** Executable used to start the server. */
command: string
/** Arguments passed to the command. */
/** Arguments passed directly, without shell interpolation. */
args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string>
/** Working directory for the child process. */
cwd: string
/** Timeout per callTool invocation (ms). */
/** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
}
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
/** Selects Streamable HTTP transport. */
transport: 'streamable-http'
/**
* Stable local namespace for this server's model-facing tool names
@@ -81,15 +81,15 @@ export interface StreamableHttpConfig {
* unique across live mcp-client instances.
*/
serverName: string
/** MCP server URL. */
/** MCP endpoint URL. */
url: string
/** Extra headers (e.g. auth tokens). */
/** Additional headers attached to MCP requests. */
headers: Record<string, string>
/** Timeout per callTool invocation (ms). */
/** Per-tool-call timeout in milliseconds. */
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 const Config = z.union([

View File

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

View File

@@ -314,18 +314,16 @@ describe('server-filesystem — real filesystem operations', () => {
const filePath = join(tempDir, 'test.txt')
const content = 'Hello from MCP e2e test!'
// Write via MCP tool
const writeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
})
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')
expect(onDisk).toBe(content)
// Read back via MCP tool
const readResult = await ctx.tools.execute({
signal: testToolSignal,
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 () => {
// Ensure a file exists
await writeFile(join(tempDir, 'listed.txt'), 'listed')
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())
// Simulate model emitting `null` as tool arguments (malformed).
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
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()
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')
expect(proof).toContain('ACP_CHILD_WAS_HERE')
}, 180_000)

View File

@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
import { spawnHarness, waitForIdle } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* 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).
*/
/** Key-gated smoke for a real parent delegating filesystem work to a real child. */
let ctx: Context | 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.' }])
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')
expect(proof).toContain('SUBAGENT_WAS_HERE')

View File

@@ -1,7 +1,5 @@
/**
* Subagent seam vocabulary: the request/result/capability types a
* {@link SubagentProvider} consumes and produces. No runtime code — types
* only, per the package convention.
* Request, result, and capability contracts for {@link SubagentProvider}.
*
* @module @deepseek-ai/dsh-subagent/types
*/
@@ -17,7 +15,7 @@ export type SubagentRunId = Branded<'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.
*/
export function SubagentRunId(id: string): SubagentRunId {
@@ -33,13 +31,9 @@ export function SubagentRunId(id: string): SubagentRunId {
* is the capability.
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean
}
@@ -50,16 +44,11 @@ export interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}.
*/
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[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* 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.
* The spawning agent. In-process providers derive workspace, lineage, and
* delegation depth from its durable session state; ACP uses only its cwd.
*/
readonly parent: Agent
/**
@@ -70,7 +59,6 @@ export interface SubagentStartRequest {
* afterward.
*/
readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* 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.
*/
export interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */
/** Cancelled through the request signal or disposal. */
aborted: 'aborted'
/** The child failed (model error, transport error). */
/** Model or transport failure. */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal'
}
@@ -170,9 +155,8 @@ export interface SubagentRun {
*/
readonly result: Promise<SubagentResult>
/**
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
* Cancel remaining work, reach child quiescence, and release resources.
* Idempotent.
*/
dispose(): Promise<void>
/**
@@ -188,12 +172,9 @@ export interface SubagentRun {
}
/**
* A subagent backend: one transport for running a child agent (in-process
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* 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.
* One registered transport for running child agents. Providers are trusted
* same-process implementations; callers treat descriptors and returned values
* as borrowed immutable data.
*/
export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -61,26 +61,16 @@ import {
import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
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 { 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 { 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'
// 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'
// 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'
// 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'
// 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 {
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.
loadingIds.add(sessionId)
try {
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse), so this rejects a session we
// 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.)
// Validate metadata before resume so rejection cannot leave a
// registered agent. Persisted cwd is authoritative for resumed work.
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) {
const persistedCwd = meta.cwd
@@ -903,12 +883,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
// 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.
// Closure can race resume. Dispose the unpublished agent before
// rejecting because quiesce() tracks only installed records.
/* v8 ignore next 4 -- the in-memory test transport rejects the in-flight
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
@@ -937,19 +913,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
pendingSwitches: {},
}
sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update. Use
// the raw event log (NOT deriveMessages, which drops assistant/chunk
// and trace events): RFC 010's load contract reconstructs the streamed
// 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.
// Replay the raw log because deriveMessages omits update-bearing
// chunks and trace events. A disposable presenter prevents an
// incomplete historical tool call from polluting live presentation.
const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
@@ -1081,11 +1047,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
assertOpen()
}
// Install the in-flight slot BEFORE followup() (followup does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
// A turn that ends in error rejects this promise (the codec never
// produces an error stop reason).
// Install before followup(), which does not synchronously enter running;
// the session listener associates and settles the message-triggered turn.
// Error turns reject because ACP has no error stop reason.
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined }
rec.agent.followup(preparedContent, { contexts: preparedContexts })
@@ -1096,18 +1060,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// session/cancel maps to the queue-aware agent.cancel({ kind: 'user' }): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// 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.
// Agent cancellation may drop a pre-step turn without emitting
// turn/end, so the bridge settles its prompt directly.
if (rec.promptPreparation !== undefined) {
rec.promptPreparation.abort(new Error('session/cancel'))
} 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
* (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.
* @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`
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
* as a workspace root). The persisted-cwd equality check for `session/load`
* happens after the metadata lookup; this validator only enforces request shape:
* - `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.
* Validate the workspace shape shared by `session/new` and `session/load`.
* `cwd` is an absolute per-session workspace; load separately requires it to
* match persisted metadata. Additional roots are rejected because ignoring
* them would desynchronize the client's displayed filesystem scope.
*/
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
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 () => {
harness = await makeBridgeHarness({ storageDir })
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: [] }))
.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: [] })
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')
})

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 () => {
h = await presetStack()
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]
if (agent === undefined) throw new Error('expected an agent')
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 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' }] })
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Dispose the whole context. The bridge's teardown must abort the agent and
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
// (not still running). Proves disposal waited, not just requested.
// Fiber disposal must not resolve until the running agent is quiescent.
await harness.ctx.fiber.dispose()
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 () => {
// The ACP transport closes (editor quits) while a turn runs. The bridge must
// 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.
// Transport closure owns the agent handle; idle-but-registered is also a leak.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// Start a prompt that hangs in the model stream. The prompt RPC will never
// return (its transport is severed), so do not await it.
// The transport will sever this RPC, so do not await it.
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
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 agent.whenIdle()
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// Await the bridge teardown to completion WITHOUT tearing down the root
// 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.
// Fiber disposal joins the disconnect teardown while root services remain queryable.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.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()
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' } } })
await new Promise(r => setTimeout(r, 10))
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)
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.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
// B is wholly unaffected.
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
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
* precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure
* `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.
* Property tests exercise the pure event translator so live/replay equivalence
* and per-call ordering remain deterministic rather than timing-dependent.
*/
import { describe, expect, it } from 'vitest'

View File

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