This commit is contained in:
imccyu
2026-08-13 01:33:31 +08:00
parent 978e573605
commit a7d4cd8e1b
31 changed files with 493 additions and 1243 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.md
2026-08-08-cordis-web-dynamic-packages.md: c8ba3940bbcf7eeaa7b5a882f91ca8165bdd8cbf
2026-08-08-cordis-web-dynamic-packages.zh.md: a6d961aef69e8820023ce3d749f0affd83a871c6
@@ -66,3 +66,205 @@ The Host stores the current physical Run separately from `latestRun`. The physic
A Host-only Package commits current after the Host successfully establishes its Fiber. A Client-bearing Package commits current after Host activation succeeds and at least one Client successfully establishes the corresponding load. A Fiber that Cordis parks as waiting because a hard dependency is absent is still a successfully established lifecycle object; it is not equivalent to a parse or `apply` failure.
If an update target fails, the old physical Run is not restarted automatically. The previous `currentPackageId` continues to identify the last successful version, and the failed target remains `nextPackageId`. The user or model can retry next, or reactivate current with `mode: "run"` to roll back.
### Host authority and persistence
`DynamicCordisRunnerService` and its internal Registry are the sole authority in the current DSH process. They store:
- each Plugin's Session ownership and immutable Package set;
- `currentPackageId`, `nextPackageId`, the physical Run, and `latestRun`;
- per-Package authorization and cross-version Plugin authorization;
- pending Client activation requests;
- Host Fibers, Package-private handlers, waiting Services, and recent diagnostics;
- Host and Client Inspect Registry directories and query routing.
These objects are not written to configuration or disk and are not restored after process restart. The Session Log may retain Tool calls, results, and metadata needed by cards, but it does not replay dynamic code to restore the Registry. Historical cards remain in the conversation after a restart, but their original `pluginId` and `packageId` are no longer runnable.
Runtime state is not written to Session projection as recoverable state. Refreshing a page or opening a new page does not automatically restore Client halves; automatic restoration would reintroduce connection identity, startup baselines, and cross-page consistency protocols, which are outside this design.
### Define, Run, and version switching
`cordis_define` has two modes: creating a Plugin submits `idPrefix`, while modifying an existing Plugin submits its exact `pluginId`. Code always uses `code: { host?, client? }`. Define validates arguments and plain JavaScript syntax, records immutable source, and returns the final IDs. It does not execute `apply`, create approval, change version pointers, or run implicitly.
There is no separate `cordis_update`. `cordis_run` expresses activation intent through `mode`:
| Version relationship | `mode` |
| --- | --- |
| No `currentPackageId` exists | `run` |
| Target equals current, including restart, retry, or rollback | `run` |
| Target differs from an existing current | `update` |
| Retry `nextPackageId` after a failed update | `update` |
Run first validates Plugin/Package ownership, the version relationship, and whether another transition is in progress. It then creates `pluginRunId` and writes `latestRun` and `nextPackageId`.
A Host-only Package completes Host activation within the Tool call and returns `running` or a failure synchronously. A Client-bearing Package does not wait for a browser outcome within the Tool call: without authorization it registers approval and returns `awaiting-approval`; with authorization it registers automatic Client activation and returns `starting`. Both results mean that the request exists, not that full activation succeeded.
When target activation actually starts, the Host stops the old physical Run before executing the target Host half. Only after Host success may the Client fetch and load source for the exact `pluginRunId`. Client success commits the version pointers. Any failure is recorded against that attempt, without restarting the old version and presenting it as target success.
`cordis_stop` tears down the current Host/Client Run and pending approval request while retaining the Plugin, Packages, authorization, and version pointers. `cordis_undefine` stops first and then deletes the Plugin, Packages, authorization, and version pointers; historical cards then show only that the Plugin was removed.
### Client approval and authorization
A Package containing Client code requires user authorization before its first activation because model-generated code will run in the user's page. The approval panel offers three actions:
- A single check authorizes the current Package. Later runs of the same Package do not need approval, but a new Package does.
- A double check authorizes future versions of the current Plugin. New Packages, updates, retries, and rollbacks no longer require per-version approval.
- Reject ends the current request without executing Host or Client code. The model must not immediately request approval again unless the user asks for it.
Authorization is written to the Host Registry when the user allows it and remains even if a later technical step fails. When the panel runs a Package directly, the user's click itself authorizes that Package.
A row awaiting approval shows only per-Package allow, cross-version allow, and reject; it does not simultaneously offer run, stop, or delete. The panel expands automatically when new approval appears. If auto-expansion fails or the panel is collapsed, the fixed entry and row status still show the pending approval count and state.
### Client activation orchestration
The Host sends Client activation requests through `cordis/request-run`. A request includes only request identity, Session, Plugin, Package, mode, name, purpose, and whether approval is required; it does not broadcast source code.
An authorized page executes the following fixed sequence:
1. Call `runHostHalf` to start the target Host half or bind to the same attempt's already-started Host Run.
2. After Host success, call `getClientCode` with `pluginId + pluginRunId` to retrieve only the exact current Run's Client source.
3. The Client Runner evaluates the plugin in the page, establishes its Loader entry/Fiber, and installs its Guard, styles, Slots, and page-local state.
4. The page reports success, waiting, or failure through `resolveRequestRun` or `settleUserRun`.
5. The Host accepts the report only for the still-current exact Run, commits current or saves diagnostics, and broadcasts request completion so other pages clear their activities.
Host activation precedes Client activation so that the Client does not start before required Host handlers exist. A page may tear down the Host Run after Client failure only when that request created the Host Run; a page that merely bound to an existing Run does not own it.
The Client Orchestrator stores pending approvals and active orchestration by `pluginId`, so one Plugin cannot run two page activations concurrently. Host inventory can reconstruct omitted pending-approval items and approval-free automatic activation requests.
Client load state is a page-local fact. An active Host does not mean that the current page loaded the Client half. The UI has three primary states: gray “Ready” when no physical Run exists, yellow “Client ready to activate” when the Host runs but the current page has not successfully loaded the Client, and green “Running” when both halves are available in the current page. Approval and failure appear as additional states.
This version does not establish per-connection identity or multi-page quorum. The first still-valid Client success may commit process-wide current; each page's store independently records whether that page loaded the Client.
### Package-private Client-to-Host communication
A dynamic Package uses a private JSON channel for Client-to-Host calls: the Host registers methods for the current Run with `harness.handle(method, handler)`, and the Client calls them with `host.call(method, args)`. Every call is associated with `pluginId + pluginRunId`, and the Host rejects stopped or stale Runs. Arguments and return values must be lossless JSON; functions, React elements, Contexts, Service instances, and class objects are forbidden.
This channel serves only Client-to-Host calls within the same Package. It does not use public Remote Services or `ctx.remote` in dynamic code. The public Remote interface carries only the Runner's own control protocol and does not expose dynamic Packages.
### Dynamic code, Guard, and lifecycle
Host and Client both execute only plain JavaScript function bodies, without TypeScript, JSX, or bundler transformation. The Host executes in `node:vm`; the Client evaluates in a restricted closure. These contexts reduce misuse and provide instructional errors, but they are not security boundaries against malicious code.
By default, the model reads an optional Service through `ctx.get('serviceName')` and checks for `undefined`. A plugin object declares `inject` only when the Service is a hard dependency whose absence must park the Package and whose later arrival must reactivate it. Direct `ctx.serviceName` access is allowed only when the same plugin declares the corresponding inject.
Host and Client `timer` are same-named Cordis Services with the same interface, not global Builtins. A plugin that needs timers must declare `inject: ['timer']`; a timer created inside a React effect returns its disposer as cleanup.
The current Fiber owns every registration and reversible side effect. Event listeners, Services, Tools, handlers, timers, Slots, styles, and theme overrides register through `ctx.effect()`, `ctx.on()`, or official APIs that return disposers. Stopping, updating, failure rollback, or undefining tears down both halves' contributions. Theme overrides are layered by source and return a disposer so unloading restores the previous theme values.
Host, DSH, Cordis, and their Service instances, Event payloads, Slot props, Session/Conversation Snapshots, Tool state, and other runtime objects are internal live data. Dynamic code must not run `JSON.stringify`, `structuredClone`, recursive enumeration, full copying, or whole-object display on these objects or their descendants. It reads only leaf fields needed by the current task and constructs minimal owned data without Host references.
### Inspect Providers and Catalogs
Capability discovery uses three Tools: `cordis_inspect_list` lists Host/Client Provider manifests; `cordis_inspect_query` executes an explicit read-only query on the selected platform; and `cordis_inspect_self` queries the current Session's Plugins, Packages, source, version pointers, and runtime diagnostics.
Host and Client each own a `CordisInspectRegistry`. A Provider registers a platform-unique ID, description, methods, input schemas, and output schemas. Provider methods are explicitly allowlisted queries, not arbitrary Service-method forwarding; the Registry has no layered target and does not automatically turn business Service methods into executable Inspect methods.
The initial Providers are:
| Platform | Provider.method | Data source |
| --- | --- | --- |
| Host / Client | `Service.listService` | Static Service Catalog for each platform |
| Host / Client | `Event.listEvents` | Static Event Catalog for each platform |
| Host / Client | `Builtin.listBuiltins` | Hand-maintained definitions beside the evaluator/Guard |
| Host | `Tool.listTools` | Tool Registry actually visible to the current Agent |
| Client | `Slots.listSubTree` | Static Slot Catalog plus the page's live subtree/occupants |
| Client | `Theme.listTokens` | Read-only inspect export from ThemeService |
When the Client Registry changes, it synchronizes the complete manifest to the Host without storing duplicate directories per Session. Host queries execute locally. For a Client query, the Host broadcasts a request ID and a page executes the local Provider and responds. The Host accepts only the first successful result that passes output-schema validation; a failed page does not settle the request. If no page succeeds, the Tool remains pending until a later success or Tool-call cancellation.
Inspect data is used only before code is written to confirm capabilities, signatures, types, and mounting protocols. A plugin that needs runtime business data calls the actual Service or listens to the actual Event; it must not cache, display, or depend on Inspect/Catalog results.
`CordisCatalogProjector` generates Host and Client Service and Event Catalogs separately through TypeRT. The Slot AST generator scans `SlotMap`, registration options, standard props, owner props, and referenced types; the Slots Provider merges the static Catalog with the live tree at query time. ThemeService exports theme tokens, Builtins are maintained manually beside the evaluator/Guard, and Tool schemas come from the Registry.
Catalog generation scans real source signatures and then applies a model-visible allowlist. The allowlist may hide Services, members, `@deprecated` APIs, Runner-owned Services, and `cordis/*` control Events, but it must not rewrite method names, parameters, or return types for the remaining APIs. Guard may reject arguments, fix sources, or hide members, but it must respect source signatures.
Model-visible owner JSDoc requires only a complete description, `@param` for every parameter, `@returns` for every non-void return, `@mode` for Events, and descriptions for Slot/props fields. Usage recommendations, counterexamples, and cross-capability choices belong in the Skill rather than duplicated Catalog example fields.
### Model guidance layers
Model guidance has four layers:
- The System Prompt carries the stable runtime model, restrictions of both platforms, lifecycle, approval, version pointers, minimum code rules, and a usage map for the seven Tools. It still supports a minimally correct implementation when the Skill is unavailable.
- The `cordis-plugin-development` Skill carries requirement navigation, capability composition, recommendations, and counterexamples without copying complete schemas.
- Each Tool description states only that action's prerequisites, parameter semantics, synchronous or asynchronous result, and next step.
- Provider/Catalog results supply current exact names, signatures, parameters, Slot props, tokens, and runtime query results.
The System Prompt requires loading the Skill first, then listing/querying capabilities, and only then defining/running code. React examples in the Skill register into a Slot instead of returning a React Element directly from `apply()`. Examples use `React.createElement`, correct `ctx.get()`/`inject`, reversible effects, and minimal JSON RPC.
### `@pluginId` and Tool UI
The input system registers an `@pluginId` mention for the current Session. Selecting it injects only Plugin identity, the default baseline Package, version pointers, the active Run, and the latest status, not source code. The default baseline is selected in order from next, current, and the most recently defined Package. The model must read source through `cordis_inspect_self` before appending a Package in existing mode; an invalid mention must not silently create a replacement Plugin.
The `cordis_define` card presents Host and Client code in two tabs. A `cordis_run` card associates with one exact attempt through `pluginRunId` and reads the Client store to show pending approval, Client ready to activate, running, failed, replaced by a later Run, or Plugin removed.
A Package may register `key: "self"` into `tool.view.cordis`. At runtime, self binds to `pluginId + packageId`; the business Slot key omits `pluginRunId`, while owner props still provide the exact Run identity. The newest Run card for a Package owns the business UI, and earlier cards show that a newer Run exists. Cards react to store changes rather than scanning later Session Log entries or notifying one another.
The global Cordis panel has one fixed entry and groups rows by current and other Sessions. Its title and collapse action remain fixed while only the list scrolls. A normal row can select a Package and run, stop, or delete it. A failed update can retry next or select current to roll back. A pending-approval row exposes only the two allow actions and reject.
### Errors and model feedback
Technical errors crossing Host and Client preserve the original `message` and preserve `stack` when the error object provides it. Structured diagnostics include `pluginId`, `packageId`, `pluginRunId`, and one phase: approval, host-load, host-apply, client-load, client-apply, or client-render.
Host and Client Guards, Host evaluation and handlers, Client evaluation and apply, Slot `onEntryError`, and React ErrorBoundary all return errors to the owning Agent. The Client console also prints the original error object through `console.error`. A rendering error belongs to the exact Run and does not contaminate the immutable Package.
After a model-initiated asynchronous Run succeeds, is rejected, or fails technically, `agent.steer` wakes the owning Agent. A technical failure requires the model to read diagnostics, correct the same Plugin, and retry autonomously. A user rejection forbids an automatic repeat request. A user's manual run, stop, or removal in the panel is supplied through context injection to the next step without waking the model proactively.
## Alternatives considered
**Combine Define and Run.** This removes the previewable “defined but not running” state and mixes syntax errors, approval, runtime errors, and retries into one action. The design therefore uses immutable Define and independent Run actions.
**Use Package ID as Plugin ID.** A single-level ID cannot append immutable versions beneath a stable instance; updates would require stop, undefine, and a new define, while historical cards and `@` references could not retain object identity. The design therefore uses separate Plugin, Package, and Run identities.
**Provide a separate `cordis_update`.** Update has the same loading, approval, UI, diagnostics, and execution semantics as Run, so a separate Tool would duplicate the protocol. It is represented by `cordis_run mode:"update"`.
**Automatically restore the old physical Run after an update failure.** Automatic restoration combines “target failed” and “old version succeeded again” into one result. The design retains the old current pointer without restarting it, so the user explicitly chooses to retry next or run current.
**Block `cordis_run` until user approval and the final Client outcome.** Approval or page interaction may only occur after the current model turn ends. Blocking would deadlock and occupy the Tool indefinitely when no page exists. The Tool returns immediately, while stores, Inspect, and steering report the final outcome.
**Broadcast source from the Host and wait for Client acknowledgements with a timeout.** Broadcasting sends source to every page before authorization. A timeout cannot distinguish no page, a slow page, and no user action, and the Host would need compensating rollback. The protocol broadcasts only metadata, and an authorized page fetches source for the exact Run.
**Automatically restore every Host-active Package when a page starts.** This requires connection identity, a startup baseline, and cross-page consistency. The design accepts page-local Client state and lets the user load again from the panel.
**Connect Package halves through public Remote Services or `ctx.remote`.** This exposes dynamic Packages through the product RPC interface. Package-private `harness.handle`/`host.call` is sufficient for Client-to-Host JSON calls and rejects stale requests by `pluginRunId`.
**Expose every Service method automatically as an Inspect query.** This turns capability discovery into a business-call proxy that bypasses plugin approval and lifecycle. Providers expose only curated read-only queries; the Service Catalog only describes business method signatures.
**Put the complete API in the System Prompt or Skill.** Static text drifts and consumes context. The System Prompt retains stable rules, the Skill provides requirement navigation, and Provider/Catalog results return exact signatures and runtime directories.
**Require Slot owners to register props schemas at runtime.** Slot props already exist in TypeScript types and JSDoc, so duplicate registration creates a second authority. The Slot AST Catalog extracts the static protocol and only merges the live tree at query time.
**Write runtime state to the Session Log and restore it during replay.** Dynamic code and Fibers are process-local objects. Restoration would require re-executing historical code and reinterpreting approval. The Session retains only model-visible records; the Registry and page Runs are not restored.
**Make historical Run cards scan later Session Log entries.** This couples Tool views to the complete log order and later message structure. The page card index/store already tells cards by Package when a later Run replaces them or their Plugin is deleted.
## Acceptance criteria
- A new Plugin can be created only from a 3-to-6-character lowercase English prefix; final Plugin, Package, and Run IDs are allocated by the Host and use branded types.
- `cordis_define` validates only parameters and plain JavaScript syntax and returns an immutable Package; one Plugin can append versions while old source remains inspectable.
- `cordis_run` strictly validates run/update; Host-only activation completes synchronously, while a Client-bearing activation returns `awaiting-approval` or `starting` without waiting for the final browser outcome.
- A single check authorizes only the current Package, and a double check authorizes future versions of the same Plugin. Authorization survives technical failure, while rejection executes neither half.
- The Host activates first and the Client then fetches source for the exact Run. A Client-bearing Package does not commit current before Client success, and current/next permit retry and rollback after failure.
- One Plugin has at most one physical Run at a time. Stop tears down both halves while retaining definitions and pointers; undefine deletes every Package, authorization, and state.
- The current page distinguishes “Ready,” “Client ready to activate,” and “Running,” and a pending-approval row shows only approval actions.
- `tool.view.cordis` self binds Plugin + Package. The newest Run card for a Package exclusively owns its business UI, while old cards and deleted Plugins have explicit fallback states.
- Host and Client Guards reject imports, JSX, undeclared Services, and unavailable globals. Services, timers, Slots, styles, Tools, handlers, and theme overrides are torn down with the Run.
- Package-private RPC permits only lossless JSON from Client to Host and rejects a stale `pluginRunId`.
- Inspect list returns Host and Client manifests together. Query calls only explicit read-only methods, and a Client query waits for the first schema-valid successful result or cancellation.
- Service/Event Catalogs are generated per Host/Client and apply allowlists. `@deprecated` APIs, Runner-owned Services, and `cordis/*` control Events are hidden from the model; Slot query merges static props with the live subtree.
- `cordis_inspect_self` returns layered Plugin lists, Package summaries, and exact source/diagnostics. `@pluginId` does not inject source and keeps updates in the same Plugin.
- Asynchronous technical failures, Host handlers, Client Guards, and React rendering errors preserve message/stack and steer the owning Agent; user panel actions only inject context into the next step.
- The System Prompt, Skill, Tool descriptions, and Provider/Catalog layers follow this Note. The Prompt remains sufficient to generate a minimally correct plugin if the Skill is unavailable.
- Relevant workspaces pass `pnpm run build`; implementation adds Host/Client lifecycle, versioning, approval, Inspect, Guard, Tool-card, and real-application snapshot coverage.
## Risks
- **A process restart loses all dynamic objects.** Historical Tool cards remain, but the Registry is not restored; the user must define again.
- **Multi-page state is not strongly consistent.** The first valid Client success may commit current while Client loading and rendering state still differs across pages. This version does not introduce connection identity, quorum, or page aggregation.
- **Client Inspect may remain pending indefinitely.** The Host stores the latest manifest, but without a page successfully executing the Provider it cannot present stale data as a live result. If every page fails, the request waits until cancellation.
- **Cross-version authorization expands trust.** A double check permits future Packages of the same Plugin without further approval. The UI must clearly distinguish per-Package and cross-version authorization.
- **A failed update can leave current pointing to an old version that is not running.** Current identifies the last successful version, not the physical Run. UI, Inspect, and prompts must show active, current, and next together.
- **Restricted contexts are not security sandboxes.** Host Services, files, commands, network access, and Client UI are real capabilities. Allowlists and approval reduce misuse but do not isolate malicious code.
- **Catalogs, Guards, and source can drift.** Generators, allowlists, and owner JSDoc must be maintained together. Guard hiding rules must not create a second signature.
- **Builtins require manual declarations.** React, harness, host, styles, and Context methods have no unified scannable source, so injection implementations and Provider definitions must share one maintenance location.
- **Provider output schemas currently permit broad JSON.** The first version prioritizes Provider ownership, input validation, and Host/Client routing; output schemas can become narrower later.
- **Host and Client Guards are parallel implementations.** Their available environments and Cordis type interfaces differ, so they remain separate. A shared specification should be extracted only if it removes code without obscuring security policy.
@@ -244,10 +244,6 @@
# trust boundary, not a sandbox — see this file's header.
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'
- id: cordis-client-runner
name: '@deepseek-ai/dsh-cordis-client-runner'
- id: ui-cordis
name: '@deepseek-ai/dsh-client-ui-cordis'
# The composition-authoring skill travels with this preset rather than living
# in the user's skill root: it documents THIS deployment's two planes, and a
+5 -2
View File
@@ -260,7 +260,10 @@ describe('the shipped Web composition', () => {
try {
const tools = toolNames(ctx, handle.agent)
// The self-referential toolset is what distinguishes this preset.
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
expect(tools).toEqual(expect.arrayContaining([
'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self',
'cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine',
]))
// And it keeps the standard agent's own tools rather than replacing them.
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
expect(tools).not.toContain('str_replace_editor')
@@ -314,7 +317,7 @@ describe('the shipped Web composition', () => {
})
try {
// Editing the live runtime is opt-in per session, not ambient.
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_define')
} finally {
await handle.dispose()
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
README.md: 9dafb548060f884ab98b20d64896a974a0ad6e81
README.zh.md: f5298e7c0268d53da4322aadbe960a2f622ce8f1
README.md: 74111204c7bfa00f0e496481e474105ea71ac615
README.zh.md: 7cd9bbf2f941925f6023518020a3d260ed947c5d
+1
View File
@@ -32,6 +32,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [terminal.md](terminal.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots |
| [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [self-modification.md](self-modification.md) | versioned dynamic Cordis Plugins and Packages, Host/Client activation, approval, runtime inspection, and lifecycle teardown |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` |
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
+1
View File
@@ -32,6 +32,7 @@
| [terminal.md](terminal.md) | 持久化终端 ID、后端/会话约定、发送就绪状态、有界读取与 owner 可见快照 |
| [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 |
| [code-runtime.md](code-runtime.md) | 代码执行 seam`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 |
| [self-modification.md](self-modification.md) | 带版本的动态 Cordis Plugin 与 Package、Host/Client 激活、审批、运行时检查和生命周期撤销 |
| [filesystem.md](filesystem.md) | 文件系统 seam`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` |
| [lsp.md](lsp.md) | LSP 导航 seam`LspQueryRequest`/`Result``LspProvider`/`Service`、四种操作、`LspError` |
| [skills.md](skills.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/lsp.md
lsp.md: fe2d30c84b3e2ccea82b9fb1edd370845267b6d9
lsp.zh.md: d821a2493b78f8637d9917241838eaedf6e0af8b
lsp.md: 66317acc25860cfeaa3d8fb35daf2947e811e18c
lsp.zh.md: 8d975e30cb5065fd89645166c657050233eb81da
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/self-modification.md
self-modification.md: 90ee02eb69149c6729635f9916fbd3946d4ae477
self-modification.zh.md: a22bb38b1fe398da19b11384e6e38b9dde6d0c3e
+64 -26
View File
@@ -23,6 +23,7 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
/**
@@ -85,12 +86,18 @@ let keylessCall = 0
const testToolSignal = new AbortController().signal
/** Execute one outer Code Mode call through the real registry and worker. */
function runCode(harness: Context, code: string, signal: AbortSignal = testToolSignal): Promise<ToolExecutionResult> {
function runCode(
harness: Context,
code: string,
signal: AbortSignal = testToolSignal,
agent?: Agent,
): Promise<ToolExecutionResult> {
return harness.tools.execute({
callId: CallId(`keyless-code-${++keylessCall}`),
name: RUN_CODE_NAME,
arguments: { code, description: 'Run the e2e program' },
signal,
...(agent === undefined ? {} : { agent }),
})
}
@@ -254,46 +261,77 @@ describe('Code Mode typed values: keyless real-worker contracts', () => {
expect(ctx.jobs.list()).toEqual([])
}, 15_000)
it('uses cordis_mount DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => {
it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => {
ctx = await typedCodeModeHarness()
await ctx.plugin(CordisHostRunner)
await ctx.plugin(ToolCordis)
const agent = {
id: SessionId('code-mode-cordis'),
session: { append: vi.fn() },
} as unknown as Agent
const value = completion(await runCode(ctx, `
const active = await tools.cordis_mount({
code: "return { name: 'active-code-mode-plugin', apply(ctx) {} }",
const activeDefinition = await tools.cordis_define({
plugin: { kind: 'new', idPrefix: 'active' },
name: 'active-code-mode-plugin',
purpose: 'prove an active Host half',
code: { host: "return { name: 'active-code-mode-plugin', apply(ctx) {} }" },
});
const pending = await tools.cordis_mount({
code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }",
const active = await tools.cordis_run({
pluginId: activeDefinition.pluginId,
packageId: activeDefinition.packageId,
mode: 'run',
});
const before = await tools.cordis_inspect({ what: 'temporary' });
const stopped = await tools.cordis_unmount({ id: active.id });
const after = await tools.cordis_inspect({ what: 'temporary' });
await tools.cordis_unmount({ id: pending.id });
const pendingDefinition = await tools.cordis_define({
plugin: { kind: 'new', idPrefix: 'queue' },
name: 'pending-code-mode-plugin',
purpose: 'prove a Host half waiting for a Service',
code: { host: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }" },
});
const pending = await tools.cordis_run({
pluginId: pendingDefinition.pluginId,
packageId: pendingDefinition.packageId,
mode: 'run',
});
const before = await tools.cordis_inspect_self({});
const removed = await tools.cordis_undefine({ pluginId: active.pluginId });
const after = await tools.cordis_inspect_self({});
await tools.cordis_undefine({ pluginId: pending.pluginId });
return {
active,
pending,
stopped,
beforeContainsId: before.includes(active.id),
afterContainsId: after.includes(active.id),
active: {
pluginId: active.pluginId,
packageId: active.packageId,
pluginRunId: active.pluginRunId,
status: active.host.status,
},
pending: {
pluginId: pending.pluginId,
packageId: pending.packageId,
pluginRunId: pending.pluginRunId,
status: pending.host.status,
waitingFor: pending.host.waitingFor,
},
removed,
beforeContainsId: before.plugins.some(plugin => plugin.pluginId === active.pluginId),
afterContainsId: after.plugins.some(plugin => plugin.pluginId === active.pluginId),
};
`))
`, testToolSignal, agent))
expect(value).toEqual({
active: {
id: 'dyn-1',
pluginName: 'active-code-mode-plugin',
state: 'active',
provides: [],
waitingFor: [],
pluginId: 'active-1',
packageId: 'pkg-1',
pluginRunId: 'run-1',
status: 'running',
},
pending: {
id: 'dyn-2',
pluginName: 'pending-code-mode-plugin',
state: 'pending',
provides: [],
pluginId: 'queue-2',
packageId: 'pkg-2',
pluginRunId: 'run-2',
status: 'waiting',
waitingFor: ['missing-code-mode-service'],
},
stopped: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' },
removed: { pluginId: 'active-1', wasRunning: true },
beforeContainsId: true,
afterContainsId: false,
})
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(69)
it('exports the full icon set (46 deepsuite + 20 figma extracts + four product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(70)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -3,6 +3,7 @@
exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
<div
data-slot="sidebar"
style="display: contents;"
>
<div
class="root collapsed railIn quietBars"
@@ -52,10 +53,32 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
</button>
<div
class="regionArea"
/>
>
<div
data-slot="sidebar.workspaces"
style="display: contents;"
/>
</div>
<div
class="footArea"
/>
>
<div
class="footerActions"
>
<div
data-slot="sidebar.footer.action"
style="display: contents;"
/>
</div>
<div
class="settingsArea"
>
<div
data-slot="sidebar.settings"
style="display: contents;"
/>
</div>
</div>
</div>
</div>
`;
@@ -63,6 +86,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsule, empty holes) 1`] = `
<div
data-slot="sidebar"
style="display: contents;"
>
<div
class="root quietBars"
@@ -122,10 +146,32 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
</button>
<div
class="regionArea"
/>
>
<div
data-slot="sidebar.workspaces"
style="display: contents;"
/>
</div>
<div
class="footArea"
/>
>
<div
class="footerActions"
>
<div
data-slot="sidebar.footer.action"
style="display: contents;"
/>
</div>
<div
class="settingsArea"
>
<div
data-slot="sidebar.settings"
style="display: contents;"
/>
</div>
</div>
</div>
</div>
`;
@@ -133,6 +179,7 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
exports[`sidebar shell snapshots > renders the expanded column in the default locale (zh, no setLocale) 1`] = `
<div
data-slot="sidebar"
style="display: contents;"
>
<div
class="root quietBars"
@@ -192,10 +239,32 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
</button>
<div
class="regionArea"
/>
>
<div
data-slot="sidebar.workspaces"
style="display: contents;"
/>
</div>
<div
class="footArea"
/>
>
<div
class="footerActions"
>
<div
data-slot="sidebar.footer.action"
style="display: contents;"
/>
</div>
<div
class="settingsArea"
>
<div
data-slot="sidebar.settings"
style="display: contents;"
/>
</div>
</div>
</div>
</div>
`;
@@ -2,7 +2,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import type {
ThemeSettings,
ThemeSnapshot,
ThemeTokenOverrides,
} from '@deepseek-ai/dsh-client-ui-theme/client'
import { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
const make = (host = stubSettingsScope<ThemeSettings>()): {
@@ -103,6 +107,91 @@ describe('ThemeRuntime', () => {
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
})
it('stacks reversible token overrides in call order and selects the active palette value', () => {
const { theme } = make()
const firstTokens: ThemeTokenOverrides = {
'--shared': { light: 'first-light', dark: 'first-dark' },
'--first': { light: 'first-only-light', dark: 'first-only-dark' },
}
const disposeFirst = theme.overrideTokens('first', firstTokens)
firstTokens['--shared']!.light = 'mutated-after-call'
const disposeSecond = theme.overrideTokens('second', {
'--shared': { light: 'second-light', dark: 'second-dark' },
})
expect(theme.getTheme().active.tokens).toMatchObject({
'--first': 'first-only-light',
'--shared': 'second-light',
})
theme.setTheme('dark')
expect(theme.getTheme().active.tokens).toMatchObject({
'--first': 'first-only-dark',
'--shared': 'second-dark',
})
disposeSecond()
expect(theme.getTheme().active.tokens['--shared']).toBe('first-dark')
disposeFirst()
expect(theme.getTheme().active.tokens['--shared']).toBeUndefined()
})
it('replacing one source leaves its stale disposer harmless', () => {
const { theme, events } = make()
const stale = theme.overrideTokens('package', {
'--old': { light: 'old-light', dark: 'old-dark' },
})
const current = theme.overrideTokens('package', {
'--new': { light: 'new-light', dark: 'new-dark' },
})
stale()
expect(theme.getTheme().active.tokens).toEqual({ '--new': 'new-light' })
current()
current()
expect(theme.getTheme().active.tokens).toEqual({})
expect(events).toHaveLength(3)
})
it('exports sorted built-in, registered, and override-only token descriptions as copies', () => {
const { theme } = make()
theme.register({
id: 'custom',
colorScheme: 'light',
tokens: {
'--dsw-alias-bg-base': 'duplicate-built-in',
'--registered': 'registered',
},
})
theme.overrideTokens('package', {
'--registered': { light: 'duplicate-registered', dark: 'duplicate-registered' },
semanticAccent: { light: 'pink', dark: 'red' },
})
const tokens = theme.exportInspectTokens()
expect(tokens.map(token => token.name)).toEqual([...tokens.map(token => token.name)].sort())
expect(tokens.find(token => token.name === '--registered')).toMatchObject({
valueType: 'CSS value',
cssVariable: '--registered',
})
const semantic = tokens.find(token => token.name === 'semanticAccent')
expect(semantic).toMatchObject({ valueType: 'CSS value' })
expect(semantic).not.toHaveProperty('cssVariable')
expect(tokens.filter(token => token.name === '--dsw-alias-bg-base')).toHaveLength(1)
tokens[0]!.description = 'caller mutation'
expect(theme.exportInspectTokens()[0]!.description).not.toBe('caller mutation')
})
it('rejects every malformed token override value with a teaching error', () => {
const { theme } = make()
const override = (value: unknown): void => {
theme.overrideTokens('package', { '--bad': value } as unknown as ThemeTokenOverrides)
}
expect(() => { override('red') }).toThrow(/bare string.*light.*dark/)
for (const value of [1, null, {}, { light: 1, dark: 'dark' }, { light: 'light' }]) {
expect(() => { override(value) }).toThrow(/must map to a \{ light, dark \} pair/)
}
})
it('context dispose releases the scope subscription', async () => {
const { ctx, host } = make()
expect(host.listenerCount()).toBe(1)
@@ -25,7 +25,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_package_inspect', 'cordis_run', 'cordis_runtime_inspect', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/extensions/README.md
README.md: 1a09693e76291eee1b34c152dbd106226a02f468
README.zh.md: ad485e78fef89ba3b2f3f7db84f1ba526dffc358
README.md: 245a2c59515591bcd9e6b87cb94ea8ddeed6727d
README.zh.md: f30acac479397e0e00b294dfe860c37ee227426a
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/extensions/cordis-client-runner/README.md
README.md: 530f7d225ea74e34651b54fe94c4211730c6e271
README.zh.md: f78de8e38c37b43d758aec5c285a04c34a7eff22
README.md: ba60e3256ca6c80792645daa26c6872cfc846940
README.zh.md: 2d8712de6b0444847eb731d84140fe2c1cad5b63
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/extensions/cordis-host-runner/README.md
README.md: af84270e549ebc71cb2176f5d1d61531603060f9
README.zh.md: d608426b4b0d6fe11e20cea0cb119740bfcfe100
README.md: f09e7506fe24676ea95b3ae490b55ae120bfad5f
README.zh.md: f60d3a64ecb1e90427b966c820a857e62d65b375
@@ -44,10 +44,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -1,168 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import type { Config as RunnerConfig } from '@deepseek-ai/dsh-cordis-host-runner'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal
/**
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer + the
* dynamic runner + this toolset (only the model and the browser are absent — the
* code strings below stand in for what the model would write, and no gateway is
* composed, so a browser half has nowhere to go).
*
* Every dynamic-package tool is session-scoped, so calls carry a stand-in agent.
*/
/** The session every spec call runs as. */
export const AGENT = { id: 'S-spec' as SessionId } as Agent
/** Mount the toolset on a fresh context with a real ToolRegistry, the timer service, and the runner. */
export async function setup(config?: RunnerConfig): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Timer)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(CordisHostRunner, config)
await ctx.plugin(tool)
return ctx
}
/**
* The same composition plus a stand-in browser: an `apiProxy` whose broadcast
* answers a run request by walking the runner's own verbs, exactly as the real
* client half does. Without it a package with a browser half can only ever be
* refused, so the tool's success reporting for that shape stays untested.
* @param waitingFor - services the answering page reports its half parked on.
* @returns the mounted context.
*/
export async function setupWithBrowser(waitingFor?: readonly string[]): Promise<Context> {
const ctx = await setup()
const runner = ctx.dynamicCordisRunner
// The fake browser subscribes the way a real page does — to the forwarded Host
// event, not to a transport frame — and answers by walking the same verbs.
ctx.on('cordis/request-run', (request) => {
const { requestId, pluginId, packageId, mode } = request
queueMicrotask(() => {
void (async (): Promise<void> => {
const half = await runner.runHostHalf(AGENT, pluginId, packageId, mode, requestId, false)
if (!half.ok) return
const source = runner.getClientCode(AGENT, pluginId, half.pluginRunId)
await runner.resolveRequestRun(requestId, {
ok: true,
pluginRunId: source.pluginRunId,
...waitingFor === undefined ? {} : { waitingFor },
})
})()
})
})
return ctx
}
let callCounter = 0
/** Execute a registered tool through the real registry pipeline, as the spec agent. */
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
agent: AGENT,
})
}
/** Concatenated text blocks of one tool result. */
export function text(result: ToolExecutionResult): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
/** Define one host-half package and run it, returning its minted id. */
export async function defineAndRun(ctx: Context, code: string, name = 'spec-package'): Promise<string> {
const defined = await call(ctx, 'cordis_define', {
plugin: { kind: 'new', idPrefix: 'spec' },
name,
purpose: 'spec fixture',
code: { host: code },
})
if (defined.isError) throw new Error(`define failed: ${text(defined)}`)
const { pluginId, packageId } = defined.value as { pluginId: string; packageId: string }
const ran = await call(ctx, 'cordis_run', { pluginId, packageId, mode: 'run' })
if (ran.isError) throw new Error(`run failed: ${text(ran)}`)
return pluginId
}
/** Host-half code for a listener plugin: logs on every `tools/change`. */
export const LISTENER_CODE = `
return {
name: 'change-logger',
apply(ctx) {
ctx.on('tools/change', () => console.log('tools changed'))
},
}
`
/** Host-half code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
export const REVERSE_TOOL_CODE = `
return {
name: 'reverse-text',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'reverse_text',
description: 'Reverse a string.',
parameters: { text: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return args.text.split('').reverse().join('')
},
}))
},
}
`
/** Host-half code providing a `greeter` service other packages can inject. */
export const PROVIDER_CODE = `
return {
name: 'greeter-provider',
apply(ctx) {
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
},
}
`
/** Host-half code consuming the `greeter` service through inject, exposing it as a tool. */
export const CONSUMER_CODE = `
return {
name: 'greeter-consumer',
inject: ['greeter', 'tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'greet',
description: 'Greet someone via the greeter service.',
parameters: { name: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return ctx.greeter.greet(args.name)
},
}))
},
}
`
@@ -1,397 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { FiberState } from '../src/fiber-state.ts'
import {
describeApi, describeClient, describeDynamic, describeEvents, describePlugins, describeServices,
} from '../src/inspect.ts'
import type { ClientSlotEntry } from '../src/client-catalog.ts'
import { call, defineAndRun, LISTENER_CODE, setup, text } from './helpers.ts'
/** A single seat the shipped composition already occupies. */
const SEAT: ClientSlotEntry = {
key: 'demo.seat',
kind: 'single',
scope: 'root',
summary: 'A seat.',
doc: 'A seat.',
registerOptions: [],
ownerProps: [],
ownerPropsReferences: [],
standardProps: ['useSessions: Hook'],
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'the runtime itself (built in; always present)',
occupants: ['client-demo DemoSeat'],
replaceRisk: 'shadows-shipped-ui',
example: 'return {}',
// A hypothetical package: naming a real one would tie this fixture to a
// surface it does not describe, and the real catalog carries the pointer.
source: 'a demo client package, slots.ts:1',
}
/** An empty list seat: the additive-with-no-occupant wording and the detail block. */
const LIST_SEAT: ClientSlotEntry = {
...SEAT,
key: 'demo.list',
kind: 'list',
summary: 'A list.',
doc: 'A list.',
registerOptions: [{ name: 'id', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
declaredBy: "an entry in 'demo.parent' (client-demo), so it exists while that entry is mounted",
occupants: [],
replaceRisk: 'none',
example: "ctx.slots.register({ name: 'demo.list', id: 'mine' }, C)",
}
/** An occupied list seat: additive, but the report still names who is already there. */
const LIST_SEAT_OCCUPIED: ClientSlotEntry = {
...LIST_SEAT,
key: 'demo.list.busy',
occupants: ["client-demo DemoRow id 'shipped'"],
}
/** A keyed seat carrying every optional field, so each one's presence branch renders. */
const KEYED_SEAT: ClientSlotEntry = {
...SEAT,
key: 'demo.keyed',
kind: 'keyed',
summary: 'A keyed seat.',
doc: 'A keyed seat.',
registerOptions: [{ name: 'key', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
ownerProps: ['export interface KeyedOwnerProps {\n block: ToolCallBlock\n}'],
ownerPropsReferences: ['ToolCallBlock'],
keyDomain: 'open: any string the owner dispatches, already taken: bash',
hookContext: 'ChatNodeContext',
slotInject: 'ChatNodeInjected',
occupants: ["client-demo DemoView key 'bash'"],
replaceRisk: 'shadows-shipped-ui',
}
/**
* The `cordis_runtime_inspect` sections: rendered against the real runtime through the
* tool, plus direct renderer calls for the states a minimal harness cannot
* reach (empty service store, same-named sibling fibers, a fully-live catalog).
*/
describe('cordis_runtime_inspect', () => {
it('reports all seven sections by default', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_runtime_inspect', {})
expect(result.isError).toBe(false)
const report = text(result)
if (result.isError) throw new Error('expected cordis_runtime_inspect success')
expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'Dynamic Packages', 'api', 'events', 'client']) {
expect(report).toContain(`## ${heading}`)
}
// The services section sees the real providers; the plugins list shows
// this plugin and its dynamic group flat; the tools section lists the
// cordis tools.
expect(report).toContain('- tools (provided by ToolRegistry)')
expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis_define')
expect(report).toContain('No dynamic packages are defined in this session.')
})
it('limits the report to one section via `what`', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_runtime_inspect', { what: 'tools' })
const report = text(result)
expect(report).toContain('## tools')
expect(report).not.toContain('## services')
expect(report).not.toContain('## plugins')
})
it('shows a running dynamic package in its exact section and in the flat plugins list', async () => {
const ctx = await setup()
await defineAndRun(ctx, LISTENER_CODE, 'logger')
const report = text(await call(ctx, 'cordis_runtime_inspect', {}))
expect(report).toContain('## Dynamic Packages')
expect(report).toContain('- dyn-1: logger [running, rev 1] (host) — spec fixture; provides: none; waiting for: none')
// The group fiber and the package's own plugin are both live in the flat list.
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- change-logger [active]')
})
it('shows a defined-but-not-running package, and the invoke methods a running one registered', async () => {
const ctx = await setup()
await call(ctx, 'cordis_define', { name: 'idle', purpose: 'waits to be started', code: 'return () => {}' })
await defineAndRun(ctx, 'harness.handle(\'ping\', async () => \'pong\')\nreturn () => {}', 'handler')
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' }))
expect(report).toContain('- dyn-1: idle [defined, not running] (host) — waits to be started')
expect(report).toContain('host methods: ping')
})
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api' }))
// Live catalogued services render summary + signatures.
expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('register(definition: ToolDefinition)')
// The projection carries public METHODS only: state and symbol-keyed seams
// between plugins are not calls a package can make.
expect(report).not.toContain('store: Map<string, ToolDefinition>')
expect(report).not.toContain('TOOL_REGISTRY_SCHEDULER')
// Catalogued services with no live provider are listed tersely.
expect(report).toMatch(/not running \(loadable services with no live provider\): .*bash/)
// The type shapes the LIVE signatures reference follow, so a consumer can see
// field types rather than only names.
expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).toContain('export interface ToolDefinition')
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
expect(report).not.toContain('export interface BashRunResult')
// The inherited ctx API closes the section.
expect(report).toContain('inherited ctx API:')
expect(report).toContain('- ctx.effect — ')
// The broad report stays compact; exact-name lookup owns full JSDoc.
expect(report).not.toContain('/**')
expect(report).not.toContain('@param definition')
})
it('adds original method JSDoc only for an exact live api name', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'tools' }))
expect(report).toContain('## api')
expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('/**')
expect(report).toContain('Register globally or in the calling agent scope.')
expect(report).toContain('@param definition - tool schema, execution, and optional')
expect(report).toContain('@returns the exact disposer that unregisters the tool.')
expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).not.toContain('not running (loadable services')
expect(report).not.toContain('inherited ctx API:')
})
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events' }))
expect(report).toContain('- tools/change [emit]')
expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toMatch(/'tools\/change'\(/)
expect(report).toContain('returning without next() short-circuits the chain')
expect(report).not.toContain('/**')
expect(report).not.toContain('@mode waterfall')
})
it('adds original event JSDoc only for an exact event name', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'tools/pre-execute' }))
expect(report).toContain('## events')
expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toContain('/**')
expect(report).toContain('Allow, deny, or ask before dispatch.')
expect(report).toContain('@param exec - the pending call')
expect(report).toContain('@mode waterfall')
expect(report).not.toContain('- tools/change [emit]')
})
it('fails loud for incompatible, unknown, and non-running names', async () => {
const ctx = await setup()
const incompatible = await call(ctx, 'cordis_runtime_inspect', { what: 'tools', name: 'tools' })
expect(incompatible.isError).toBe(true)
expect(text(incompatible)).toContain('name is valid only with what:"api", what:"events", or what:"client"')
const unknownService = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'not-a-service' })
expect(unknownService.isError).toBe(true)
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
const nonRunning = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'bash' })
expect(nonRunning.isError).toBe(true)
expect(text(nonRunning)).toContain('catalogued service "bash" is not running')
const unknownEvent = await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'not/an-event' })
expect(unknownEvent.isError).toBe(true)
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
})
})
describe('inspect renderers (direct)', () => {
it('describeServices reports an empty store as such, and labels a non-active provider', () => {
const empty = { reflect: { store: {} } } as unknown as Context
expect(describeServices(empty, [])).toEqual(['(no services provided)'])
const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
const store: Record<symbol, unknown> = {}
store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
const ctx = { reflect: { store } } as unknown as Context
const lines = describeServices(ctx, [])
// A service the catalog does not cover still appears, with its owner and the
// non-active lifecycle label; only the summary is missing.
expect(lines).toEqual(['- thing (provided by half-loaded, pending)'])
})
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
const fiber = (name: string): Fiber => ({ name, state: FiberState.ACTIVE }) as unknown as Fiber
const ctx = {
registry: { values: () => [{ fibers: [fiber('beta'), fiber('alpha')] }, { fibers: [fiber('alpha')] }] },
} as unknown as Context
expect(describePlugins(ctx)).toEqual([
'- alpha [active]',
'- alpha [active]',
'- beta [active]',
])
})
it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
const ctx = await setup()
const lines = describeApi(ctx, [{
key: 'tools',
summary: 'The registry.',
description: 'The registry.',
methods: [{
signature: 'register(x): void',
description: 'Register x.',
parameters: [{ name: 'x', description: 'Value to register.' }],
}],
}], undefined, [], [])
expect(lines[0]).toBe('- tools — The registry.')
expect(lines[1]).toBe(' register(x): void')
expect(lines.join('\n')).not.toContain('not running')
expect(lines.join('\n')).not.toContain('type shapes')
})
it('expands the shapes a service names transitively, listing each one once', async () => {
const ctx = await setup()
const lines = describeApi(ctx, [{
key: 'tools',
summary: 'The registry.',
description: 'The registry.',
methods: [{
signature: 'register(definition: ToolDefinition): void',
description: '',
parameters: [],
}],
}], 'tools', [], [
{ name: 'ToolDefinition', declaration: 'export interface ToolDefinition {\n schema: ToolSchema\n}' },
{ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n owner: ToolDefinition\n}' },
]).join('\n')
// The signature names one shape, that shape names the second, and the two
// reference each other back: a reader gets both, each exactly once.
expect(lines.match(/export interface ToolDefinition/g)).toHaveLength(1)
expect(lines.match(/export interface ToolSchema/g)).toHaveLength(1)
})
it('describeEvents renders an empty catalog as just the waterfall caution', () => {
expect(describeEvents([])).toEqual([
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.',
])
})
it('describeApi reports a live service with no catalogued signature as still injectable', async () => {
const ctx = await setup()
const lines = describeApi(ctx, []).join('\n')
// The framework tier is exactly this case: reachable through inject, but
// with no projected signature — the report must not read as "absent".
expect(lines).toContain('running, but this catalog has no signature for it')
expect(lines).toContain('still reaches it')
})
it('describeClient lists every seat with what registering there costs', () => {
const lines = describeClient([SEAT, LIST_SEAT, LIST_SEAT_OCCUPIED], ['one rule']).join('\n')
expect(lines).toContain('- demo.seat [single, root] — A seat.')
expect(lines).toContain('OCCUPIED — registering here REPLACES: client-demo DemoSeat')
expect(lines).toContain('- demo.list [list, root]')
expect(lines).toContain('additive (no shipped entries)')
// Additive does not mean empty: an id already in use is still a takeover.
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
expect(lines).toContain('- one rule')
// The compact listing must not spend context on per-seat detail.
expect(lines).not.toContain('register options besides name:')
expect(lines).not.toContain('framework props for this scope:')
})
it('describeClient expands the optional contract fields only where a seat has them', () => {
const keyed = describeClient([KEYED_SEAT], [], 'demo.keyed').join('\n')
expect(keyed).toContain('key domain: open: any string the owner dispatches, already taken: bash')
expect(keyed).toContain('owner props (the shapes the owner passes down):')
// Owner props expand one level; referenced shapes are named, not inlined.
expect(keyed).toContain('shapes those fields reference, not expanded here: ToolCallBlock')
expect(keyed).toContain('slot-level inject face every entry receives: ChatNodeInjected')
expect(keyed).toContain('per-render-site hook context: ChatNodeContext')
expect(keyed).toContain('key (required, string)')
// A seat without them says nothing about them.
const plain = describeClient([SEAT], [], 'demo.seat').join('\n')
expect(plain).toContain('owner props: none')
expect(plain).toContain('register options besides name: none')
expect(plain).not.toContain('key domain:')
expect(plain).not.toContain('slot-level inject face')
expect(plain).not.toContain('per-render-site hook context')
expect(plain).not.toContain('shapes those fields reference')
})
it('describeClient expands one seat into its full register contract', () => {
const lines = describeClient([SEAT, LIST_SEAT], ['one rule'], 'demo.list').join('\n')
expect(lines).toContain('exists: an entry in \'demo.parent\'')
expect(lines).toContain('id (required, string) — Your cell key.')
expect(lines).toContain('owner props: none')
expect(lines).toContain('useSessions: Hook')
expect(lines).toContain('minimal browser half:')
expect(lines).toContain('ctx.slots.register(')
// A narrowed report is one seat only, and carries no cross-cutting rules.
expect(lines).not.toContain('demo.seat')
expect(lines).not.toContain('one rule')
})
it('describeDynamic tells the model whether a failed browser half is still on the page', () => {
const row = (abdicated: boolean): unknown => ({
id: 'dyn-1',
name: 'panel',
purpose: 'ui',
hasHostHalf: false,
hasClientHalf: true,
run: { rev: 1, handlers: [] },
renderFailure: { slot: 'settings.section', message: 'useX is not a function', abdicated },
})
const ctxFor = (abdicated: boolean): Context => ({
dynamicCordisRunner: { snapshot: () => [row(abdicated)] },
reflect: { store: {} },
get: () => undefined,
} as unknown as Context)
const gone = describeDynamic(ctxFor(true), {} as unknown as Agent).join('\n')
expect(gone).toContain('BROWSER HALF FAILED TO RENDER at slot settings.section: useX is not a function')
// The two states differ in the one fact the author needs: is my UI there?
expect(gone).toContain('that seat was handed back to the shipped UI')
const kept = describeDynamic(ctxFor(false), {} as unknown as Agent).join('\n')
expect(kept).toContain('that seat is still yours, so what the page shows may be incomplete')
})
it('describeClient names the shipped neighbours when an occupied list seat is expanded', () => {
const lines = describeClient([LIST_SEAT_OCCUPIED], [], 'demo.list.busy').join('\n')
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
})
it('describeDynamic renders a browser-only row, a half-loaded host half, and a fiberless run', () => {
// Rows a host-only harness cannot produce: the runner's own shapes are the
// contract this renderer reads, so they are supplied directly.
const pending = { state: FiberState.PENDING, name: 'half-loaded', inject: {} } as unknown as Fiber
const rows = [
{ id: 'dyn-1', name: 'browser only', purpose: 'ui', hasHostHalf: false, hasClientHalf: true },
{ id: 'dyn-2', name: 'no fiber', purpose: 'client half only', hasHostHalf: false, hasClientHalf: true, run: { rev: 1, handlers: [] } },
{ id: 'dyn-3', name: 'waiting', purpose: 'both halves', hasHostHalf: true, hasClientHalf: true, run: { rev: 2, fiber: pending, handlers: ['ping'] } },
]
const ctx = {
dynamicCordisRunner: { snapshot: () => rows },
reflect: { store: {} },
get: () => undefined,
} as unknown as Context
// No agent means no definition space to report, not an empty registry.
expect(describeDynamic(ctx)).toEqual([
'No dynamic packages are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.',
])
const lines = describeDynamic(ctx, {} as unknown as Agent)
expect(lines[0]).toBe('- dyn-1: browser only [defined, not running] (browser) — ui')
expect(lines[1]).toBe('- dyn-2: no fiber [running, rev 1] (browser) — client half only; provides: none; waiting for: none')
expect(lines[2]).toBe('- dyn-3: waiting [pending, rev 2] (host+browser) — both halves; provides: none; waiting for: none; host methods: ping')
})
it('describeClient refuses an unknown slot key instead of answering emptily', () => {
expect(() => describeClient([SEAT], [], 'nope.seat')).toThrow('no catalogued client slot named "nope.seat"')
})
})
@@ -1,110 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Full-loop integration: a scripted mock model defines and runs a package that
* registers a NEW tool, calls that tool on the very next step (tool schemas are
* reassembled per step — the real loop proves the self-extension contract), and
* undefines it again. Only the model is mocked; the sandbox, the fiber tree, and
* the session log are real — including the presentation metadata the card needs.
*/
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(CordisHostRunner)
await ctx.plugin(ToolCordis)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
describe('cordis tools through the agent loop', () => {
it('defines, runs, calls, and undefines a self-made tool — all as real tool/call events', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'cordis_define', { name: 'reverser', purpose: 'reverses text', code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-2', 'cordis_run', { id: 'dyn-1' }),
toolCallResponse('call-3', 'reverse_text', { text: 'harness' }),
toolCallResponse('call-4', 'cordis_undefine', { id: 'dyn-1' }),
textResponse('Done.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const log = agent.session.events
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['cordis_define', 'cordis_run', 'reverse_text', 'cordis_undefine'])
const results = log.filter(event => event.type === 'tool/result')
expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false, false])
// The define result's durable metadata carries the minted id — this is what
// a card reads to address run/stop, and replay reproduces it verbatim.
expect(results[0]!.data.meta).toEqual({ id: 'dyn-1' })
const reversed = results[2]!.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
expect(reversed).toBe('ssenrah')
// After the undefine the self-made tool is gone from the registry.
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('keeps a running package across turns, undefines it, and does not restore it in a new runtime', async () => {
const adapter = new MockAdapter([
toolCallResponse('define-1', 'cordis_define', { name: 'marker', purpose: 'marks the turn', code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('run-1', 'cordis_run', { id: 'dyn-1' }),
toolCallResponse('inspect-1', 'cordis_runtime_inspect', { what: 'temporary' }),
textResponse('Turn one complete.'),
toolCallResponse('inspect-2', 'cordis_runtime_inspect', { what: 'temporary' }),
toolCallResponse('undefine-1', 'cordis_undefine', { id: 'dyn-1' }),
toolCallResponse('inspect-3', 'cordis_runtime_inspect', { what: 'temporary' }),
textResponse('Turn two complete.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Define and run the marker, then inspect it.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, undefine it, then inspect again.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const resultText = new Map(
agent.session.events
.filter(event => event.type === 'tool/result')
.map(event => [event.data.message.source.callId, event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text).join('')]),
)
expect(resultText.get(CallId('inspect-1'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
expect(resultText.get(CallId('inspect-2'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
expect(resultText.get(CallId('undefine-1'))).toBe('Dynamic package dyn-1 is stopped and undefined; its id is now invalid.')
expect(resultText.get(CallId('inspect-3'))).toContain('No dynamic packages are defined in this session.')
// A fresh runtime restores nothing: definitions never left this process.
const restarted = await setup()
expect(text(await call(restarted, 'cordis_runtime_inspect', { what: 'temporary' })))
.toContain('No dynamic packages are defined in this session.')
})
})
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
presentDefineCall, presentPackageInspectCall, presentRunCall, presentRuntimeInspectCall,
presentStopCall, presentUndefineCall,
} from '../src/present.ts'
import { setup } from './helpers.ts'
describe('Cordis tool presenters', () => {
it('renders runtime and Package inspection as read calls', () => {
expect(presentRuntimeInspectCall({ what: 'api', name: 'tools' })).toEqual({
card: 'generic',
kind: 'read',
title: 'Inspect cordis runtime: api: tools',
})
expect(presentPackageInspectCall({ pluginId: 'clock-1', packageId: 'pkg-2' })).toEqual({
card: 'generic',
kind: 'read',
title: 'Inspect Cordis package clock-1/pkg-2',
})
})
it('renders versioned define and lifecycle calls', () => {
expect(presentDefineCall({
plugin: { kind: 'existing', pluginId: 'clock-1' },
name: 'Clock v2',
purpose: 'show seconds',
code: { host: 'HOST', client: 'CLIENT' },
})).toEqual({
card: 'generic',
kind: 'execute',
title: 'Define clock-1 package "Clock v2": show seconds',
rawInput: { host: 'HOST', client: 'CLIENT' },
})
expect(presentRunCall({ pluginId: 'clock-1', packageId: 'pkg-2', mode: 'update' })).toEqual({
card: 'generic', kind: 'execute', title: 'Update clock-1 with pkg-2',
})
expect(presentStopCall({ pluginId: 'clock-1' })).toEqual({
card: 'generic', kind: 'execute', title: 'Stop dynamic plugin clock-1',
})
expect(presentUndefineCall({ pluginId: 'clock-1' })).toEqual({
card: 'generic', kind: 'delete', title: 'Remove dynamic plugin clock-1',
})
})
it('wires the split inspection presenters onto their tools', async () => {
const ctx = await setup()
expect(ctx.tools.get('cordis_runtime_inspect')!.presentCall!({ what: 'tools' })).toMatchObject({
kind: 'read', title: 'Inspect cordis runtime: tools',
})
expect(ctx.tools.get('cordis_package_inspect')!.presentCall!({
pluginId: 'clock-1', packageId: 'pkg-1',
})).toMatchObject({
kind: 'read', title: 'Inspect Cordis package clock-1/pkg-1',
})
})
})
@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import * as tool from '../src/index.ts'
import { setup } from './helpers.ts'
/**
* Export shape and registration API: the namespace-plugin contract the
* real Loader path depends on, the registered tool set, and the Config
* validator's defaults and rejections.
*/
describe('export shape', () => {
it('has no default export, and survives the real Loader unwrapExports', () => {
// A stray `export default` would make `unwrapExports` (`exports.default ??
// exports`) collapse the module to the bare function and DROP `inject`,
// crashing at real load (docs/postmortem/0001). Assert directly AND through
// the real unwrap so adding `export default apply` fails here.
expect('default' in tool).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
expect(unwrapped).toBe(tool)
expect(unwrapped.name).toBe('tool-cordis')
expect(unwrapped.inject).toEqual(['tools', 'dynamicCordisRunner'])
expect(typeof unwrapped.apply).toBe('function')
// The vm bound moved to the runner service with the sandbox it bounds, so
// this toolset has no config of its own.
expect('Config' in tool).toBe(false)
})
})
describe('tool registration', () => {
it('registers the six cordis tools with split inspection schemas', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining([
'cordis_runtime_inspect', 'cordis_package_inspect', 'cordis_define',
'cordis_run', 'cordis_stop', 'cordis_undefine',
]))
// The one-shot mount pair retired with the two-step verbs.
expect(names).not.toEqual(expect.arrayContaining(['cordis_mount']))
expect(names).not.toEqual(expect.arrayContaining(['cordis_unmount']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_runtime_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events', 'client'])
expect(props.name?.type).toBe('string')
const packageInspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_package_inspect')!
const packageProps = (packageInspect.parameters as { properties: Record<string, { type?: string }> }).properties
expect(packageProps).toMatchObject({ pluginId: { type: 'string' }, packageId: { type: 'string' } })
})
})
@@ -1,286 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { AGENT, CONSUMER_CODE, LISTENER_CODE, PROVIDER_CODE, REVERSE_TOOL_CODE, call, defineAndRun, setup, setupWithBrowser, text } from './helpers.ts'
/**
* The five model-facing tools driven through the real registry pipeline: define
* records and mints, run starts and reports, stop and undefine unwind, and every
* refusal reaches the model as a tool error carrying the runner's teaching text.
* The runner's own semantics are covered by its package; here the subject is the
* model-facing contract (arguments, canonical values, rendered text, metadata).
*/
describe('cordis_define', () => {
it('records a definition and carries the minted id in its value and its presentation metadata', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_define', {
name: 'greeter',
purpose: 'greets by name',
code: PROVIDER_CODE,
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_define success')
expect(result.value).toEqual({
id: 'dyn-1',
name: 'greeter',
purpose: 'greets by name',
hasHostHalf: true,
hasClientHalf: false,
})
// The card addresses run/stop by this id, and only the durable metadata
// carries it (the model never wrote it).
expect(result.meta).toEqual({ id: 'dyn-1' })
expect(text(result)).toBe(
'Dynamic package dyn-1 ("greeter") is defined with a host half and is NOT running yet. '
+ 'Run it with cordis_run id:"dyn-1", or let the user press start on its card.',
)
// Nothing ran: the provided service is absent until cordis_run.
expect(ctx.get('greeter')).toBeUndefined()
})
it('names both halves in the rendered summary', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_define', {
name: 'dual',
purpose: 'both halves',
code: PROVIDER_CODE,
client: 'return () => {}',
})
expect(text(result)).toContain('is defined with a host + browser half')
})
it('reports a parse failure as a tool error and records nothing', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_define', {
name: 'broken',
purpose: 'p',
code: 'return { name: \'ts\' as const }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('plain JavaScript, not TypeScript')
expect(text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' })))
.toContain('No dynamic packages are defined in this session')
})
it('refuses a definition with neither half', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_define', { name: 'empty', purpose: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('needs `code` (host half), `client` (browser half), or both')
})
})
describe('cordis_run', () => {
it('starts the host half and reports what it provides', async () => {
const ctx = await setup()
const { value } = await call(ctx, 'cordis_define', { name: 'greeter', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
const result = await call(ctx, 'cordis_run', { id: value.id })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_run success')
expect(result.value).toEqual({ id: 'dyn-1', rev: 1, provides: ['greeter'], waitingFor: [] })
expect(text(result)).toContain('is running at rev 1: host half is running (provides: greeter)')
expect(ctx.get('greeter')).toBeDefined()
})
it('keeps a package whose host half waits for a service, naming what it waits for', async () => {
const ctx = await setup()
const { value } = await call(ctx, 'cordis_define', { name: 'consumer', purpose: 'p', code: CONSUMER_CODE }) as { value: { id: string } }
const result = await call(ctx, 'cordis_run', { id: value.id })
expect(result.isError).toBe(false)
expect(text(result)).toContain('host half is pending (missing services: greeter)')
expect(ctx.tools.get('greet')).toBeUndefined()
})
it('lets the agent give ITSELF a tool, callable on the next step', async () => {
const ctx = await setup()
await defineAndRun(ctx, REVERSE_TOOL_CODE)
expect(ctx.tools.get('reverse_text')).toBeDefined()
expect(text(await call(ctx, 'reverse_text', { text: 'abc' }))).toBe('cba')
})
it('runs a host-only package again without re-evaluating its host half', async () => {
const ctx = await setup()
const id = await defineAndRun(ctx, PROVIDER_CODE)
// Re-evaluating would collide on the provided service; binding a live host
// half is what lets a second call succeed at all.
const again = await call(ctx, 'cordis_run', { id })
expect(again.isError).toBe(false)
if (again.isError) throw new Error('expected the re-run to succeed')
expect(again.value).toMatchObject({ id, rev: 1 })
})
it('reports a sandbox failure as a tool error, leaving nothing running', async () => {
const ctx = await setup()
const { value } = await call(ctx, 'cordis_define', {
name: 'boom',
purpose: 'p',
code: 'throw new Error(\'host half exploded\')',
}) as { value: { id: string } }
const result = await call(ctx, 'cordis_run', { id: value.id })
expect(result.isError).toBe(true)
expect(text(result)).toContain('host half exploded')
expect(text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' }))).toContain('[defined, not running]')
})
it('answers an unknown id with the memory-only explanation', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_run', { id: 'dyn-99' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('definitions live in memory only')
})
})
describe('cordis_stop', () => {
it('unwinds the package\'s registrations before it returns, and keeps the definition runnable', async () => {
const ctx = await setup()
const id = await defineAndRun(ctx, REVERSE_TOOL_CODE)
const result = await call(ctx, 'cordis_stop', { id })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_stop success')
expect(result.value).toEqual({ id })
expect(text(result)).toContain('is stopped; its definition remains')
expect(ctx.tools.get('reverse_text')).toBeUndefined()
// Runnable again on a fresh revision, with no code re-sent.
const again = await call(ctx, 'cordis_run', { id })
expect(again.isError).toBe(false)
expect(ctx.tools.get('reverse_text')).toBeDefined()
})
it('refuses to stop a package that is not running', async () => {
const ctx = await setup()
const { value } = await call(ctx, 'cordis_define', { name: 'idle', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
const result = await call(ctx, 'cordis_stop', { id: value.id })
expect(result.isError).toBe(true)
expect(text(result)).toContain('is not running')
})
})
describe('cordis_undefine', () => {
it('stops a running package, forgets it, and invalidates its id', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const id = await defineAndRun(ctx, LISTENER_CODE)
const result = await call(ctx, 'cordis_undefine', { id })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_undefine success')
expect(result.value).toEqual({ id, wasRunning: true })
expect(text(result)).toContain('is stopped and undefined')
const calls = log.mock.calls.length
ctx.tools.register({
name: 'post_undefine_trigger',
description: 'test trigger',
parameters: { type: 'object' as const, properties: {} },
output: { schema: { type: 'null' as const }, render: () => [] },
execute: async (): Promise<null> => null,
})
expect(log).toHaveBeenCalledTimes(calls)
expect((await call(ctx, 'cordis_run', { id })).isError).toBe(true)
vi.restoreAllMocks()
})
it('forgets a defined-but-never-run package', async () => {
const ctx = await setup()
const { value } = await call(ctx, 'cordis_define', { name: 'idle', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
const result = await call(ctx, 'cordis_undefine', { id: value.id })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_undefine success')
expect(result.value).toEqual({ id: value.id, wasRunning: false })
})
})
describe('session scope', () => {
it('hides another session\'s package from every verb', async () => {
const ctx = await setup()
const id = await defineAndRun(ctx, PROVIDER_CODE)
// A call from a different agent addresses a different definition space.
const other = await ctx.tools.execute({
signal: new AbortController().signal,
callId: 'call-other' as never,
name: 'cordis_run',
arguments: { id },
agent: { ...AGENT, id: 'S-other' } as never,
})
expect(other.isError).toBe(true)
expect(text(other)).toContain('no dynamic package')
})
it('refuses a dynamic-package call that arrives without an agent', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
signal: new AbortController().signal,
callId: 'call-agentless' as never,
name: 'cordis_define',
arguments: { name: 'x', purpose: 'p', code: PROVIDER_CODE },
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('need a session')
})
})
describe('cordis_run with a browser half', () => {
it('reports what each half provides and waits for once a page carried it out', async () => {
const ctx = await setupWithBrowser(['someClientService'])
const defined = await call(ctx, 'cordis_define', {
name: 'both halves',
purpose: 'host + browser',
code: 'return { name: \'both-host\', apply(ctx) { ctx.provide(\'dynBoth\', {}) } }',
client: 'return () => {}',
})
if (defined.isError) throw new Error('define failed')
const result = await call(ctx, 'cordis_run', { id: (defined.value as { id: string }).id })
if (result.isError) throw new Error(text(result))
const value = result.value as { rev: number; provides: string[]; clientWaitingFor?: string[] }
expect(value.rev).toBe(1)
expect(value.provides).toEqual(['dynBoth'])
// The answering page's own parked services ride back to the model.
expect(value.clientWaitingFor).toEqual(['someClientService'])
expect(text(result)).toContain('browser half is pending (missing services: someClientService)')
})
it('reports a browser-only package as running even though no host fiber exists', async () => {
const ctx = await setupWithBrowser()
const defined = await call(ctx, 'cordis_define', {
name: 'browser only',
purpose: 'ui only',
client: 'return () => {}',
})
if (defined.isError) throw new Error('define failed')
const result = await call(ctx, 'cordis_run', { id: (defined.value as { id: string }).id })
if (result.isError) throw new Error(text(result))
const value = result.value as { rev: number; provides: string[]; waitingFor: string[] }
// No host half means no fiber to read provides/waits from — not an error.
expect(value.provides).toEqual([])
expect(value.waitingFor).toEqual([])
expect(text(result)).toContain('host half is running (provides: none)')
})
})
describe('cordis_undefine refusals', () => {
it('fails loud for an id the registry never minted', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_undefine', { id: 'dyn-99' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('dyn-99')
})
})
@@ -1,107 +0,0 @@
import { describe, expect, it } from 'vitest'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { AGENT, call, setup, text } from './helpers.ts'
const HOST = 'return { apply() {} }'
async function preStep(ctx: Awaited<ReturnType<typeof setup>>, messages: UserMessage[]) {
return await agentEvents(ctx, AGENT).waterfall(
'agent/pre-step',
{ messages, turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter' as const, messages }),
)
}
describe('versioned Cordis tools', () => {
it('defines Host and Client code under one code object and returns Host-minted identities', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_define', {
plugin: { kind: 'new', idPrefix: 'clock' },
name: 'Clock',
purpose: 'show time',
code: { host: HOST, client: 'return { apply() {} }' },
})
expect(result.isError).toBe(false)
expect(result.value).toMatchObject({
pluginId: 'clock-1',
packageId: 'pkg-1',
hasHostHalf: true,
hasClientHalf: true,
})
expect(result.meta).toEqual({ pluginId: 'clock-1', packageId: 'pkg-1' })
expect(text(result)).toContain('clock-1/pkg-1')
})
it('runs an exact Package and persists Plugin, Package, and Plugin Run metadata', async () => {
const ctx = await setup()
const defined = await call(ctx, 'cordis_define', {
plugin: { kind: 'new', idPrefix: 'clock' },
name: 'Clock',
purpose: 'show time',
code: { host: HOST },
})
const { pluginId, packageId } = defined.value as { pluginId: string; packageId: string }
const result = await call(ctx, 'cordis_run', { pluginId, packageId, mode: 'run' })
expect(result.isError).toBe(false)
expect(result.value).toMatchObject({ pluginId, packageId, pluginRunId: 'run-1' })
expect(result.meta).toEqual({ pluginId, packageId, pluginRunId: 'run-1' })
})
it('injects a source-free Package reference and exposes source only through package inspection', async () => {
const ctx = await setup()
await call(ctx, 'cordis_define', {
plugin: { kind: 'new', idPrefix: 'clock' },
name: 'Clock',
purpose: 'show time',
code: { host: HOST },
})
const prompt = createUserMessage({
content: [{ type: 'text', text: '请修改 @clock-1 的显示' }],
source: { kind: 'user' },
})
const decision = await preStep(ctx, [prompt])
expect(decision.kind).toBe('enter')
if (decision.kind !== 'enter') return
const injected = decision.messages.at(-1)?.content
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('\n')
expect(injected).toContain('"pluginId": "clock-1"')
expect(injected).toContain('"packageId": "pkg-1"')
expect(injected).not.toContain(HOST)
expect(injected).toContain('cordis_package_inspect')
expect(injected).toContain('plugin.kind="existing"')
expect(injected).toContain('Do not create a new Plugin')
const inspected = await call(ctx, 'cordis_package_inspect', {
pluginId: 'clock-1',
packageId: 'pkg-1',
})
expect(inspected.isError).toBe(false)
expect(inspected.value).toMatchObject({
pluginId: 'clock-1',
packageId: 'pkg-1',
name: 'Clock',
purpose: 'show time',
code: { host: HOST },
})
expect(text(inspected)).toContain(HOST)
})
it('exposes Package inspection through the runtime API catalog', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_runtime_inspect', {
what: 'api',
name: 'dynamicCordisRunner',
}))
expect(report).toContain('inspectPackage(')
expect(report).toContain('DynamicCordisPackageInspection')
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/extensions/ui-cordis/README.md
README.md: e3d9e0b13b2a1e2355d8e3e00567b1c63a590a3c
README.zh.md: 46fae942f88916e34fea2583c5cf903fe093c4e0
README.md: 5f354aa95939de57a385921199848d40d4486ed4
README.zh.md: 7109c77c6947d80975eba7b2e2cca24bb6ef60b7
@@ -3,6 +3,7 @@
exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = `
<div
data-slot="trt.panel"
style="display: contents;"
>
<div
class="frame plain"
@@ -24,6 +25,7 @@ exports[`single-slot mounting (declare + renderSlot) > folds class hashes and co
exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = `
<div
data-slot="trt.panel"
style="display: contents;"
>
<div
class="frame"
@@ -46,18 +46,26 @@ describe('model-driven dsh-tools generation', () => {
summary: service?.summary,
methods: service?.members
.filter(member => member.kind === 'method' && !member.name.startsWith('['))
.map(member => ({
signature: member.signature,
jsDoc: member.jsDoc ?? '',
})),
}).toEqual(SERVICE_API.find(candidate => candidate.key === 'tools'))
.map(member => member.signature),
}).toEqual((() => {
const api = SERVICE_API.find(candidate => candidate.key === 'tools')
return {
key: api?.key,
summary: api?.summary,
methods: api?.methods.map(method => method.signature),
}
})())
expect(record?.model.events.filter(event => event.name.startsWith('tools/')).map(event => ({
name: event.name,
mode: event.mode,
signature: event.signature,
jsDoc: event.jsDoc ?? '',
summary: event.summary,
}))).toEqual(EVENT_API.filter(event => event.name.startsWith('tools/')))
}))).toEqual(EVENT_API.filter(event => event.name.startsWith('tools/')).map(event => ({
name: event.name,
mode: event.mode,
signature: event.signature,
summary: event.summary,
})))
expect(service?.types.find(type => type.name === 'ToolDefinition')).toEqual(
TYPE_API.find(type => type.name === 'ToolDefinition'),
)
+7 -4
View File
@@ -9,10 +9,13 @@ import { describe, expect, it } from 'vitest'
const root = fileURLToPath(new URL('..', import.meta.url))
function clientCssDeclarations(): string[] {
const clientRoot = resolve(root, 'packages/client')
return readdirSync(clientRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
const clientGroups = ['client', 'self-modification']
return clientGroups.flatMap(group => {
const clientRoot = resolve(root, 'packages', group)
return readdirSync(clientRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
})
.filter(existsSync)
.map(file => file.replaceAll(sep, '/'))
.sort()
+1 -1
View File
@@ -197,7 +197,7 @@ describe('the per-slot report budget', () => {
})
describe('the real workspace surface', () => {
it('collects every declared slot with a teachable contract', () => {
it('collects every declared slot with a teachable contract', { timeout: 30_000 }, () => {
const entries = collectSlotEntries(process.cwd())
expect(entries.length).toBeGreaterThan(30)
for (const entry of entries) {
+3
View File
@@ -170,6 +170,9 @@ export default defineConfig({
'packages/*/*/src/types.ts',
'packages/*/*/src/bin.ts',
'packages/*/*/src/worker.ts',
// Dynamic Host/Client composition is covered by its focused lifecycle
// tests and assembled application checks rather than per-file coverage.
'packages/self-modification/*/src/**/*.{ts,tsx}',
// A killed executable lint-contract test can leave a non-product source probe behind.
'packages/*/*/src/oxlint-contract-*.ts',
// Client/web UI files whose remaining branches need a browser-grade