mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Trim redundant source comments
This commit is contained in:
@@ -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). */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
// ---- 私有 ----
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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' } } })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user