diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 91c0034..0affbb5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -28,7 +28,7 @@ body: attributes: label: DeepSeek Harness version / DeepSeek Harness 版本 description: The `@deepseek-ai/dsh` version in use, if relevant. / 如相关,请填写正在使用的 `@deepseek-ai/dsh` 版本。 - placeholder: e.g. 0.1.5-rc.1 + placeholder: e.g. 0.2.0-rc.2 validations: required: false - type: textarea diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 968c66e..6716c9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,7 @@ jobs: - name: Check the DeeSeek Harness plugin # `next` = the newest published generation (0.2.0-rc.2), whose npm # dependency closure is complete; `latest` still serves the other - # supported line (0.1.7-rc.2). The 0.1.5-rc line is transitively broken - # on npm right now: web-app 0.1.5-rc.3 was published without the - # dsh-client-ui-sidebar-documentpreview rc.3 it requests, and every - # ^0.1.5-rc range resolves to it. Revisit when upstream republishes a - # complete stable closure. + # supported line (0.1.7-rc.2). uses: bowenliang123/dsh-plugin-checker@v1 # Reads the pinned pnpm version from `packageManager` in package.json. @@ -61,7 +57,6 @@ jobs: git init -q git remote add origin https://github.com/deepseek-ai/deepseek-harness.git git fetch --depth 1 -q origin \ - refs/tags/dsh-v0.1.5-rc.1:refs/tags/dsh-v0.1.5-rc.1 \ refs/tags/dsh-v0.1.7-rc.2:refs/tags/dsh-v0.1.7-rc.2 \ refs/tags/dsh-v0.2.0-rc.2:refs/tags/dsh-v0.2.0-rc.2 diff --git a/README.md b/README.md index 5774c88..86b1a47 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [`dsh-context`](https://www.npmjs.com/package/dsh-context) provides full context lifecycle management features. - **Context Insights page** — the cross-session overview as a first-level sidebar panel (beside Plugins and Automation tasks): KPI band with a 7-day token/cost usage chart, activity heatmap, aggregate composition ring, and filterable session cards that jump straight into any session. - **Context tab** — an UI context dashboard for DeepSeek Harness's context stats, composition, trend, events, and messages. -- **Context panel** — the same dashboard as a right-sidebar tab (dsh 0.1.5-rc.1+): pick **Context** on the sidebar's guide page and the panel opens beside the chat. +- **Context panel** — the same dashboard as a right-sidebar tab: pick **Context** on the sidebar's guide page and the panel opens beside the chat. - **`/context` command** — the slash command shows the context model for current context composition and recent context evolution. ## Install / Update @@ -154,7 +154,7 @@ One row per touched file — read, written, or searched — aggregated up to whi - **Every mode counts** — native tools, the Minimal preset's `str_replace_editor`, and the nested calls inside PTC `run_code` programs are all folded into per-tool rows. - **Searches land on real files** — matched files get their own ops rows with hit counts. - **Click a row** to expand its full operation log — every op jumps straight to the exact tool result in the Context browser. -- **Click a file name** to open its preview in the right Sidebar (dsh 0.1.5-rc.1+), exactly as the built-in Files sidebar does — the same viewer, the same tab-per-file behavior. On a harness without that column the name opens on your system as before. +- **Click a file name** to open its preview in the right Sidebar, exactly as the built-in Files sidebar does — the same viewer, the same tab-per-file behavior. On a harness without that column the name opens on your system as before. ### 🕸 Agent Network — the family portrait @@ -174,10 +174,7 @@ A centered dialog opens with the **Current Composition** card and the **Context ## ⚙️ Settings -The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. Where it lives depends on your dsh release: - -- **dsh 0.1.7+** — the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form). -- **older releases** — **Settings → Plugins → Plugin configuration** → the **Context** card. +The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. It lives on the **Plugins** page: the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form). In-chart and in-card toggles stay per-view and never overwrite the stored preference. @@ -186,7 +183,7 @@ In-chart and in-card toggles stay per-view and never overwrite the stored prefer ## Good to know - **Estimates vs actuals** — category figures use dsh's own fixed-density heuristic (the same one as its built-in token meter); the pinned trend details show provider-reported actuals next to them, and the Token card pairs its ≈-estimated composition shares with the provider-exact billed total. -- **Compatibility** — works on `@deepseek-ai/dsh` **0.1.5-rc.1+** (the `0.1.5` line from rc.1, the `0.1.7` line from rc.2, the `0.2.0` line from rc.2), across the V3 (`0.1.5-alpha.x+`) and V4 (`0.1.6/0.1.7/0.2.0+`) session-log generations, from one shape-driven code path. The per-release matrix and how it is verified: [docs/compatibility.md](docs/compatibility.md). +- **Compatibility** — works on `@deepseek-ai/dsh` **0.1.7-rc.2+** (the `0.1.7` line from rc.2 and the `0.2.0` line from rc.2), the V4 session-log generation, from one shape-driven code path. The per-release matrix and how it is verified: [docs/compatibility.md](docs/compatibility.md). - **I18n** — UI in English and 简体中文. ## Like it? diff --git a/docs/compatibility.md b/docs/compatibility.md index ce17af1..0c92fbe 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,27 +2,26 @@ dsh-context declares per-release compatibility with `@deepseek-ai/dsh` in its package manifest (`dsh.compatibility.dshReleases`). This page records what is actually verified for each declared release, and how. -Last verified: **2026-09-29** (plugin `dsh-context@0.60.0` source tree, dsh `0.2.0-rc.2`). +Last verified: **2026-10-07** (plugin `dsh-context@0.65.0` source tree, dsh `0.2.0-rc.2`). ## Supported dsh releases | dsh release | Session log | Declared | Automated seam matrix | Disposable-profile install / uninstall | | --- | --- | --- | --- | --- | -| `0.1.5-rc.1` | V3 | compatible | ✅ baseline `v0.1.5-rc.1` | ✅ install OK → 1 composed row → uninstall OK → 0 rows (verified 2026-09-10) | | `0.1.7-rc.2` | V4 | compatible | ✅ baseline `v0.1.7-rc.2` | ✅ install OK → uninstall OK (verified manually 2026-09-28) | | `0.2.0-rc.2` | V4 | compatible | ✅ baseline `v0.2.0-rc.2` | ✅ install OK → 1 composed row → uninstall OK → 0 rows (`dsh-context@0.60.0`, verified manually 2026-09-29) | -The automated seam matrix runs for every row on every `pnpm test`. The disposable-profile column is a manual, per-release check: each release's CLI was installed from npm into a temporary `DSH_HOME` (the real `~/.dsh` is never touched) — `0.1.5-rc.1` on 2026-09-10 (a stale mirror can 404 the harness's own dependency closure before the plugin is even considered), then `0.1.7-rc.2` and `0.2.0-rc.2` on 2026-09-28 and 2026-09-29. The check pins the plugin build it installs: a bare-name install resolves through the profile's package manager, whose release-age policy can lag a just-published build (on `0.2.0-rc.2` the bare name resolved to `0.59.1`, so the row records the explicit `dsh-context@0.60.0`). The only pnpm complaints there are peers the harness supplies from its own runtime module tree — none version-incompatible, and the install is never refused. +The automated seam matrix runs for every row on every `pnpm test`. The disposable-profile column is a manual, per-release check: each release's CLI was installed from npm into a temporary `DSH_HOME` (the real `~/.dsh` is never touched) — `0.1.7-rc.2` and `0.2.0-rc.2` on 2026-09-28 and 2026-09-29. The check pins the plugin build it installs: a bare-name install resolves through the profile's package manager, whose release-age policy can lag a just-published build (on `0.2.0-rc.2` the bare name resolved to `0.59.1`, so the row records the explicit `dsh-context@0.60.0`). The only pnpm complaints there are peers the harness supplies from its own runtime module tree — none version-incompatible, and the install is never refused. -The supported range is the three lines above: the `0.1.5` line from `rc.1`, the `0.1.7` line from `rc.2` (the first release of that line with a complete npm dependency closure; `rc.2` adds `startsSeries` to the first `request/header` of a resumed series and the `developer/message` tool-registry events; `startsSeries` is inert to the fold, while registry messages contribute to injected context), and the `0.2.0` line from `rc.2` — the continuation of the V4 Config-form generation, every seam the plugin rides spelled as at `0.1.7-rc.2` (cordis `4.0.4`, the same projection-registry contract, platform module table, client seats, settings transport, and durable-event vocabulary; still the V4 log generation, with no `v4-to-v5` migration in the session-format tree). Everything older — the `0.1.2`/`0.1.3` generations, the `0.1.1` line, and the alpha previews — was supported through `dsh-context@0.55.x` and is no longer in the support matrix; on such a harness the plugin's baseline gate composes the fallback units and shows the upgrade prompt instead of folding. +The supported range is the two lines above: the `0.1.7` line from `rc.2` (the first release of that line with a complete npm dependency closure; `rc.2` adds `startsSeries` to the first `request/header` of a resumed series and the `developer/message` tool-registry events; `startsSeries` is inert to the fold, while registry messages contribute to injected context), and the `0.2.0` line from `rc.2` — the continuation of the V4 Config-form generation, every seam the plugin rides spelled as at `0.1.7-rc.2` (cordis `4.0.4`, the same projection-registry contract, platform module table, client seats, settings transport, and durable-event vocabulary; still the V4 log generation, with no `v4-to-v5` migration in the session-format tree). Everything older — the `0.1.5` line (supported through `dsh-context@0.65.x`), the `0.1.2`/`0.1.3` generations, the `0.1.1` line, and the alpha previews — is no longer in the support matrix; on such a harness the plugin's baseline gate composes the fallback units and shows the upgrade prompt instead of folding. ## Harness-side compatibility admission (0.1.7+) -From `0.1.7` the harness enforces a plugin's own `peerDependencies` at startup and install: a bundle whose `@deepseek-ai/dsh*` peer ranges do not satisfy the running version (semver, `includePrerelease: true`) is skipped at composition, refused at install, and only loads with an exact-version exemption (`dsh plugin allow-version`). This plugin's dsh peers are all `>=0.1.5-rc.1`, which every supported release — all three prerelease lines included — satisfies, so the plugin composes normally with no exemption. The `0.2.0` line keeps the same gate. +Every supported line enforces a plugin's own `peerDependencies` at startup and install: a bundle whose `@deepseek-ai/dsh*` peer ranges do not satisfy the running version (semver, `includePrerelease: true`) is skipped at composition, refused at install, and only loads with an exact-version exemption (`dsh plugin allow-version`). This plugin's dsh peers are all `>=0.1.7-rc.2`, which both supported lines satisfy, so the plugin composes normally with no exemption. The gate predates them (`0.1.7` introduced it), so an older harness without it (the `0.1.5`/`0.1.6` lines) still loads the plugin — and the plugin's own baseline gate below is what composes the fallback units there. ## Baseline gate -The declared floor is enforced at runtime, not only documented. At startup the host resolves the running harness's version — first from the module tree the plugin is bound to, then from the `$DSH_HOME/profiles/node_modules` mirror — and compares it against `0.1.5-rc.1` (channel order: release > rc > beta > alpha): +The declared floor is enforced at runtime, not only documented. At startup the host resolves the running harness's version — first from the module tree the plugin is bound to, then from the `$DSH_HOME/profiles/node_modules` mirror — and compares it against `0.1.7-rc.2` (channel order: release > rc > beta > alpha): - **Below the floor** — fallback projection units that fold nothing and serve zeroed data: the Context tab keeps its (empty) cards while a modal names both versions and urges the update. - **At or above the floor** — the real projection units. @@ -30,33 +29,30 @@ The declared floor is enforced at runtime, not only documented. At startup the h The mirror reflects whichever installation last booted a CLI profile, which need not be the running one (a packaged desktop client never refreshes it), so it never outranks the running module tree. Implementation: `src/host/version.ts`, `src/host/fallback.ts`, `src/shared/version.ts`. -## Session-log generations +## Session-log generation -The supported range spans three baselines and two durable-log generations, and the plugin folds both generations from one shape-driven code path: the spellings meet only in `src/host/logShapes.ts` (`firstTokenTimeOfStream`, `replaceRangeOf`, `isTokenChunk`), and `applyTimeline` runs a single code path over all of them: +Every supported line is the V4 durable-log generation, and the plugin folds it from one shape-driven code path (`src/host/logShapes.ts` holds the vocabulary readers — `firstTokenTimeOfStream`, `replaceRangeOf`, `isTokenChunk` — and `applyTimeline` runs a single code path over them): -| Seam | V3 (`0.1.5-rc.1+`) | V4 (`0.1.6/0.1.7+`, `0.2.0+`) | -| --- | --- | --- | -| System prompt | `system/message` surface node (a `request/header` carrying `header.system` is rejected outright) | same as V3 | -| First token | embedded `assistant/message.data.stream` (also `assistant/attempt.data.stream`) — packed delta runs plus raw `chunk` records | same as V3 | -| Replacement endpoints | `{ startSeq, endSeq }` | same as V3 | -| Nested PTC dispatch | `tool/ptc-dispatch` | same as V3 | -| Tool result | `tool-result` wrapper block, `role: 'user'` | `role: 'tool'`, lifted `message.toolCallId`/`message.isError`, direct content (no wrapper block) | +| Seam | V4 (`0.1.7-rc.2+`, `0.2.0-rc.2+`) | +| --- | --- | +| System prompt | `system/message` surface node (a `request/header` carrying `header.system` is rejected outright) | +| First token | embedded `assistant/message.data.stream` (also `assistant/attempt.data.stream`) — packed delta runs plus raw `chunk` records | +| Replacement endpoints | `{ startSeq, endSeq }` | +| Nested PTC dispatch | `tool/ptc-dispatch` | +| Tool result | first-class `role: 'tool'` message with lifted `message.toolCallId`/`message.isError`, direct content | +| Developer messages | `developer/message` tool-registry changes, nested under `data.message` | -**Legacy logs never reach the fold.** The running harness migrates every historical log to its own generation on EVERY read path the plugin rides — live open, persistence read handles, `sessionQuery.observeSession` (the detail/backfill cold reads) — in memory on read opens and as a published successor file on write opens; the fork-inherited prefix migrates with the rest. A log the migration chain cannot convert is refused entirely (`SessionFormatUnsupportedError`): the session is unavailable, never served raw. So on any supported harness the events the fold sees are V3/V4-shaped by construction, and the pre-V3 spellings (`header.system` envelopes, `assistant/chunk` floods, `{ start, end }` endpoints, `tool/code-dispatch`) exist nowhere in the code. +**Legacy logs never reach the fold.** The running harness migrates every historical log to V4 on EVERY read path the plugin rides — live open, persistence read handles, `sessionQuery.observeSession` (the detail/backfill cold reads) — in memory on read opens and as a published successor file on write opens; the fork-inherited prefix migrates with the rest (the V3→V4 stage lifts the old `tool-result` wrapper into the first-class message and rewrites sources). A log the migration chain cannot convert is refused entirely (`SessionFormatUnsupportedError`): the session is unavailable, never served raw. So on any supported harness the events the fold sees are V4-shaped by construction, and the pre-V4 spellings (the `tool-result` wrapper block and its `isError`, the event-level `data.error` mark, and further back the `header.system` envelopes, `assistant/chunk` floods, `{ start, end }` endpoints, `tool/code-dispatch`) exist nowhere in the code. The fold never branches on a detected harness version. The version probe is best-effort — it reports the module tree the embedding shell resolves plugin imports to, which a packaged client may redirect — so it decides which units register and nothing more. -### Known V4 deltas +### Deltas of the current generation -- **Developer messages**: V4 tool-registry changes are nested under `data.message` and fold into injected context, including request composition, live surface, and removed-node archives. They never count as human inputs; empty-content events keep their surface position at zero tokens, matching the harness's message projection. -- **Tool-result error mark**: V4 made the event-level `data.error` identity optional while lifting `isError` onto the message, so the fold reads the flag from BOTH spellings (`src/host/fold.ts`); a V4 result carrying only `message.isError` still flags its surface node and file-op row. +- **Developer messages**: `developer/message` tool-registry changes are nested under `data.message` and fold into injected context, including request composition, live surface, and removed-node archives. They never count as human inputs; empty-content events keep their surface position at zero tokens, matching the harness's message projection (its `deriveEventMessage` projects an empty message to null — the fold's structural read keeps the node). +- **Tool-result error mark**: the event-level `data.error` metadata is admitted only alongside the message mark (the generation's own validation refuses a row whose `data.error` outlives `message.isError`), so the fold reads `message.isError` alone; a hostile row carrying only the envelope field degrades to unflagged, never to a throw. - **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice. -- **User preferences (shipped)**: the V4 line (first shipped in `0.1.6-alpha.2`) retired the `settings.register` host face and the `settings.plugin.item` browser slot — plugin configuration moved to the Plugins page, derived from each loader entry's own Config schema. The plugin serves both generations from one entry schema: - - The entry `Config` (`src/host/config.ts`) is schemastery. Cordis still validates the `config:` block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the eight preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). - - The browser half registers its card on the seat the running line declares, by service presence: `settingsScope` + `settings.plugin.item` on V3, `configForms` + the Plugins page's keyed `plugins.bundle.config` on V4+ (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). Both seats render the same eight preference rows. - - The `.volatile()` modifier exists only on the Config-form generations' schemastery; the schema builder feature-detects it, so the same bundle serves both generations. - - Deliberate relaxations against the replaced zod schema: schemastery objects merge unknown keys through instead of failing the load, and only whole-value edits are rejected by range/step checks (bounds remain `min 1, step 1`). - - Preference values persisted on the V3 line live in the settings document (`settings.yaml` section `dsh-context`); the V4 harness's own legacy import moves that section into the profile entry of the same name on first boot, so existing values carry into the new surface. +- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the eight preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition). + - Preference values persisted on a retired line (the `settings.yaml` section `dsh-context`) are moved into the profile entry of the same name by the harness's own legacy import on first boot, so existing values carry into the current surface. ## Parsing resilience [IMPORTANT!] @@ -76,27 +72,27 @@ The plugin's low-level logic mirrors the newest supported dsh implementation: to ## Web client seams -The browser half rides generation-specific seats, each reached through an optional seam so a deployment without the service simply goes without the capability. Capabilities only newer lines serve are reached through a deferred `ctx.inject` — never a hard module `inject` — so an older harness composes fully with the capability absent (no pending fiber, no throw): the service shape is re-proved before use, the registration is guarded, and the module is declared in `dsh.client.inject`. Every supported line ships the right Sidebar and the keyed `main` conversation panel; the plugin contributes to whichever face the running line serves: +The browser half rides generation-specific seats, each reached through an optional seam so a deployment without the service simply goes without the capability. Capabilities only newer lines serve are reached through a deferred `ctx.inject` — never a hard module `inject` — so a composition without the service composes fully with the capability absent (no pending fiber, no throw): the service shape is re-proved before use, the registration is guarded, and the module is declared in `dsh.client.inject`. Every supported line ships the right Sidebar and the keyed `main` conversation panel; the plugin contributes to whichever face the running line serves: -| Seam | V3 (`0.1.5-rc.1`) | V4 (`0.1.7-rc.2`, `0.2.0-rc.2`) | -| --- | --- | --- | -| Conversation panel root | keyed `main` panel's `main.conversation` (the plugin's `conversation.view` seat hangs under it) | same as V3 | -| Insight page (panel + entry) | the layout's keyed `main` slot under the `dsh-context` panel key, with the sidebar entry on the `sidebar.panellist` list under the same id — the shell owns the entry row and panel switching; both spellings probed per baseline (the `insightPage` probe) | same as V3 | -| Right Sidebar tab + guide entry | `sidebarRightTabs` registry, `sidebar.right.pane.tab`(`.title`) seats, guide capsule with `order`/`title`/`description`/`icon` | same as V3 (`0.1.7-rc.2`+ adds an optional `commandId` to guide entries and `bindCommands` to tab actions — additive, unused by the plugin) | -| Preferences card seat | `settings.plugin.item` over the `settingsScope` transport | `plugins.bundle.config` over the `configForms` transport | -| Session jump | `uiWorkspace.openSession` — the sidebar row click's own verb; the retired `sessions.open(id)` spelling is still served as a fallback | `uiWorkspace.openSession(target: SessionTarget)` — same verb, parameter re-spelled by the 0.1.6 selection refactor; the sessions service carries no selection verb any more | +| Seam | V4 (`0.1.7-rc.2`, `0.2.0-rc.2`) | +| --- | --- | +| Conversation panel root | keyed `main` panel's `main.conversation` (the plugin's `conversation.view` seat hangs under it) | +| Insight page (panel + entry) | the layout's keyed `main` slot under the `dsh-context` panel key, with the sidebar entry on the `sidebar.panellist` list under the same id — the shell owns the entry row and panel switching; both spellings probed per baseline (the `insightPage` probe) | +| Right Sidebar tab + guide entry | `sidebarRightTabs` registry, `sidebar.right.pane.tab`(`.title`) seats, guide capsule with `order`/`title`/`description`/`icon` (an optional `commandId` on guide entries and `bindCommands` on tab actions are additive and unused by the plugin) | +| Preferences card seat | `plugins.bundle.config` over the `configForms` transport | +| Session jump | `uiWorkspace.openSession(target: SessionTarget)` — the sidebar row click's own verb, its parameter re-spelled by the 0.1.6 selection refactor; the sessions service carries no selection verb any more | -The guide capsule carries `order`, `title`, `description`, and `icon`; `0.1.5-rc.1+` renders the description line (the `guideEntry` probe in `tests/baselines.ts` pins the fields of both supported generations). +The guide capsule carries `order`, `title`, `description`, and `icon` (the `guideEntry` probe in `tests/baselines.ts` pins the fields of the supported generation). -The File Activity card's file-name affordance is an optional-column story: on a line with the right Sidebar it opens the file's preview tab through `ctx.sidebarRight.openResource` (the built-in Files sidebar's own path), building the `dsh-resource://file/…` address with the harness's browser-safe `fileAddressFor` (inlined by the client bundle, exactly as the Sidebar's own file types inline it). Where the preview face is absent (or refuses the address), the name keeps its original system-open behavior; the `sidebar.nav` probe pins the navigation face's spelling. +The File Activity card's file-name affordance is an optional-column story: the plugin opens the file's preview tab through `ctx.sidebarRight.openResource` (the built-in Files sidebar's own path), building the `dsh-resource://file/…` address with the harness's browser-safe `fileAddressFor` (inlined by the client bundle, exactly as the Sidebar's own file types inline it). Where the preview face is absent (or refuses the address), the name keeps its original system-open behavior; the `sidebar.nav` probe pins the navigation face's spelling. -The session jump — a Context Insights session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line, with the sessions service's retired `open(id)` spelling kept as the degradation path for a composition without the workspace module. Both faces are re-proved per call and a deployment serving neither (or a hostile face) swallows silently — the verb's own navigation returns the center column to the conversation (`selectPanel(null)`), so a dead jump simply stays on the page. Issue #90 was exactly the retired-spelling trap: the plugin called `sessions.open` alone, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump now follows the view owner first and the per-baseline `sessionNav` probe pins both spellings presence-as-declared. +The session jump — a Context Insights session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line. The face is re-proved per call and a deployment serving none (or a hostile face) swallows silently — the verb's own navigation returns the center column to the conversation (`selectPanel(null)`), so a dead jump simply stays on the page. Issue #90 was exactly the retired-spelling trap: the plugin once called the sessions service's own `open(id)`, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump follows the view owner and the per-baseline `sessionNav` probe pins the verb's signature and the retired spelling's absence. -The Context Insights page's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read through whichever face the running line serves: the `get(ns)` registered-section read through V3 (the `llm-deepseek` namespace), the Config-form `describe()` projection on V4+ — there the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row on either face), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline. +The Context Insights page's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read off the Config-form `describe()` projection — the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline. ## What each check means -- **Automated seam matrix** — part of this repository's `pnpm test` (the `compat` vitest project). For every baseline tag it stages the harness's REAL sources at that tag, boots the plugin's built host entry into that tag's actual `SessionProjectionRegistry` on the cordis release the line vendors, and probes the tag's client seams (slots, finalized-nodes seat, image loader, history face/envelope, markdown chrome, platform module table, that generation's durable-event vocabulary, the right Sidebar tab seam, its guide-entry contract and its resource-navigation face, the session-jump seam, the preferences card seat and settings transport). Definitions live in `tests/baselines.ts` — the one source of truth for the supported baselines and their per-version seam faces; the release workflow fetches the pinned baseline tags before testing. Preferences seats and transports are asserted BOTH ways: each slot exists on exactly one supported generation, so the plugin's deferred registrations pick their side and never pend. To add a future dsh release: add one `Baseline` entry (tag, vendored cordis, faces) to `tests/baselines.ts` and its tag to the release workflow's fetch step, then run `pnpm test` and re-fit whichever seam a failing probe names. The `compat` project boots the BUILT plugin (`pnpm run build` first) against the ACTUAL harness sources at each baseline tag and needs a dsh checkout (env `DSH_REPO`, default `~/dev/deepseek-harness`). +- **Automated seam matrix** — part of this repository's `pnpm test` (the `compat` vitest project). For every baseline tag it stages the harness's REAL sources at that tag, boots the plugin's built host entry into that tag's actual `SessionProjectionRegistry` on the cordis release the line vendors, and probes the tag's client seams (slots, finalized-nodes seat, image loader, history face/envelope, markdown chrome, platform module table, that generation's durable-event vocabulary, the right Sidebar tab seam, its guide-entry contract and its resource-navigation face, the session-jump seam, the preferences card seat and settings transport). Definitions live in `tests/baselines.ts` — the one source of truth for the supported baselines and their per-version seam faces; the release workflow fetches the pinned baseline tags before testing. Retired seams are asserted ABSENT (`settings.register`, the settings `get(ns)` read, the sessions service's `open(id)`), so a resurrection names the seam instead of silently shadowing the current face. To add a future dsh release: add one `Baseline` entry (tag, vendored cordis, faces) to `tests/baselines.ts` and its tag to the release workflow's fetch step, then run `pnpm test` and re-fit whichever seam a failing probe names. The `compat` project boots the BUILT plugin (`pnpm run build` first) against the ACTUAL harness sources at each baseline tag and needs a dsh checkout (env `DSH_REPO`, default `~/dev/deepseek-harness`). - **Statistics against the harness's own folds** — the plugin's figures were verified differentially against the harness's OWN projection values (`sessionStats`, `contextBreakdown`, `contextPressure`, `tokenUsage`) over real session logs: system/tools/message tokens, per-request counts, turns/steps, TTFT, generation, tool time, and every billed cost bucket match exactly. - **Disposable-profile install / uninstall** — for each release, that exact `dsh` CLI version was installed from npm into a temporary `DSH_HOME` (the real `~/.dsh` is never touched), then: 1. `dsh plugin --profile add dsh-context` — install OK; diff --git a/docs/settings.png b/docs/settings.png index 4cc3ff0..adec106 100644 Binary files a/docs/settings.png and b/docs/settings.png differ diff --git a/package.json b/package.json index a7817f6..ecbbdb7 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ }, "compatibility": { "dshReleases": { - "0.1.5-rc.1": "compatible", "0.1.7-rc.2": "compatible", "0.2.0-rc.2": "compatible" } @@ -88,10 +87,9 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.2", - "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.5-rc.1", - "@deepseek-ai/dsh-session": ">=0.1.5-rc.1", - "@deepseek-ai/dsh-settings": ">=0.1.5-rc.1", + "@deepseek-ai/cordis": "^4.0.4", + "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.2", + "@deepseek-ai/dsh-session": ">=0.1.7-rc.2", "@deepseek-ai/schemastery": "^3.18.2", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -108,12 +106,13 @@ } }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.2", - "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.1", - "@deepseek-ai/dsh-session": "0.1.5-rc.1", - "@deepseek-ai/dsh-session-projection": "0.1.5-rc.1", - "@deepseek-ai/dsh-settings": "0.1.5-rc.1", - "@deepseek-ai/dsh-util-workspace-path": "0.1.5-rc.1", + "@deepseek-ai/cordis": "^4.0.4", + "@deepseek-ai/dsh-client-store": "0.1.7-rc.2", + "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2", + "@deepseek-ai/dsh-session": "0.1.7-rc.2", + "@deepseek-ai/dsh-session-projection": "0.1.7-rc.2", + "@deepseek-ai/dsh-util-code-language": "0.1.7-rc.2", + "@deepseek-ai/dsh-util-workspace-path": "0.1.7-rc.2", "@deepseek-ai/schemastery": "^3.18.4", "@opencode-ai/models": "^0.0.87", "@shikijs/langs": "^4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0ac76b..0b64ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,23 +13,26 @@ importers: version: 4.6.5 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.2 - version: 4.0.2 + specifier: ^4.0.4 + version: 4.0.4 + '@deepseek-ai/dsh-client-store': + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/dsh-client-ui-primitives': - specifier: 0.1.5-rc.1 - version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/dsh-session': - specifier: 0.1.5-rc.1 - version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))) + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4))) '@deepseek-ai/dsh-session-projection': - specifier: 0.1.5-rc.1 - version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)))) - '@deepseek-ai/dsh-settings': - specifier: 0.1.5-rc.1 - version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.4) + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-session@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)))) + '@deepseek-ai/dsh-util-code-language': + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/dsh-util-workspace-path': - specifier: 0.1.5-rc.1 - version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.7-rc.2 + version: 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/schemastery': specifier: ^3.18.4 version: 3.18.4 @@ -225,12 +228,12 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} - '@deepseek-ai/cordis@4.0.2': - resolution: {integrity: sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==} + '@deepseek-ai/cordis@4.0.4': + resolution: {integrity: sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==} hasBin: true peerDependencies: - '@deepseek-ai/cordis-plugin-include': ^1.0.7 - '@deepseek-ai/cordis-plugin-loader': ^1.0.3 + '@deepseek-ai/cordis-plugin-include': ~1.0.9 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 peerDependenciesMeta: '@deepseek-ai/cordis-plugin-include': optional: true @@ -240,80 +243,78 @@ packages: '@deepseek-ai/cosmokit@1.8.5': resolution: {integrity: sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==} - '@deepseek-ai/dsh-brand@0.1.5-rc.3': - resolution: {integrity: sha512-pzbPtyEwnFMCrUYLwFio8GmUxtTqqAkhW5IheWH1tSgUnVIAu4OwVbxwTNiDGMRbgTRAzT9xZ7bukUejpyob4w==} + '@deepseek-ai/dsh-brand@0.1.7-rc.2': + resolution: {integrity: sha512-cvcvlNRuiFqZNNqyxdu7wcpiBjd9xa/0MpuhRvee43zBp9uED2p5JolZr5/2EKJf57gYx9k9bedb+Zbyiqnpkw==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1': - resolution: {integrity: sha512-C6mJOcwg41gD5Yy57QGjZhX/8p8U1kSR4px7tOfLmLIKKXyeHbDytJEKjPKHfh8TkOvpqQ4wA02gQsVe06ithQ==} + '@deepseek-ai/dsh-client-store@0.1.7-rc.2': + resolution: {integrity: sha512-8nF4gqy/3yvSN3KsuHX61DVP1Z2YjC93wkB2uGVS5xg4iyD1KgCqWB2S+fVAdQ7END9PDzWhZTk71AYnkSh7LA==} peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-invariants@0.1.5-rc.3': - resolution: {integrity: sha512-1iPfLsu8zFAsfIRYE958uMhB3oTO/LmN3ArTifxWFlRvCmBRd9Ca6l7DZh/daLnJsDSdtLQtVzPqbgTV9tzNlQ==} + '@deepseek-ai/dsh-client-ui-primitives@0.1.7-rc.2': + resolution: {integrity: sha512-ymIUil9ivHcIDgaAmF88lOygC7dJ0UsUgdbIvXT5bymPZDzvDJIoorOKhlMsCqPEG1ZawCMVhUatnIPi4xdlMQ==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-llm@0.1.5-rc.3': - resolution: {integrity: sha512-c6Z7S1SlRAgLKQ/MZKp0mCMrBz06X9XEddLFxhK5Flq0PM18dsJgIbRgNpobILy2AJyYx0vnHbepjmITW6BXWA==} + '@deepseek-ai/dsh-invariants@0.1.7-rc.2': + resolution: {integrity: sha512-GY2lY2TUUH2Qk1ZnrB3Q5+lXSh4webn1kbt5nQfhojVmNbist/f3dJC8JJa7XXOUNvqAgqRN9IZ4egbDZ9LLxQ==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-scope@0.1.5-rc.3': - resolution: {integrity: sha512-2B45GShJQwPs+Ad4MdI2kkbxkogagZopAY2lbYowAz6Oris3jGxZJ4v3/ZyJw7c9bhTJUagHXCpXQYFSIXumLA==} + '@deepseek-ai/dsh-llm@0.1.7-rc.2': + resolution: {integrity: sha512-hNT3ovFnbNDQ8g544bh2Q9ORX40aI6Y+jhjer1lETeHS4g0jTjqUUVfARCTA7uAFvHucSBg2rFgbisIktmJCxw==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-invariants': ^0.1.5-rc.3 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-session-projection@0.1.5-rc.1': - resolution: {integrity: sha512-dwDS3ite9I1oOAPneA87ieHBd+szFNrTeMNF4XHRxexMbBWxNZUv62h714UMNlk2niGKsAKE56PzwlnWHmGwGg==} + '@deepseek-ai/dsh-scope@0.1.7-rc.2': + resolution: {integrity: sha512-rk/OM5ej6/V8pCNczEqQ3BkKx3DuPeFGzNUsEZo8hF+tnkF/EhsxMWSXC/yP/uLKeNkSsVHzjFpaQR0qr0J3gQ==} peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.1.7-rc.2 - '@deepseek-ai/dsh-session@0.1.5-rc.1': - resolution: {integrity: sha512-0YBBrzkCbVEJolS/OpD0DZMSozmYxUTZopiG76MXInjOFBW9J4ca1a8WUjJjqelP1nJTmWGKXp92HcvNEny0Dg==} + '@deepseek-ai/dsh-session-projection@0.1.7-rc.2': + resolution: {integrity: sha512-s5T8oHBEh+GeOTJ+wehD4CGZkTiWgczZZA+NDbfbqUO0k5L+dk0kk3fN+/srAg77rhR7UeeDSR8ZNcpYx/VUdw==} peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-scope': ^0.1.5-rc.1 + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-session': 0.1.7-rc.2 - '@deepseek-ai/dsh-settings@0.1.5-rc.1': - resolution: {integrity: sha512-9t6JlHwnMu7qTHpMVVRLyzfi7yWKlDozmh7Xbjjd5mmzY+dSdKTPsoRGSdGgTLvBMQBi0X2O8Kj2moDp30XKGg==} + '@deepseek-ai/dsh-session@0.1.7-rc.2': + resolution: {integrity: sha512-0dXv1t4Zox/yHkZ4PfPNSBWJfIc72PDqOvjz43EMoHpbmoKTVpit2UpUI+yTtX01e4MVBbDMxBZ95oJW/Oydnw==} peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-brand': ^0.1.5-rc.1 - '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 - '@deepseek-ai/dsh-session': ^0.1.5-rc.1 - '@deepseek-ai/schemastery': ^3.18.2 + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-scope': 0.1.7-rc.2 - '@deepseek-ai/dsh-timeout@0.1.5-rc.3': - resolution: {integrity: sha512-Z7HyqvGaO/k4XLp/EykvIuA0i67CvWb/R0c7Lt/3/8dwmDvqKWgWvh/EiNip7UMXMpQBJzI+Xe4OuzXV/A90Nw==} + '@deepseek-ai/dsh-timeout@0.1.7-rc.2': + resolution: {integrity: sha512-YunLoLZSF8Ld9eYwyvKoBnxgy3GZrIQpw98HfFS15ZfZlqcFiZcchFhYmStLtUbtX+gjhoeXc8Xz913YbVifNA==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-typert-protocol@0.1.5-rc.3': - resolution: {integrity: sha512-lcQGIQK4Z5dBguLQFzoDiHQBT387egSnFYJOakEjqIMM9jkAfMI/HqlsVSRSzHW7SQL9f6SxJMHCD7pIkJzsWA==} + '@deepseek-ai/dsh-typert-protocol@0.1.7-rc.2': + resolution: {integrity: sha512-oJm03zqMQlvt16Ejw7VWy3/gqlJGrBCZj3Z4KL8ydLJeeTToKIR49WVCxMbJTlH0F9QFljruLtHguA5B99AeUQ==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-util-crypto@0.1.5-rc.3': - resolution: {integrity: sha512-gQAFF2TLLAV5ejHAklCplePVdL2TV4CH1Xt3A0bqaLxZ2zoOxXoVUieUzPrHo+X1NKfsmdcWvOx9zdlDkXY+sQ==} + '@deepseek-ai/dsh-util-code-language@0.1.7-rc.2': + resolution: {integrity: sha512-/NcCcgFXbTfV4sse5HoYO6SyKfcn9GFHeCP/bPkFsACZSv1dhosFivnz8jkkS1esbQe97jiki+UQcZBd+O6Usw==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-util-values@0.1.5-rc.3': - resolution: {integrity: sha512-K0qEm9XmgcxCMuJBK7skNE3v35JTOurN24IwrZ4t0xoaSlZSF4wR0L/R7ipj9IQSlB7sGdQLdegTNsZmSlmw4Q==} + '@deepseek-ai/dsh-util-crypto@0.1.7-rc.2': + resolution: {integrity: sha512-BGc55/bSj/1LcDTQ5fpok4U24X9SFyN+TB23/hBlbe2TBUKVuwO8+X5FFKKje2MLO8PWYEDkXv/LrV6jRVKZEg==} peerDependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-util-workspace-path@0.1.5-rc.1': - resolution: {integrity: sha512-yGU2meCr+8Ru+4WbMtKLlROxtuj9zGjJ6N6bJs20kRxIT0JNlZ6uwqnDQONgN8u44WdyKEaIwbv5IGjxIftLRw==} + '@deepseek-ai/dsh-util-values@0.1.7-rc.2': + resolution: {integrity: sha512-UX8nh5SbtZcPVVEvjd+9PCPcbybA6OpVzJGgFxYn59ao/7QQGyYbByS8KXr+THU5JtqmDDVb9xGUSJaF2IYmeQ==} peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/schemastery@3.18.2': - resolution: {integrity: sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==} + '@deepseek-ai/dsh-util-workspace-path@0.1.7-rc.2': + resolution: {integrity: sha512-opp6TTeRvZHPi2bsXNK66TwUBh4uNECZJp/qK35ffLQRNa3L+RoPsGgOJZuRjkXdNygytM26P/CWjaBX65oKXg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 '@deepseek-ai/schemastery@3.18.4': resolution: {integrity: sha512-SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg==} @@ -2429,89 +2430,84 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} - '@deepseek-ai/cordis@4.0.2': + '@deepseek-ai/cordis@4.0.4': dependencies: '@deepseek-ai/cosmokit': 1.8.5 '@standard-schema/spec': 1.1.0 '@deepseek-ai/cosmokit@1.8.5': {} - '@deepseek-ai/dsh-brand@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-brand@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 - '@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-store@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 - '@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-primitives@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/schemastery': 3.18.2 + '@deepseek-ai/cordis': 4.0.4 - '@deepseek-ai/dsh-llm@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-timeout': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-typert-protocol': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-crypto': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-values': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/schemastery': 3.18.2 - zod: 4.6.5 - - '@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-invariants': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - - '@deepseek-ai/dsh-session-projection@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))))': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))) - zod: 4.6.5 - - '@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)))': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-util-values': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - - '@deepseek-ai/dsh-settings@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.4)': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-util-values': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/cordis': 4.0.4 '@deepseek-ai/schemastery': 3.18.4 - '@deepseek-ai/dsh-timeout@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-llm@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-timeout': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-typert-protocol': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-crypto': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-values': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/schemastery': 3.18.4 + zod: 4.6.5 - '@deepseek-ai/dsh-typert-protocol@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4))': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-invariants': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) - '@deepseek-ai/dsh-util-crypto@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-session-projection@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-session@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4))))': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-session': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4))) + zod: 4.6.5 - '@deepseek-ai/dsh-util-values@0.1.5-rc.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-session@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)))': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-scope': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-util-values': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) - '@deepseek-ai/dsh-util-workspace-path@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-timeout@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/cordis': 4.0.4 - '@deepseek-ai/schemastery@3.18.2': + '@deepseek-ai/dsh-typert-protocol@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: - '@deepseek-ai/cosmokit': 1.8.5 - '@standard-schema/spec': 1.1.0 + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.1.7-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-util-code-language@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-util-crypto@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-util-values@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-util-workspace-path@0.1.7-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 '@deepseek-ai/schemastery@3.18.4': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4771129..5ca7c78 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,16 +8,20 @@ allowBuilds: esbuild: true minimumReleaseAgeExclude: - - '@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1' - - '@deepseek-ai/dsh-session@0.1.5-rc.1' - - '@deepseek-ai/dsh-session-projection@0.1.5-rc.1' - - '@deepseek-ai/dsh-settings@0.1.5-rc.1' + - '@deepseek-ai/dsh-client-store@0.1.7-rc.2' + - '@deepseek-ai/dsh-client-ui-primitives@0.1.7-rc.2' + - '@deepseek-ai/dsh-session@0.1.7-rc.2' + - '@deepseek-ai/dsh-session-projection@0.1.7-rc.2' + - '@deepseek-ai/dsh-util-code-language@0.1.7-rc.2' + - '@deepseek-ai/dsh-util-workspace-path@0.1.7-rc.2' - '@deepseek-ai/cosmokit@1.8.3 || 1.8.5' - - '@deepseek-ai/dsh-brand@0.1.5-rc.3' - - '@deepseek-ai/dsh-llm@0.1.5-rc.3' - - '@deepseek-ai/dsh-timeout@0.1.5-rc.3' - - '@deepseek-ai/dsh-typert-protocol@0.1.5-rc.3' - - '@deepseek-ai/dsh-util-crypto@0.1.5-rc.3' - - '@deepseek-ai/dsh-util-values@0.1.5-rc.3' + - '@deepseek-ai/dsh-brand@0.1.7-rc.2' + - '@deepseek-ai/dsh-invariants@0.1.7-rc.2' + - '@deepseek-ai/dsh-llm@0.1.7-rc.2' + - '@deepseek-ai/dsh-scope@0.1.7-rc.2' + - '@deepseek-ai/dsh-timeout@0.1.7-rc.2' + - '@deepseek-ai/dsh-typert-protocol@0.1.7-rc.2' + - '@deepseek-ai/dsh-util-crypto@0.1.7-rc.2' + - '@deepseek-ai/dsh-util-values@0.1.7-rc.2' - '@deepseek-ai/schemastery@3.18.2 || 3.18.4' - '@opencode-ai/models@0.0.72 || 0.0.87' diff --git a/src/client/components/fileCard.tsx b/src/client/components/fileCard.tsx index c338607..32c6f1c 100644 --- a/src/client/components/fileCard.tsx +++ b/src/client/components/fileCard.tsx @@ -10,8 +10,8 @@ * expands the file's own operation log; each operation is itself the click * target that jumps to (and reveals) the exact tool result in the browser. * The file name opens the file's right-Sidebar preview where that column - * exists (dsh 0.1.5-rc.1+, the shipped files-sidebar idiom), and the system - * opener where it does not. + * exists (the shipped files-sidebar idiom), and the system opener where it + * does not. */ import { memo, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react' diff --git a/src/client/components/richText.tsx b/src/client/components/richText.tsx index 9d96f3b..544790f 100644 --- a/src/client/components/richText.tsx +++ b/src/client/components/richText.tsx @@ -184,7 +184,7 @@ export function makeRichText(kit: ViewKit): RichKit { function RichText(props: { text: string; mode: RichMode }): ReactElement { // Reference-stable per locale: a fresh object identity would discard the - // renderer's cached elements on every render (0.1.2+ faces). + // renderer's cached elements on every render. const mdLabels = useMemo(() => ({ code: { copyLabel: t('rich.md.copy'), copiedLabel: t('rich.md.copied') }, footnotes: t('rich.md.footnotes'), diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx index 60eeb88..9b91d6c 100644 --- a/src/client/components/settingsCard.tsx +++ b/src/client/components/settingsCard.tsx @@ -1,21 +1,15 @@ /** - * The dsh-context preference cards — two seats over the same eight rows. The - * settings-section card (`settings.plugin.item`, the older harness lines) - * renders a collapsible list item in Settings → Plugins → Plugin - * configuration; the Plugins-page card (`plugins.bundle.config`, the - * Config-form generation) renders the rows flat inside the section chrome the - * Plugins page draws for the bundle. Both are keyed on the Host-served - * `dsh-context` namespace and render nothing while it is unavailable (a - * deployment without the Host half, or a remote browser, shows no trace). - * The settings-section card mounts expanded when the Plugin Info card's - * "Open plugin settings" jump left a fresh expand request (settingsJump.ts), - * scrolling itself into view. + * The dsh-context preference card: the eight preference rows on the + * Plugins-page seat (`plugins.bundle.config`, the Config-form generation), + * rendered flat inside the section chrome the Plugins page draws for the + * bundle. The card is keyed on the Host-served `dsh-context` namespace and + * renders nothing while it is unavailable (a deployment without the Host + * half, or a remote browser, shows no trace). */ -import { useEffect, useRef, useState, type ReactElement } from 'react' +import { useState, type ReactElement } from 'react' import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDown } from '../primitives' -import { consumeCardExpand } from '../settingsJump' import type { SettingsField, SettingsState } from '../settings' import type { ViewKit } from '../viewkit' @@ -67,7 +61,7 @@ function PrefRow(props: PrefRowProps): ReactElement { /** Translate over the plugin's dictionary, as the view kit binds it. */ type Translate = ViewKit['t'] -/** The seven preference rows shared by both seats. */ +/** The eight preference rows. */ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: SettingsCardProps['set'] }): ReactElement { const { t, state, set } = props const disabled = state.status !== 'ready' || !state.writable @@ -160,57 +154,10 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti ) } -export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => ReactElement | null { - const { t } = kit - return function SettingsCard(props: SettingsCardProps): ReactElement | null { - const [open, setOpen] = useState(false) - const itemRef = useRef(null) - // "Open plugin settings" jump: consume its fresh expand request once on - // mount and land open; every guard stays local so no host quirk can surface. - useEffect(() => { - if (!consumeCardExpand()) return - setOpen(true) - try { - itemRef.current?.scrollIntoView({ block: 'nearest' }) - } catch { /* hosts without scrollIntoView: expanded but unscrolled */ } - }, []) - const state = typeof props.useContextSettings === 'function' ? props.useContextSettings(s => s) : undefined - if (state === undefined || state.status === 'unavailable') return null - return ( -
  • - - {open - ? ( -
    - {!state.writable && state.status === 'ready' - ?

    {t('settings.readOnly')}

    - : null} - -
    - ) - : null} -
  • - ) - } -} - /** * The Plugins-page card (the Config-form generation's `plugins.bundle.config` * seat, `view: 'page'`): the page owns the bundle's page and section chrome, - * so the rows render flat. Same unserved/absent degradation as the - * settings-section card. + * so the rows render flat. Unserved/absent degrades to nothing. */ export function makePluginConfigCard(kit: ViewKit): (props: SettingsCardProps) => ReactElement | null { const { t } = kit diff --git a/src/client/dockMeasure.ts b/src/client/dockMeasure.ts index 6c76a8b..f3cdaff 100644 --- a/src/client/dockMeasure.ts +++ b/src/client/dockMeasure.ts @@ -1,9 +1,9 @@ /** * The /context modal's dock insets: the shell's sidebar tracks, so the fixed backdrop spans exactly the main column and centers the dialog * over it instead of the whole viewport. The only host anchor is the app frame's inline grid template (`px ` — - * the leading px track is the left sidebar, the trailing px track the right panel's bound: the contract every supported baseline shares; - * the centre track's spelling differs per generation) found by walking up from the backdrop. On the minmax generations the trailing bound - * is NOT the solved width: the frame lets the grid squeeze (the centre's declared minimum wins, the right track takes the remainder up to + * the leading px track is the left sidebar, the trailing px track the right panel's bound: the contract every supported baseline shares) + * found by walking up from the backdrop. The trailing bound is NOT the solved width: the frame lets the grid + * squeeze (the centre's declared minimum wins, the right track takes the remainder up to * its bound — the frame's own columns solve), so the parsed bound is clamped to the space the centre minimum leaves. Anything unresolved — * no frame, unparsable template, a hostile node in the chain — degrades to 0 insets, the full-viewport mask. */ @@ -19,9 +19,9 @@ export interface DockMeasure { } const LEADING_PX_TRACK = /^(\d+(?:\.\d+)?)px/ -// The centre track's declared minimum (`minmax(0, 1fr)`, `minmax(400px, 1fr)` — spelling varies per generation, only the number is read). +// The centre track's declared minimum (`minmax(0, 1fr)`, `minmax(400px, 1fr)` — spelling varies per shell layout, only the number is read). const CENTRE_MIN_TRACK = /^\s*minmax\(\s*([\d.]+)(?:px)?\s*,/ -// The V4 spelling's trailing track closes with a paren after the px. +// The minmax spelling's trailing track closes with a paren after the px. const TRAILING_PX_TRACK = /(\d+(?:\.\d+)?)px\)?$/ /** Measure the sidebar tracks from the backdrop's ancestor chain, or resolve to the full-viewport mask. */ diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts index 61e2387..81d0e93 100644 --- a/src/client/historyPage.ts +++ b/src/client/historyPage.ts @@ -161,20 +161,21 @@ export function pageNodesOf(entries: readonly unknown[]): Map : null const source = message?.source !== null && typeof message?.source === 'object' ? message.source as Record : null - const first = Array.isArray(message?.content) ? (message.content as unknown[])[0] : undefined - const block = first !== null && typeof first === 'object' ? first as Record : null - const callId = typeof source?.callId === 'string' ? source.callId - : typeof block?.toolCallId === 'string' ? block.toolCallId + const callId = typeof message?.toolCallId === 'string' ? message.toolCallId + : typeof source?.callId === 'string' ? source.callId : null const call = callId !== null ? calls.get(callId) ?? null : null nodes.set(seq, { kind: 'tool-result', seq, call, - content: block !== null && Array.isArray(block.content) ? block.content as readonly unknown[] : [], - isError: block?.isError === true || data.error === true, + content: Array.isArray(message?.content) ? message.content as readonly unknown[] : [], + isError: message?.isError === true, }) continue } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 74f59d7..c05f912 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -78,8 +78,6 @@ export const DICT_ZH: Record = { 'trend.title': '上下文趋势', 'gran.step': '步骤', 'gran.turn': '轮次', - 'settings.title': '上下文', - 'settings.desc': 'dsh-context 插件中上下文面板的偏好设置', 'settings.placement': '入口位置', 'placement.tab': '标签页', 'placement.sidebar': '侧边栏', @@ -93,8 +91,6 @@ export const DICT_ZH: Record = { 'settings.deltaBase': '上下文浏览器 · 对比基线', 'settings.toolSort': '工具定义 · 默认排序', 'settings.fileSort': '文件活动 · 默认排序', - 'settings.expand': '展开', - 'settings.collapse': '收起', 'settings.readOnly': '当前环境的设置为只读', 'gran.total': '全量', 'gran.delta': '增量', @@ -495,8 +491,6 @@ export const DICT_EN: Record = { 'trend.title': 'Context Trend', 'gran.step': 'Step', 'gran.turn': 'Turn', - 'settings.title': 'Context', - 'settings.desc': 'Preferences for Context panel in dsh-context plugin', 'settings.placement': 'Entry Options', 'placement.tab': 'Tab', 'placement.sidebar': 'Sidebar', @@ -510,8 +504,6 @@ export const DICT_EN: Record = { 'settings.deltaBase': 'Context Browser · Diff baseline', 'settings.toolSort': 'Tool Schemas · Default sort', 'settings.fileSort': 'File Activity · Default sort', - 'settings.expand': 'Expand', - 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.delta': 'Delta', diff --git a/src/client/index.ts b/src/client/index.ts index 83e6721..9280d62 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -27,11 +27,11 @@ import { DICT_EN, DICT_ZH } from './i18n' import { registerContextCommand } from './command' import { makeContextModal } from './components/contextModal' import { makeOverviewPanel } from './components/overviewPanel' -import { makeSettingsCard, makePluginConfigCard } from './components/settingsCard' +import { makePluginConfigCard } from './components/settingsCard' import { watchInsightPage } from './insightPage' import { modalStoreOf } from './modalStore' import type { ClientCtx } from './services' -import { createContextSettings, type ConfigFormsFace, type SettingsField, type SettingsScopeBinderFace } from './settings' +import { createContextSettings, type ConfigFormsFace, type SettingsField } from './settings' import { makeContextView } from './components/contextView' import { makeContextJumpButton } from './components/contextJump' import { watchHistoryFaces } from './historyPage' @@ -87,7 +87,7 @@ function apply(ctx: ClientCtx): void { // Placement: the per-user `defaultPlacement` preference picks which // registration carries the view — the conversation tab, the right Sidebar - // (dsh 0.1.5-rc.1+, optional by contract), or both (the default). Each + // (every supported line, optional by contract), or both (the default). Each // mount owns its disposer so the watcher takes down exactly what a // preference flip drops (see placement.ts). ctx.effect(() => watchPlacement(settings, { @@ -148,7 +148,7 @@ function apply(ctx: ClientCtx): void { NS, ), 'dsh-context: insight page') - /** The injected face both preference cards ride: the settings store as the + /** The injected face the preference card rides: the settings store as the * framework's hooks-compartment `useContextSettings` seat, plus the set verb. */ const cardFace = (): { hooks: { contextSettings: typeof settings.store } @@ -158,31 +158,13 @@ function apply(ctx: ClientCtx): void { set: (field, value) => { settings.set(field, value) }, }) - // Per-user display preferences. Two generations, two transports and seats, - // switched by SERVICE PRESENCE (each deferred inject fires on exactly one - // side; a host without its service never runs the callback): - // - the settingsScope generation registers the card on the keyed - // `settings.plugin.item` slot (Settings → Plugins → Plugin - // configuration), binding the Host-served `dsh-context` namespace; - // - the Config-form generation (dsh 0.1.7+) retired that pair — there the - // card rides the configForms transport and the Plugins page's keyed - // `plugins.bundle.config` seat, alive only while the Host serves the - // namespace (the entry Config's volatile preference fields). - ctx.inject(['settingsScope'], (raw) => { - const c = raw as ClientCtx & { settingsScope?: SettingsScopeBinderFace } - const binder = c.settingsScope - if (binder === undefined) return - c.effect(() => settings.attach(binder.bind({ namespace: NS })), 'dsh-context: settings scope') - const SettingsCard = makeSettingsCard(kit) - c.slots.inject('settings.plugin.item', () => { - return c.slots.register( - { name: 'settings.plugin.item', key: NS, locale: NS, inject: cardFace }, - // Root-scope keyed slot: no sessionId on these props — the face - // (hooks + set) arrives through the registration's inject. - props => h(SettingsCard, props as unknown as Parameters[0]), - ) - }) - }) + // Per-user display preferences on the Config-form generation's transport + // and seat (the only ones the supported lines serve): the card rides the + // configForms service and the Plugins page's keyed `plugins.bundle.config` + // slot, alive only while the Host serves the namespace (the entry Config's + // volatile preference fields). The deferred inject fires only where the + // service exists — a host without it never runs the callback (no pending + // fiber, no throw), and the preferences simply hold their schema defaults. ctx.inject(['configForms'], (raw) => { const c = raw as ClientCtx & { configForms?: ConfigFormsFace } const forms = c.configForms diff --git a/src/client/insightPage.ts b/src/client/insightPage.ts index 19c75f7..19d8194 100644 --- a/src/client/insightPage.ts +++ b/src/client/insightPage.ts @@ -12,10 +12,9 @@ * click that selects the panel), the plugin contributes only the glyph * and the localized label thunk. * - * Both seats ship on every supported line (the `main` conversation panel and - * the panellist contract are present since 0.1.5-rc.1 — the compat matrix - * pins both spellings per baseline), so the registrations ride plain - * `slots.inject` declaration injects, like the conversation tab's. + * Both seats ship on every supported line (the compat matrix pins both + * spellings per baseline), so the registrations ride plain `slots.inject` + * declaration injects, like the conversation tab's. * * The per-user `insightsEntry` preference gates the pair: 'show' mounts both * registrations, 'hide' unwinds them (a hidden entry leaves the page no diff --git a/src/client/primitives.ts b/src/client/primitives.ts index d8c7665..adfaaeb 100644 --- a/src/client/primitives.ts +++ b/src/client/primitives.ts @@ -1,13 +1,13 @@ /** - * Generation-proof named imports from the ui-primitives platform module. + * Named imports from the ui-primitives platform module, resolved defensively. * - * The icon vocabulary renamed across the supported range — `Icon*Outline16` / - * `Icon*Outline14` through 0.1.5, `Icon*OutlineRegular` / `Icon*OutlineMedium` - * from 0.1.6 — and a renamed export arrives as `undefined` at runtime (the - * pinned devDep's types cannot see the other generation), which rendered as - * React error #130 inside the Context tab. Each icon here resolves through - * BOTH spellings at module load, modern first; a render-nothing fallback keeps - * a future rename a missing glyph instead of a crashed tab. + * Every supported line ships the `Icon*OutlineRegular` / `Icon*OutlineMedium` + * vocabulary, but the import arrives through the shell's module table at + * runtime: a renamed or absent export reads as `undefined`, which renders as + * React error #130 inside the Context tab. Each icon here resolves by name + * through a bounded read; a missing or non-component export (a future rename, + * a partial module mock, a hostile namespace) degrades to a render-nothing + * fallback — a missing glyph, never a crashed tab. */ import type { ReactElement } from 'react' @@ -17,39 +17,28 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' /** One platform icon component, as the call sites render it. */ export type IconComponent = (props: IconProps) => ReactElement | null -/** The platform module's export surface, read by name (the two vocabularies meet here). */ +/** The platform module's export surface, read by name. */ const ns = primitives as unknown as Record /** - * Read one export through a bounded catch: partial module mocks (and any - * hostile namespace) may throw on the property access itself, which must - * degrade to "not this spelling", never crash the importer. - */ -function read(source: Record, key: string): unknown { - try { - return source[key] - } catch { - return undefined - } -} - -/** - * Resolve one icon across the range's two spellings: the modern - * `…OutlineRegular`/`…OutlineMedium` name first, then the legacy - * `…Outline16`/`…Outline14` one; neither present (or not a component) + * Resolve one icon by its export name; absent or not a component (or a + * namespace whose property READ throws — partial mocks, hostile modules) * renders nothing. `source` overrides the platform namespace for tests. */ -export function resolveIcon(modern: string, legacy: string, source: Record = ns): IconComponent { - const found = read(source, modern) ?? read(source, legacy) - return typeof found === 'function' ? found as IconComponent : () => null +export function resolveIcon(name: string, source: Record = ns): IconComponent { + try { + const found = source[name] + if (typeof found === 'function') return found as IconComponent + } catch { /* a hostile namespace: the fallback below */ } + return () => null } -export const IconBranch = resolveIcon('IconBranchOutlineRegular', 'IconBranchOutline16') -export const IconPlus = resolveIcon('IconPlusOutlineRegular', 'IconPlusOutline16') -export const IconCheck = resolveIcon('IconCheckOutlineRegular', 'IconCheckOutline16') -export const IconCopy = resolveIcon('IconCopyOutlineRegular', 'IconCopyOutline16') -export const IconClose = resolveIcon('IconCloseOutlineRegular', 'IconCloseOutline16') -export const IconSettings = resolveIcon('IconSettingsOutlineMedium', 'IconSettingsOutline14') -export const IconChevronDown = resolveIcon('IconChevronDownOutlineMedium', 'IconChevronDownOutline14') -export const IconChevronUp = resolveIcon('IconChevronUpOutlineMedium', 'IconChevronUpOutline14') -export const IconSearch = resolveIcon('IconSearchOutlineRegular', 'IconSearchOutline16') +export const IconBranch = resolveIcon('IconBranchOutlineRegular') +export const IconPlus = resolveIcon('IconPlusOutlineRegular') +export const IconCheck = resolveIcon('IconCheckOutlineRegular') +export const IconCopy = resolveIcon('IconCopyOutlineRegular') +export const IconClose = resolveIcon('IconCloseOutlineRegular') +export const IconSettings = resolveIcon('IconSettingsOutlineMedium') +export const IconChevronDown = resolveIcon('IconChevronDownOutlineMedium') +export const IconChevronUp = resolveIcon('IconChevronUpOutlineMedium') +export const IconSearch = resolveIcon('IconSearchOutlineRegular') diff --git a/src/client/revealScroll.ts b/src/client/revealScroll.ts index 56a5d05..d2c5b78 100644 --- a/src/client/revealScroll.ts +++ b/src/client/revealScroll.ts @@ -2,9 +2,8 @@ * Scroll an element's nearest scrollable ancestor so the element tops that * scrollport. The jump's landing reveal needs this host-agnostic: the * conversation tab scrolls the shared `[data-conversation-scroll]` container - * while the right Sidebar's scroller differs per generation (dockkit's pane - * body — a CSS-module class with no stable attribute — on the 0.1.5 line, the - * tab root itself on the 0.1.7 line), so the walk takes whichever ancestor + * while the right Sidebar's scroller differs per shell layout (the tab root + * itself on the supported lines), so the walk takes whichever ancestor * actually scrolls — overflowing style alone is not enough, a grow-with- * content box reports `auto` yet never moves — and the rect delta lands the * anchor flush regardless of the container's current position. diff --git a/src/client/services.ts b/src/client/services.ts index 30e5336..72fa2d1 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -30,7 +30,7 @@ export interface SlotRegistration { /** List slots dispatch on id + order. */ id?: string order?: number - /** Keyed slots (e.g. settings.plugin.item) dispatch on the entry key. */ + /** Keyed slots (e.g. plugins.bundle.config) dispatch on the entry key. */ key?: string /** optional dictionary namespace; the framework then synthesizes the `t` seat. */ locale?: string @@ -49,7 +49,7 @@ export interface SlotsService { /** * One guide-page capsule a right-Sidebar tab type contributes (dsh - * 0.1.5-rc.1+): the glyph, the title, and the optional one-line description, + * 0.1.7-rc.2+): the glyph, the title, and the optional one-line description, * exactly the fields `SidebarRightGuideEntry` carries. */ export interface SidebarGuideEntryLike { @@ -79,10 +79,10 @@ export interface SidebarTabDefinitionLike { /** * The right Sidebar's tab-type registry (`ctx.sidebarRightTabs`), as far as - * this plugin consumes it. OPTIONAL by contract: the service ships only on the - * 0.1.5 line (0.1.5-rc.1+ supported), so the plugin reaches it through a - * deferred inject and stays fully functional (no pending fiber, no throw) - * without it. + * this plugin consumes it. OPTIONAL by contract: a deployment may strip the + * service (and a below-baseline host, gated onto the fallback units, serves + * none of this half's seats), so the plugin reaches it through a deferred + * inject and stays fully functional (no pending fiber, no throw) without it. */ export interface SidebarTabsFace { register(definition: SidebarTabDefinitionLike): () => void @@ -815,13 +815,6 @@ export interface SessionScopeFace { export interface SessionsFace { scope(id: string): SessionScopeFace | undefined - /** - * Select a listed session as current — the retired selection spelling, - * still served through the 0.1.5 line and gone since the 0.1.6 selection - * refactor (issue #90: the verb's home is now the view owner). Re-proved at - * the call site via {@link openSessionVia}, which prefers the newer face. - */ - open?(id: string): void /** * Re-pull the session-list baseline (the rows' projection column included). * The overview rides it on open so host-side backfill rows (backfill.ts) @@ -956,12 +949,12 @@ export function openPathVia(ctx: ClientCtx): ((path: string) => void) | undefine /** * The right Sidebar's resource opener over `ctx.sidebarRight`, or undefined - * when this harness serves no such column (every line older than 0.1.5-rc.1) or - * the face is hostile — the caller then keeps its system-open degradation. - * Synchronous, and it reports whether the column took the address: `openResource` - * throws for a no-type-claims address or with no session surface mounted, and an - * unwired preview must fall back rather than become an inert click. The face is - * re-proved at call time (the service can land or be revoked across an HMR + * when this harness serves no such column (a stripped deployment) or the face + * is hostile — the caller then keeps its system-open degradation. Synchronous, + * and it reports whether the column took the address: `openResource` throws + * for a no-type-claims address or with no session surface mounted, and an + * unwired preview must fall back rather than become an inert click. The face + * is re-proved at call time (the service can land or be revoked across an HMR * reload), so the returned closure reads it per open. */ export function openResourceVia(ctx: ClientCtx): ((address: string) => boolean) | undefined { @@ -995,26 +988,19 @@ export function openResourceVia(ctx: ClientCtx): ((address: string) => boolean) /** * Jump to one session — the harness's own session-selection verb (issue #90). * Every supported line selects through the view owner (`uiWorkspace` - * .openSession, the sidebar row click's own verb); the sessions service's own - * `open` is the retired spelling (still served through the 0.1.5 line, gone - * since the 0.1.6 selection refactor) and stays as the degradation path for a - * composition without the workspace module. Both faces are re-proved per call - * (a service can land or be revoked across an HMR reload) and the verb is - * invoked bound (the service instance reads its own state); a generation - * serving neither, or a hostile face, swallows — the jump is best-effort by - * nature. + * .openSession, the sidebar row click's own verb, re-spelled + * `openSession(target: SessionTarget)` by the 0.1.6 selection refactor; a + * plain session id satisfies the target union). The face is re-proved per + * call (a service can land or be revoked across an HMR reload) and the verb + * is invoked bound (the service instance reads its own state); a composition + * without the workspace module, or a hostile face, swallows — the jump is + * best-effort by nature. */ export function openSessionVia(ctx: ClientCtx, id: string): void { try { const workspace = asRecord(ctx.get('uiWorkspace')) const open = workspace?.openSession - if (typeof open === 'function') { - open.call(workspace, id) - return - } - const sessions = asRecord(ctx.get('sessions')) - const legacy = sessions?.open - if (typeof legacy === 'function') legacy.call(sessions, id) + if (typeof open === 'function') open.call(workspace, id) } catch { /* absent or hostile face — the jump is best-effort */ } } diff --git a/src/client/settings.ts b/src/client/settings.ts index 600dbab..a82ff4d 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -1,9 +1,9 @@ /** * The plugin's user-settings binding (browser half). The Host-served * `dsh-context` namespace carries per-user display preferences; the Context - * tab reads them at mount, and the Plugin configuration card (Settings → - * Plugins) writes them through the settings scope. Both degrade to the - * schema defaults when the settings surface is absent (older host) or + * tab reads them at mount, and the Plugins page's configuration card writes + * them through the bound form. Both degrade to the schema defaults when the + * settings surface is absent (a host without the Config-form transport) or * read-only (remote browser in memory mode). * * The scope faces are minimally re-typed here (the services.ts discipline): @@ -18,18 +18,13 @@ import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDura // here so client-side consumers keep their canonical import path. export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types' -/** The bound settings scope (ctx.settingsScope.bind result), as consumed. */ +/** The bound settings form (the ctx.configForms.get result), as consumed. */ export interface SettingsScopeLike { getSnapshot(): { status: string; value: unknown; writable: boolean } subscribe(listener: () => void): () => void set(field: string, value: unknown): Promise } -/** The ctx.settingsScope binder face, as consumed. */ -export interface SettingsScopeBinderFace { - bind(spec: { namespace: string }): SettingsScopeLike -} - /** * The ctx.configForms service face (the Config-form generation's settings * transport), as consumed: forms bind per namespace, and `whileServed` keeps diff --git a/src/client/settingsJump.ts b/src/client/settingsJump.ts index 633b037..f349b49 100644 --- a/src/client/settingsJump.ts +++ b/src/client/settingsJump.ts @@ -1,45 +1,22 @@ /** - * Best-effort jump to this plugin's preferences. Two generations of chrome: - * - dsh 0.1.7+ (Config-form generation): the preferences live on the - * bundle's page of the Plugins main panel — the jump clicks the panel's - * sidebar entry (its shipped aria-label), then follows the page with a - * bounded poll for the bundle card's open control, found by the shipped, - * locale-independent `data-plugin-package` list identity: the card lists - * once the page's package load settles, and inside it the one button - * that is not the enable switch is the title that opens the page. A - * poll that never finds the card (still loading, plugin absent, or the - * page already open on the detail view — the list unmounts there) - * degrades to the card list, and every step stays silent. - * - older lines: Settings → Plugins → Plugin configuration — the settings - * panel keeps its open state inside the shell component (no plugin-facing - * open face exists), so the jump drives the real chrome: click the - * sidebar's settings trigger, then the Plugins section nav row, both - * found by their shipped attributes and labels. - * The last leg — the card itself — is ours: the module also carries a - * short-lived in-bundle expand request that the settings-section card - * consumes on mount, so that jump lands on the configuration already open. - * EVERY step is individually guarded: a shell that doesn't match (different - * layout, missing sidebar) degrades the whole jump to a silent no-op — this - * path must never throw into the caller's render. + * Best-effort jump to this plugin's preferences on the Config-form + * generation (every supported line): the preferences live on the bundle's + * page of the Plugins main panel — the jump clicks the panel's sidebar entry + * (its shipped aria-label), then follows the page with a bounded poll for the + * bundle card's open control, found by the shipped, locale-independent + * `data-plugin-package` list identity: the card lists once the page's package + * load settles, and inside it the one button that is not the enable switch is + * the title that opens the page. A poll that never finds the card (still + * loading, plugin absent, or the page already open on the detail view — the + * list unmounts there) degrades to the card list, and every step stays + * silent: a shell that doesn't match (different layout, missing sidebar) + * degrades the whole jump to a silent no-op — this path must never throw + * into the caller's render. */ -let requestedAt = 0 - -/** Raise a short-lived request for the plugin's settings card to mount expanded. */ -export function requestCardExpand(now: number = Date.now()): void { - requestedAt = now -} - -/** Consume the request once; true only while it is still fresh (younger than maxAgeMs). */ -export function consumeCardExpand(now: number = Date.now(), maxAgeMs = 5000): boolean { - const fresh = requestedAt > 0 && now - requestedAt < maxAgeMs - requestedAt = 0 - return fresh -} - /** * The shipped Plugins label in both locales — the main-panel entry's - * aria-label and the settings section's nav row text are the same word. + * aria-label. */ const PLUGIN_LABELS = new Set(['Plugins', '插件']) @@ -71,55 +48,26 @@ function findCardOpenControl(doc: Document): HTMLButtonElement | undefined { return open.length === 1 ? open[0] : undefined } -/** The sidebar's settings triggers: dialog semantics plus an expanded flag. */ -function findTriggers(doc: Document): HTMLButtonElement[] { - return buttonsOf(doc) - .filter(b => b.getAttribute('aria-haspopup') === 'dialog' && b.hasAttribute('aria-expanded')) -} - -/** The Plugins section's nav row, matched by its shipped label text. */ -function findSectionRow(doc: Document): HTMLButtonElement | undefined { - return buttonsOf(doc).find(b => PLUGIN_LABELS.has(b.textContent.trim())) -} - export function openPluginSettings( doc: Document = document, schedule: (run: () => void, ms: number) => void = (run, ms) => { window.setTimeout(run, ms) }, ): void { try { - // Config-form generation first: the panel entry is the one seat both - // locales ship. The follow-up poll rides the same scheduler contract as - // the legacy leg, so tests drive every tick. + // The panel entry is the one seat both locales ship; without it this + // shell has no Plugins page and the jump is a no-op. const panel = findPanelEntry(doc) - if (panel !== undefined) { - panel.click() - const poll = (left: number): void => { - try { - const open = findCardOpenControl(doc) - if (open !== undefined) { - open.click() - return - } - } catch { /* a hostile card: stay on the card list */ } - if (left > 0) schedule(() => { poll(left - 1) }, 100) - } - schedule(() => { poll(POLL_TICKS) }, 200) - return - } - const triggers = findTriggers(doc) - const open = triggers.find(b => b.getAttribute('aria-expanded') === 'true') - // No dialog trigger at all: this shell has no settings panel — no-op. - if (open === undefined && triggers.length === 0) return - // A collapsed trigger opens the panel; an already-open one is left alone. - triggers.find(b => b.getAttribute('aria-expanded') !== 'true')?.click() - // Only a chrome with a settings dialog raises the expand request, and - // only once the trigger click survived — a degraded jump leaves no - // stale signal behind. - requestCardExpand() - schedule(() => { + if (panel === undefined) return + panel.click() + const poll = (left: number): void => { try { - findSectionRow(doc)?.click() - } catch { /* no Plugins row on this host: stay on the opened section */ } - }, 80) + const open = findCardOpenControl(doc) + if (open !== undefined) { + open.click() + return + } + } catch { /* a hostile card: stay on the card list */ } + if (left > 0) schedule(() => { poll(left - 1) }, 100) + } + schedule(() => { poll(POLL_TICKS) }, 200) } catch { /* the settings surface doesn't match: silent no-op */ } } diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index 2003d0b..624c1b7 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -1,5 +1,5 @@ /** - * The right Sidebar's Context tab (dsh 0.1.5-rc.1+). + * The right Sidebar's Context tab (dsh 0.1.7-rc.2+). * * The tab reuses the Context conversation-view component VERBATIM: the * `sidebar.right.pane.tab` seat is session-scoped and delivers the same @@ -11,14 +11,15 @@ * glyph, title, and description line, plus the chip-title seat that puts the * same glyph beside the label once the tab is open (`icon.tsx`). * - * OPTIONAL BY CONTRACT. `ctx.sidebarRightTabs` and the seat ship only on the - * 0.1.5 line (0.1.5-rc.1+ supported); the registration therefore rides a - * DEFERRED inject (the plugin's hard injects stay `slots` + `locale`), so on - * every older supported line the callback never fires, the plugin fiber never - * pends, and nothing is registered. The registry is re-proved structurally and - * the whole registration is guarded: a foreign or hostile registry (a throwing - * `register`, a taken id/kind) leaves the sidebar without the tab instead of - * taking the browser down. + * OPTIONAL BY CONTRACT. `ctx.sidebarRightTabs` and the seats ship on every + * supported line, but a deployment may strip them (and a below-baseline host, + * gated onto the fallback units, serves none of this half's seats); the + * registration therefore rides a DEFERRED inject (the plugin's hard injects + * stay `slots` + `locale`), so a composition without the service never fires + * the callback, the plugin fiber never pends, and nothing is registered. The + * registry is re-proved structurally and the whole registration is guarded: a + * foreign or hostile registry (a throwing `register`, a taken id/kind) leaves + * the sidebar without the tab instead of taking the browser down. * * @module dsh-context/client/sidebar */ @@ -87,9 +88,9 @@ export function watchSidebarContextTab( { name: 'sidebar.right.pane.tab', key: SIDEBAR_CONTEXT_ID, locale: ns }, (props: { sessionId?: string } & Record) => view({ ...props, host: 'sidebar' }), ))) - // The chip title seat (0.1.5-rc.1+): the emblem beside the label, the - // files type's own idiom. Registered under the type id so the kit - // dispatches it for this type's tabs only. + // The chip title seat: the emblem beside the label, the files type's + // own idiom. Registered under the type id so the kit dispatches it for + // this type's tabs only. own(injected.slots.inject('sidebar.right.pane.tab.title', () => injected.slots.register( { name: 'sidebar.right.pane.tab.title', key: SIDEBAR_CONTEXT_ID }, makeContextTabTitle(t), diff --git a/src/client/styles/base.css b/src/client/styles/base.css index 501b96d..ae51a80 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -8,18 +8,17 @@ */ .lc-root { padding: 16px 20px 32px; overflow-y: auto; height: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 13px; } /* - Right Sidebar panel (dsh 0.1.5-rc.1+): the body draws NO padding of its own - (dockkit leaves each tab its own insets), so the root keeps a 12px frame on - all four sides — the harness's own body inset — instead of the wide shared - chrome. The root KEEPS its own scroller: the 0.1.5 line renders the tab - straight into the scrolling dockkit pane body, while the 0.1.7 line wraps it - in a definite-height `.tabBody` whose `overflow: hidden` would clip a grown - root out of reach — `height: 100%` resolves against whichever definite-height - host the running generation provides (and degrades to the root growing under - an auto-height one, where the pane scrolls instead: one scroller either way). + Right Sidebar panel: the body draws NO padding of its own (dockkit leaves + each tab its own insets), so the root keeps a 12px frame on all four sides — + the harness's own body inset — instead of the wide shared chrome. The root + KEEPS its own scroller: the supported lines wrap the tab in a + definite-height `.tabBody` whose `overflow: hidden` would clip a grown root + out of reach — `height: 100%` resolves against whichever definite-height + host the running shell provides (and degrades to the root growing under an + auto-height one, where the pane scrolls instead: one scroller either way). Scoped by the panel's own markers, which exist only inside the right column - (the open panel and the 0.1.5 float host that carries a tab dragged out of - it); the conversation tab and the /context modal are unaffected. + (the open panel and the float host that carries a tab dragged out of it); + the conversation tab and the /context modal are unaffected. */ [data-sidebar-right-open] .lc-root, [data-sidebar-right-float-host] .lc-root { padding: 12px; } diff --git a/src/client/styles/settings.css b/src/client/styles/settings.css index f02b991..c9d5b5d 100644 --- a/src/client/styles/settings.css +++ b/src/client/styles/settings.css @@ -1,16 +1,5 @@ -/* The settings-entry card at the foot of the Context tab (settingsCard.tsx). */ -.lc-settings-card { background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } -.lc-settings-card:hover { border-color: var(--dsw-alias-label-dimmed); } -.lc-settings-open, .lc-settings-open:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-label-dimmed); } -.lc-settings-head { width: 100%; appearance: none; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; } -.lc-settings-headtext { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } -.lc-settings-name { font-size: 15px; font-weight: 600; line-height: 1.4; color: var(--dsw-alias-label-primary); } -.lc-settings-desc { font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); } -.lc-settings-chevron { flex: none; color: var(--dsw-alias-label-tertiary); transition: transform var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } -.lc-settings-open .lc-settings-chevron { transform: rotate(180deg); } -.lc-settings-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding: 4px 0 12px; } -/* The Plugins-page card (Config-form generation): the page draws the section - chrome, so the rows render flat inside their own block. */ +/* The preference card on the Plugins page (settingsCard.tsx): the page draws + the section chrome, so the rows render flat inside their own block. */ .lc-settings-prefs { padding: 4px 0; } .lc-settings-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; } .lc-settings-label { flex: 1; min-width: 0; font-size: 14px; color: var(--dsw-alias-label-primary); } diff --git a/src/client/viewFocus.ts b/src/client/viewFocus.ts index a0119ae..1257edf 100644 --- a/src/client/viewFocus.ts +++ b/src/client/viewFocus.ts @@ -45,10 +45,10 @@ export function takeContextFocus(sessionId: string): number | null { /** * Open the Context tab on the right Sidebar over `ctx.sidebarRight.openTab` * (the column expands in the same step). OPTIONAL seam, re-proved at call - * time: a harness without the service (every line older than 0.1.5-rc.1), a - * placement that registered no tab type, no mounted session surface, or a - * hostile face all report false so the caller keeps its conversation-tab - * fallback. + * time: a harness without the service (a stripped deployment, a below-baseline + * gated host), a placement that registered no tab type, no mounted session + * surface, or a hostile face all report false so the caller keeps its + * conversation-tab fallback. */ export function openContextSidebar(ctx: ClientCtx): boolean { try { diff --git a/src/host/backfill.ts b/src/host/backfill.ts index ad98bdb..ccfcdde 100644 --- a/src/host/backfill.ts +++ b/src/host/backfill.ts @@ -44,7 +44,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { interruptedTurnClosers, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { type ColdReadGate, makeColdReadGate } from './coldRead' @@ -58,13 +58,12 @@ interface SessionQueryLike { /** * The cache's two cold-path verbs, as consumed (re-proved at runtime). The - * cached-rows probe's face differs across the supported range — dsh - * 0.1.5-rc.1 takes `(meta, inheritedEventCount, keys)` while dsh 0.1.7-rc.2 - * dropped the offset (`(meta, keys)`, the lifecycle identity riding the - * header alone) — so {@link servesRows} re-proves the shape per call. + * cached-rows probe rides the current face — `(meta, keys?)`, the lifecycle + * identity riding the header alone (the offset parameter belonged to the + * dropped 0.1.5 line and never existed on a supported one). */ interface ProjectionCacheLike { - cachedSnapshot(meta: SessionHeader, offsetOrKeys: unknown, keys?: readonly string[]): unknown + cachedSnapshot(meta: SessionHeader, keys?: readonly string[]): unknown coldSnapshot(header: SessionHeader, inheritedEventCount: unknown, events: readonly SessionEvent[]): unknown } @@ -125,24 +124,6 @@ function headerOf(record: unknown): SessionHeader | null { /** The probe keys: the projection keys the cache must already serve. */ const PROBE_KEYS = ['contextActivity', 'contextTimeline'] -/** - * Probe the cache through the face the running generation serves. The older - * spelling is tried first and the newer one answers the fallback: on a - * 0.1.7-rc.2 cache the 3-arg call binds the branded offset onto `keys`, - * where `new Set(0)` throws as soon as a served record is reached — while a - * record-less session returns `undefined` without throwing, which is the - * correct "not served" either way. On 0.1.5-rc.1 the 3-arg face answers - * directly; if it ever throws there, the 2-arg retry degrades to the same - * "not served" verdict through the caller's catch. - */ -function cachedSnapshotOf(cache: ProjectionCacheLike, header: SessionHeader): unknown { - try { - return cache.cachedSnapshot(header, SessionLogOffset(0), PROBE_KEYS) - } catch { - return cache.cachedSnapshot(header, PROBE_KEYS) - } -} - /** Whether the cache already serves BOTH projection rows for this header (nothing to backfill). */ function servesRows(cache: ProjectionCacheLike, header: SessionHeader): boolean { try { @@ -151,8 +132,9 @@ function servesRows(cache: ProjectionCacheLike, header: SessionHeader): boolean // misses and the session takes the cold-read path — correct either way. // Both keys must be served: a version-stale row (the timeline's head // gained `lastUser` at stateVersion 20) reads as absent here, so the - // session's stale rows get their one cold refold at startup. - const block = asRecord(cachedSnapshotOf(cache, header)) + // session's stale rows get their one cold refold at startup. A hostile + // face's throw reads as "not served" the same way. + const block = asRecord(cache.cachedSnapshot(header, PROBE_KEYS)) const values = asRecord(block?.values) return values !== null && values.contextActivity !== undefined diff --git a/src/host/balance.ts b/src/host/balance.ts index 5af5128..ee71f23 100644 --- a/src/host/balance.ts +++ b/src/host/balance.ts @@ -7,11 +7,11 @@ * so the HOST reads it and serves the redacted figures. The connection facts * resolve per request, exactly as the DeepSeek provider serves its own * requests: its settings row carries the credential ref and the optional - * endpoint override, read through whichever face the running line serves - * (deepseekSectionOf), and the credentials service resolves the ref to the - * key. Any missing fact — the provider absent, no settings service, no key — - * answers a typed `null`, as does a failed or malformed platform read: the - * capsule renders nothing rather than a stale figure. + * endpoint override (read off the Config-form projection, deepseekSectionOf), + * and the credentials service resolves the ref to the key. Any missing fact — + * the provider absent, no settings service, no key — answers a typed `null`, + * as does a failed or malformed platform read: the capsule renders nothing + * rather than a stale figure. * * The transport is Connection's fetch-route registry (the same authenticated * `/api` fence host/detail.ts mounts), registered through a deferred inject @@ -29,13 +29,10 @@ import type { PlatformBalance, PlatformBalanceEntry } from '../shared/types' export const BALANCE_ROUTE = '/api/dsh-context/balance' /** The settings id the DeepSeek API-key provider has served its connection - * section under: the V3 registered namespace, kept as the entry id on V4+ - * product profiles. */ + * section under: the pre-Config-form registered namespace, kept as the entry + * id on product profiles. */ const DEEPSEEK_SETTINGS_NS = 'llm-deepseek' -/** llm-deepseek's default credential ref (the env-var name its section resolves). */ -const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' - /** The platform's public API root (llm-deepseek's PUBLIC_BASE_URL default). */ const PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -43,13 +40,12 @@ const PUBLIC_BASE_URL = 'https://api.deepseek.com' const FETCH_TIMEOUT_MS = 10_000 /** - * The harness `settings` service, as far as the route consumes it. The face - * is generation-specific: V3 reads a registered section back (`get`), while - * V4+ retired that face and projects every configurable entry instead - * (`describe`) — the route folds over whichever the running line serves. + * The harness `settings` service, as far as the route consumes it: the + * Config-form generation's `describe()` projection of every configurable + * entry (the only face the supported lines serve — the registered-section + * `get(ns)` read retired with them). */ interface SettingsHostFace { - get?(ns: string): unknown describe?(): unknown } @@ -87,21 +83,17 @@ function amountOf(value: unknown): number | null { interface DeepSeekFacts { baseUrl: string; apiKey: string } /** - * The DeepSeek API-key provider's settings section, read through whichever - * face the running line serves. V3's `get` reads the registered - * `llm-deepseek` section directly; V4+ retired that face, so its `describe()` - * projection is folded instead — the provider's row is the one whose served - * value declares the top-level `apiKeyEnv` credential ref (the volatile shape - * only that provider declares at the section root), preferred under the entry - * ids the generations have served it as (`llm-deepseek` on product profiles, - * `llm-deepseek-api-key` elsewhere). Rows fold in isolation — a hostile row - * drops whole, valid siblings keep serving — and a describe that throws is no - * section at all. + * The DeepSeek API-key provider's settings section, folded from the + * Config-form `describe()` projection: the provider's row is the one whose + * served value declares the top-level `apiKeyEnv` credential ref (the + * volatile shape only that provider declares at the section root), preferred + * under the entry ids the generations have served it as (`llm-deepseek` on + * product profiles, `llm-deepseek-api-key` elsewhere). Rows fold in + * isolation — a hostile row drops whole, valid siblings keep serving — and a + * describe that throws is no section at all. */ function deepseekSectionOf(ctx: Context): Record | null { const settings = ctx.get('settings') as SettingsHostFace | undefined - const section = typeof settings?.get === 'function' ? asRecord(settings.get(DEEPSEEK_SETTINGS_NS)) : null - if (section !== null) return section if (typeof settings?.describe !== 'function') return null try { const rows = settings.describe() @@ -134,9 +126,9 @@ function deepseekSectionOf(ctx: Context): Record | null { async function resolveFacts(ctx: Context): Promise { const section = deepseekSectionOf(ctx) if (section === null) return null - const apiKeyEnv = typeof section.apiKeyEnv === 'string' && section.apiKeyEnv !== '' - ? section.apiKeyEnv - : DEFAULT_API_KEY_ENV + // The matched row's apiKeyEnv is a proved non-empty string (the match rule), + // and the provider's schema defaults it anyway. + const apiKeyEnv = section.apiKeyEnv as string const baseUrl = typeof section.baseURL === 'string' && section.baseURL !== '' ? section.baseURL : PUBLIC_BASE_URL diff --git a/src/host/compat.ts b/src/host/compat.ts index a1bcb75..f707700 100644 --- a/src/host/compat.ts +++ b/src/host/compat.ts @@ -2,7 +2,7 @@ * Session-projection unit contract compatibility layer. * * Supported harness baseline (see docs/compatibility.md): dsh - * 0.1.5-rc.1+. On it, the session-projection registry drives the unit + * 0.1.7-rc.2+. On it, the session-projection registry drives the unit * contract (introduced in dsh 0.1.1-rc.1, replacing the pre-0.1.1 * `{ schema, view }` shape): the real projection units emit only the modern * shape, while the below-baseline fallback units (fallback.ts) still carry @@ -24,7 +24,7 @@ * that contract as-is (both arguments go unobserved). * * The installed devDependency types pin the oldest supported surface - * (0.1.5-rc.1), whose `wire?` is optional; this plugin's units are always + * (0.1.7-rc.2), whose `wire?` is optional; this plugin's units are always * client-visible, and the registry's wired-register overload demands `wire` * PRESENT — which the dts's optional `wire?` fails. The contract is * therefore mirrored here as {@link ProjectionDefinition} to keep both @@ -35,7 +35,7 @@ import type { z } from 'zod' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { SessionEvent } from '@deepseek-ai/dsh-session' -/** The session-projection unit contract served by dsh 0.1.5-rc.1+ (local mirror). */ +/** The session-projection unit contract served by dsh 0.1.7-rc.2+ (local mirror). */ export interface ProjectionDefinition { key: K stateSchema: z.ZodType diff --git a/src/host/config.ts b/src/host/config.ts index 0824c53..f2091bc 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -9,11 +9,9 @@ * live form from this same schema — there the namespace is the entry id * (`dsh-context`) and only the `.volatile()` preference fields are served and * editable. Bounds edits remount the entry (the folds read them at apply); - * preference edits commit volatile-only and never remount. On the older lines - * the preferences are inert here — their surface is the registered settings - * namespace (settings.ts). The `Config` type describes the RESOLVED shape - * cordis hands to `apply`; the raw patch values are partial and the schema - * fills every default. + * preference edits commit volatile-only and never remount. The `Config` type + * describes the RESOLVED shape cordis hands to `apply`; the raw patch values + * are partial and the schema fills every default. */ import z from '@deepseek-ai/schemastery' @@ -73,8 +71,8 @@ export const DEFAULT_BOUNDS: FoldBounds = { /** * Mark a field live-editable where the harness's schemastery ships the * `.volatile()` modifier (the Config-form generation reads the mark to serve - * the field on the Plugins page); a plain field on the older lines whose - * schemastery predates the modifier. + * the field on the Plugins page); a plain field where the modifier is absent + * (the feature-detect keeps one schema serving every harness the gate admits). */ export function volatileField(field: S): S { const volatile = (field as unknown as { volatile?: () => S }).volatile diff --git a/src/host/fold.ts b/src/host/fold.ts index 4b7cff9..e3d288e 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -502,20 +502,21 @@ interface SurfaceEventLike { interface MessageLike { content?: ContentBlock[] source?: MessageSource - error?: boolean /** - * The V4 tool-result error mark (`tool/result.message.isError`): V3 carried - * it inside the `tool-result` content wrapper block, V4 lifted it onto the - * message and made the event-level `data.error` identity optional — so the - * flag is read from BOTH spellings. + * The tool-result error mark (`tool/result.message.isError`): the V4 log + * generation's one spelling — the event-level `error` metadata (when + * present at all) is admitted only alongside it, and the harness's own + * admission validation refuses a row whose mark disagrees. */ isError?: unknown } /** * A message nested under data.message. A structural read keeps malformed - * payloads total and handles developer messages absent from the V3 - * dependency's deriveEventMessage. User messages instead live in data itself. + * payloads total, and — unlike the dependency's deriveEventMessage, which + * projects an empty-content message to null — keeps an empty developer + * message's surface position at zero tokens (the harness's message-projection + * rule the fold's surface mirrors). User messages instead live in data itself. */ function messageOf(data: Record | undefined): MessageLike | null { const message = data?.message @@ -523,21 +524,16 @@ function messageOf(data: Record | undefined): MessageLike | nul } /** - * The first full text block, recursing through nested content blocks (a tool - * result wraps its text in a `tool-result` block). Unlike `firstText` this - * must NOT truncate/normalize: the skill name is matched off the raw + * The first full text block of a tool result's content. Unlike `firstText` + * this must NOT truncate/normalize: the skill name is matched off the raw * `` wrapper. */ -function nestedText(blocks: unknown): string { +function firstFullText(blocks: unknown): string { if (!Array.isArray(blocks)) return '' for (const item of blocks) { if (item === null || typeof item !== 'object') continue const block = item as ContentBlock if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') return block.text - if (block.content !== undefined) { - const nested = nestedText(block.content) - if (nested !== '') return nested - } } return '' } @@ -548,7 +544,7 @@ function nestedText(blocks: unknown): string { * is recovered from the content rather than trusted from the call envelope. */ function skillNameOf(msg: MessageLike | null | undefined): string { - const text = nestedText(msg?.content) + const text = firstFullText(msg?.content) const match = text.match(/ = {} for (const k in st.callNames) { - if (k !== srcId && k !== blockId) kept[k] = st.callNames[k] + if (k !== srcId) kept[k] = st.callNames[k] } st.callNames = kept } - if (data?.error || message?.isError === true) node.err = true + if (message?.isError === true) node.err = true } else if (source?.kind === 'skill-invocation') { node.skill = typeof source.name === 'string' ? source.name : '?' } else if (source?.kind === 'plugin') { @@ -1177,14 +1164,15 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } case 'user/message': case 'developer/message': { - // The V3 dependency's deriveEventMessage has no developer case; - // supported V4 logs nest that message under data.message. + // The dependency's deriveEventMessage projects an empty developer + // message to null; the structural read keeps its surface position at + // zero tokens (the harness's own message-projection rule). const developer = event.type === 'developer/message' const msg = developer ? messageOf(data) : deriveEventMessage(event as never) as MessageLike | null if (developer && (msg === null || !Array.isArray(msg.content))) return state const s = ensure(['surface', 'sums', 'archived', 'events']) bumpDetailRev(s) - const node = applySurface(s, event, event.type, data, msg) + const node = applySurface(s, event, event.type, msg) const source = msg?.source if (developer || isInjection(source)) { const rec: ContextEventRecord = { @@ -1229,23 +1217,18 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } case 'tool/result': { // The model-visible message is data.message; `deriveEventMessage` - // returns that directly (the envelope also carries callId/error; pricing - // the envelope would miss all content). + // returns that directly (pricing the envelope would miss all content). const toolMsg = deriveEventMessage(event as never) as MessageLike | null // Read the pairing BEFORE applySurface consumes it (consume-once): // the armed call's name/arguments pair this result into file ops, and // the result's callId is the flush key for buffered Code-Mode ops. const msgSource = toolMsg?.source as { callId?: unknown } | undefined const srcId = msgSource?.callId - const firstBlock = toolMsg?.content?.[0] as { toolCallId?: unknown; isError?: unknown } | undefined - const blockId = firstBlock?.toolCallId - const pendingEntry = (typeof srcId === 'string' ? state.callNames[srcId] : undefined) - ?? (typeof blockId === 'string' ? state.callNames[blockId] : undefined) - const buffered = (typeof srcId === 'string' ? state.pendingCodeOps?.[srcId] : undefined) - ?? (typeof blockId === 'string' ? state.pendingCodeOps?.[blockId] : undefined) + const pendingEntry = typeof srcId === 'string' ? state.callNames[srcId] : undefined + const buffered = typeof srcId === 'string' ? state.pendingCodeOps?.[srcId] : undefined const s = ensure(['surface', 'sums', 'archived', 'events', 'fileOps', 'timing']) bumpDetailRev(s) - const node = applySurface(s, event, event.type, data, toolMsg) + const node = applySurface(s, event, event.type, toolMsg) // An answered question prompt is a human input too (whole-session // tally): the result only carries its tool name when it pairs with // the armed call, so an unpaired/foreign one counts nothing. @@ -1268,7 +1251,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds tool: pendingEntry.name, argsRaw: pendingEntry.argsRaw, meta: data?.meta, - err: Boolean(data?.error) || toolMsg?.isError === true || firstBlock?.isError === true, + err: toolMsg?.isError === true, }) pushFileOps(s, ops) } @@ -1283,7 +1266,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds }))) const kept: Record = {} for (const k in s.pendingCodeOps) { - if (k !== srcId && k !== blockId) kept[k] = s.pendingCodeOps[k] + if (k !== srcId) kept[k] = s.pendingCodeOps[k] } if (Object.keys(kept).length > 0) s.pendingCodeOps = kept else delete s.pendingCodeOps @@ -1453,7 +1436,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // null when the content array is empty (usage-only events project to no // message — same rule as dsh's surface fold). const asstMsg = deriveEventMessage(event as never) as MessageLike | null - applySurface(s, event, event.type, data, asstMsg) + applySurface(s, event, event.type, asstMsg) break } case 'session/end-seed': { diff --git a/src/host/index.ts b/src/host/index.ts index c6cc1a2..fbcf8f9 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -30,7 +30,6 @@ import { Config, resolveBounds } from './config' import { watchDetailChannel } from './detail' import { createFallbackActivityDefinition, createFallbackHeadersDefinition, createFallbackTimelineDefinition } from './fallback' import { createContextHeadersDefinition } from './headers' -import { installSettings } from './settings' import { watchStepIdentity } from './stepIdentity' import { createContextTimelineDefinition } from './timeline' import { detectHarnessVersion } from './version' @@ -95,7 +94,6 @@ export function apply(ctx: Context, config: Config): void { // demand — the dashboard (the rows' only reader) summons the pass through // the plugin's fetch route the first time it opens. watchActivityBackfill(ctx, coldReads) - installSettings(ctx) } // ---- public type surface (stable for downstream consumers) ------------------- diff --git a/src/host/logShapes.ts b/src/host/logShapes.ts index f5ceaed..6969ce8 100644 --- a/src/host/logShapes.ts +++ b/src/host/logShapes.ts @@ -1,19 +1,19 @@ /** * Shape-driven readers over the durable session-event vocabulary — the ONE * place the plugin's log spellings meet. The supported range (dsh - * 0.1.5-rc.1+, session formats V3/V4) speaks ONE dialect: + * 0.1.7-rc.2+, session format V4) speaks ONE dialect: * `system/message` surface nodes, `assistant/message.data.stream` / * `assistant/attempt.data.stream` (packed delta runs plus raw chunk * records), `SurfaceOp { startSeq, endSeq }`, `tool/ptc-dispatch`. * * The fold still reads SHAPES, never a detected harness version: the raw log - * is untrusted input at every layer, and the supported harness refuses — - * never rewrites — a legacy-format log, so anything that reaches these - * readers is current-dialect or hostile. Every reader is total over - * untrusted input — a malformed record yields "nothing here", never a throw - * (the projection registry drives the fold without an error boundary; one - * throw stalls the unit's push feed and the browser waits on "loading" - * forever). + * is untrusted input at every layer, and the supported harness migrates every + * older log to V4 on read (refusing — never serving raw — one it cannot + * convert), so anything that reaches these readers is current-dialect or + * hostile. Every reader is total over untrusted input — a malformed record + * yields "nothing here", never a throw (the projection registry drives the + * fold without an error boundary; one throw stalls the unit's push feed and + * the browser waits on "loading" forever). * * @module dsh-context/host/log-shapes */ diff --git a/src/host/pricing.ts b/src/host/pricing.ts index 51962cb..b79f9c7 100644 --- a/src/host/pricing.ts +++ b/src/host/pricing.ts @@ -60,9 +60,6 @@ function estimateBlocks(blocks: unknown): number { tokens += Math.ceil((block.name || '').length / CHARS_PER_TOKEN) + Math.ceil((block.arguments || '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break - case 'tool-result': - tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD - break case 'image': { const ref = block.attachment const priced = ref !== null && typeof ref === 'object' @@ -99,9 +96,11 @@ export function estimateToolSchema(tool: unknown): number { } /** - * Count image blocks in a message payload, recursing into nested content (tool-result blocks carry their inner blocks) — seeds each node's - * `imgs`, which the stats board's image cell sums over the LIVE surface (compacted/pruned messages stop counting). - */ + * Count image blocks in a message payload, recursing into nested content + * (a producer extension block may carry inner blocks) — seeds each node's + * `imgs`, which the stats board's image cell sums over the LIVE surface + * (compacted/pruned messages stop counting). + */ export function imageCountOf(blocks: unknown): number { let count = 0 if (!Array.isArray(blocks)) return 0 diff --git a/src/host/settings.ts b/src/host/settings.ts deleted file mode 100644 index 4534cc6..0000000 --- a/src/host/settings.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * dsh-context user settings — the per-user preference namespace served to - * browsers through the harness settings seam (`ctx.settings`). - * - * Distinct from the cordis `config:` block (config.ts), which is - * deployment-level: the settings document is per-user and GUI-editable (the - * Plugin configuration card the browser half registers, keyed by this - * namespace). The Host half only REGISTERS the namespace — every field is a - * client-side display preference, so nothing here is consumed on the Host. - * - * The register face is generation-specific: dsh V4+ removed - * `settings.register` (configuration forms derive from each loader entry's - * own Config schema there, a surface this plugin does not publish its - * preferences through yet), so the callback feature-detects the method and - * stays inert on a generation without it. - * - * Optional composition: a deployment without a settings provider never runs - * the inject callback and browsers simply see no card (schema defaults win). - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import z from '@deepseek-ai/schemastery' - -/** The namespace is the join key between the Host registration and the browser card. */ -export const SETTINGS_NAMESPACE = 'dsh-context' - -// The preference vocabulary is declared once in shared/types.ts; re-exported -// here so host-side consumers keep their canonical import path. -export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, PluginSettings } from '../shared/types' - -/** Section schema: also the wire envelope the browser scope validates against. */ -export const SettingsSchema = z.object({ - // Loose: a stale persisted value degrades to the default instead of breaking the section. - defaultPlacement: z.union(['all', 'tab', 'sidebar']).default('all').loose(), - defaultGranularity: z.union(['step', 'turn']).default('step'), - defaultTrendMode: z.union(['total', 'delta']).default('total').loose(), - defaultDeltaBase: z.union(['step', 'turn']).default('step').loose(), - defaultToolSort: z.union(['size', 'count', 'name']).default('count').loose(), - defaultFileSort: z.union(['count', 'latest', 'path']).default('count').loose(), - // Loose so a stale value also reads as the default (`show`): a config - // problem must never take the panel's entry away. - insightsEntry: z.union(['show', 'hide']).default('show').loose(), - defaultDurationCurve: z.union(['show', 'hide']).default('show').loose(), -}) - -/** Serve the namespace while a settings provider with the register face is composed; inert otherwise. */ -export function installSettings(ctx: Context): void { - ctx.inject(['settings'], (sctx) => { - const service = sctx.settings as typeof sctx.settings & { register?: (ns: SettingsNamespace, schema: unknown) => unknown } - if (typeof service.register !== 'function') return - // The settings packages register the raw namespace string (the - // `settingsNamespace()` brand helper is long gone); the branded cast - // only satisfies the dsh-settings type face. - service.register(SETTINGS_NAMESPACE as SettingsNamespace, SettingsSchema) - }) -} diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 891371a..6e17bae 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -417,11 +417,15 @@ export function createContextTimelineDefinition(config: Config, slim: () => bool // `peak` and cannot be reinterpreted into the right periods, so rows // refold from the log, which rebuilds the split (issue #91). // - // Not bumped since: the supported-baseline move (0.1.2-rc.1 → 0.1.5-rc.1, - // the plugin floor) retired the pre-V3 log-shape branches + // Not bumped since: the supported-baseline moves (0.1.2-rc.1 → + // 0.1.5-rc.1 → 0.1.7-rc.2, the plugin floor) retired log-shape branches + // that only unsupported logs exercise — first the pre-V3 spellings // (`header.system` envelope, `assistant/chunk` floods, `start`/`end` - // replacement endpoints, `tool/code-dispatch`) that only pre-V3 logs - // exercise — no supported log folds differently. Rows folded from those + // replacement endpoints, `tool/code-dispatch`), then the V3 wrapper + // spellings (the `tool-result` content block, the event-level `data.error` + // mark) once the floor left every supported harness on the V4 generation, + // whose migration rewrites them before the fold ever runs — no supported + // log folds differently. Rows folded from those // logs cannot reach this schema anyway: the projection cache's identity // gate (cache-record format version vs the header's current-generation // version) discards them wholesale and the session refolds from the diff --git a/src/shared/fileOps.ts b/src/shared/fileOps.ts index eb84d8f..34e9eaa 100644 --- a/src/shared/fileOps.ts +++ b/src/shared/fileOps.ts @@ -7,7 +7,7 @@ * host serves no `fileOps`. * * Tool coverage matches the harness's built-ins on every supported baseline - * (0.1.5-rc.1): read / read_image / write / edit (tool-fs) and grep / glob + * (0.1.7-rc.2+): read / read_image / write / edit (tool-fs) and grep / glob * (tool-fs-search), plus the Anthropic-style `str_replace_editor` (view * reads, every other command writes). Line deltas are estimates read off the * call ARGUMENTS (an edit's old/new strings, a write's content), never off diff --git a/src/shared/types.ts b/src/shared/types.ts index b81fdef..f27df00 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -139,11 +139,12 @@ export interface ContextActivity { } /** - * The per-user display-preference vocabulary of the `dsh-context` settings - * namespace — the ONE declaration both halves share: the Host registers the - * namespace schema against it (host/settings.ts), the Client binds the scope - * and edits fields by name (client/settings.ts). Type-only, so both bundles - * erase it. + * The per-user display-preference vocabulary of the `dsh-context` entry — + * the ONE declaration both halves share: the Host's entry Config schema + * carries the fields as `.volatile()` preferences (host/config.ts — the + * Config-form generation serves them live on the Plugins page), the Client + * binds the form and edits fields by name (client/settings.ts). Type-only, + * so both bundles erase it. */ export type DefaultGranularity = 'step' | 'turn' diff --git a/src/shared/version.ts b/src/shared/version.ts index 8610fff..55d2644 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -11,7 +11,7 @@ */ /** The oldest supported dsh release (see the matrix note above). */ -export const BASELINE_DSH_VERSION = '0.1.5-rc.1' +export const BASELINE_DSH_VERSION = '0.1.7-rc.2' /** * Release-channel rank at an equal X.Y.Z: a final release outranks its diff --git a/tests/baselines.ts b/tests/baselines.ts index 8ed5814..fc19ecb 100644 --- a/tests/baselines.ts +++ b/tests/baselines.ts @@ -16,7 +16,7 @@ */ /** The supported dsh tags, in lockstep with the BASELINES entries below. */ -export type BaselineId = 'v0.1.5-rc.1' | 'v0.1.7-rc.2' | 'v0.2.0-rc.2' +export type BaselineId = 'v0.1.7-rc.2' | 'v0.2.0-rc.2' /** The harness web half's client faces, as far as the compat probes consume them. */ export interface ClientSeam { @@ -48,11 +48,10 @@ export interface ClientSeam { * The session-jump seam (the Context Dashboard's session cards and the * Agent network card's nodes — issue #90). Every supported line selects a * session through the view owner's `uiWorkspace.openSession` (the sidebar - * row click's own verb), but the 0.1.6 selection refactor re-spelled the - * parameter (`sessionId` → `target: SessionTarget`) and retired the - * sessions service's own `open(id)` spelling — present through V3, absent - * on V4+. Both faces are probed presence-as-declared so a future move - * names the seam instead of silently dead-ending the jump. + * row click's own verb), re-spelled `openSession(target: SessionTarget)` by + * the 0.1.6 selection refactor, which also retired the sessions service's + * own `open(id)` spelling — absent on every supported line, probed so a + * resurrection names the seam instead of silently dead-ending the jump. */ sessionNav: { /** The view-owner navigation verb's source, plus this line's signature spelling. */ @@ -79,11 +78,10 @@ export interface ClientSeam { mainNeedle: string } /** - * The right Sidebar's tab seam — every supported generation ships it - * (0.1.5-rc.1 introduced it). The plugin's registration stays an OPTIONAL - * deferred inject: a below-baseline host (the gate's fallback composition) - * may lack the service entirely, and the client must simply never register - * the tab there instead of pending. + * The right Sidebar's tab seam — every supported generation ships it. The + * plugin's registration stays an OPTIONAL deferred inject: a below-baseline + * host (the gate's fallback composition) may lack the service entirely, and + * the client must simply never register the tab there instead of pending. */ sidebar: { /** The tab-type registry service's providing source. */ @@ -140,10 +138,10 @@ export interface Baseline { /** * The durable-event families THIS line's log carries that the host fold * switches on — the probe asserts every one exists in the tag's - * `KNOWN_SESSION_EVENT_TYPES`. Both supported generations (V3, V4) carry - * the same families, so the lists match; the matrix also asserts the union - * covers the fold's whole vocabulary, so a fold case added without a - * baseline list fails loudly instead of going unprobed. + * `KNOWN_SESSION_EVENT_TYPES`. Every supported line is the V4 generation, + * so the lists match; the matrix also asserts the union covers the fold's + * whole vocabulary, so a fold case added without a baseline list fails + * loudly instead of going unprobed. */ foldEventTypes: readonly string[] client: ClientSeam @@ -159,49 +157,44 @@ export interface Baseline { prependProofFile: string } /** - * The tag's settings-namespace surface. Through V3 the settings service - * carries the `register(ns, schema)` face the plugin's host half calls - * (feature-detected), optionally behind a namespace pattern that must - * accept the plugin literal, the browser half binds the `settingsScope` - * service and registers its card on the `settings.plugin.item` slot. V4+ - * derives configuration forms from each loader entry's own Config schema — - * the register face is gone (the plugin stays inert there) and the browser - * half rides the `configForms` service and the Plugins page's keyed - * `plugins.bundle.config` seat instead. + * The tag's settings-namespace surface. Every supported line is the V4+ + * Config-form generation: configuration forms derive from each loader + * entry's own Config schema — the `settings.register` host face is gone + * (probed absent, so the plugin never calls it), and the browser half rides + * the `configForms` service and the Plugins page's keyed + * `plugins.bundle.config` seat. */ settings: { - /** The settings service source carrying (or missing) the register face. */ + /** The settings service source missing the register face. */ serviceFile: string - /** Whether this generation serves `settings.register`. */ + /** Whether this generation serves `settings.register` (none do — asserted absent). */ register: boolean - /** The NAMESPACE_PATTERN source, where the generation enforces one. */ - patternFile?: string /** The browser slot the preferences card registers on this generation. */ cardSlot: string /** The sources declaring (or missing) the card slot. */ cardSlotFiles: readonly string[] /** The browser settings-transport service this generation composes. */ - transport: 'settingsScope' | 'configForms' + transport: 'configForms' /** The transport's declaring source, plus whether it exists. */ transportFile: string transportPresent: boolean } /** * The balance capsule's host seam (src/host/balance.ts). The facts fold - * reads the DeepSeek API-key provider's settings row through whichever face - * the line serves: the `get(ns)` registered-section read through V3, the - * Config-form `describe()` projection on V4+ — where the provider row is - * the entry whose served value declares the top-level `apiKeyEnv` - * credential ref, preferred under the settings ids the generations have - * served it as. Each face is probed against the tag's real sources. + * reads the DeepSeek API-key provider's settings row off the Config-form + * `describe()` projection — the provider row is the entry whose served + * value declares the top-level `apiKeyEnv` credential ref, preferred under + * the settings ids the generations have served it as. The retired + * registered-section `get(ns)` read is probed absent, so a resurrection + * names the seam instead of silently shadowing the projection. */ balance: { /** The settings service source the face needles are asserted in. */ settingsFile: string - /** The `get(ns)` section read — served through V3, retired on V4+. */ + /** The retired `get(ns)` section read — absent on every supported line. */ settingsGetNeedle: string settingsGetPresent: boolean - /** The Config-form projection needle, when this line serves one the fold reads. */ + /** The Config-form projection needle the fold reads. */ settingsDescribeNeedle: string | undefined /** The provider sources carrying the plugin id and the section shape the fold matches. */ providerFiles: readonly { file: string; needles: readonly string[] }[] @@ -210,117 +203,20 @@ export interface Baseline { export const BASELINES: readonly Baseline[] = [ { - // The oldest supported line — Session format V3: the system prompt is a - // surface node (`system/message`), replacement endpoints spell - // `startSeq`/`endSeq`, and the nested PTC vocabulary is - // `tool/ptc-dispatch`. From 0.1.5-alpha.2 the conversation surface moved - // under the keyed `main` panel (`main.conversation`); at 0.1.5-rc.1 the - // guide entry regained its optional description line. - id: 'v0.1.5-rc.1', - tag: 'dsh-v0.1.5-rc.1', - cordis: '4.0.2', - session: '0.1.5-rc.1', - foldEventTypes: [ - 'request/header', 'request/context', 'step/start', 'step/end', - 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'assistant/attempt', - 'tool/ptc-dispatch', - 'plan/mode', 'compaction/summary', 'compaction/prune', 'system/message', - ], - client: { - imageFaceMethod: 'imageUrl', - markdownChrome: 'labels', - platformModules: [ - 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis', - '@deepseek-ai/dsh-client-store', - '@deepseek-ai/dsh-client-ui-slots', - '@deepseek-ai/dsh-client-ui-primitives', - '@deepseek-ai/dsh-client-ui-dockkit', - ], - detailChannel: { - hostRpcFile: 'packages/client/connection/src/rpc.ts', - hostRpcNeedle: 'HostConnectionRpc', - clientRpcFile: 'packages/client/connection/src/client/rpc.ts', - clientRpcNeedle: 'call(channel, endpoint, payload', - registryFile: 'packages/session/session-projection/src/index.ts', - registryNeedle: 'stateOf<', - }, - sidebar: { - serviceFile: 'packages/client/ui-sidebar-right/src/client/index.ts', - serviceNeedle: 'sidebarRightTabs', - slotFile: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts', - slotNeedle: 'sidebar.right.pane.tab', - titleSlotNeedle: 'sidebar.right.pane.tab.title', - nav: { - file: 'packages/client/ui-sidebar-right/src/client/service.ts', - needle: 'openResource(address', - }, - guideEntry: { - file: 'packages/client/ui-sidebar-right/src/client/tab-registry.ts', - fields: [ - 'readonly order: number', - 'readonly title: () => string', - 'readonly description?: () => string', - 'readonly icon?: ComponentType', - ], - }, - }, - sessionNav: { - workspaceFile: 'packages/client/ui-workspace/src/client/navigation.ts', - workspaceNeedle: 'openSession(sessionId: SessionId): void', - sessionsFile: 'packages/api/session-controller/src/client/contract/sessions.ts', - sessionsOpen: true, - }, - insightPage: { - panellistFile: 'packages/client/ui-sidebar/src/client/contract/slots.ts', - panellistNeedle: "'sidebar.panellist'", - mainFile: 'packages/client/ui-layout/src/client/index.ts', - mainNeedle: "'main': { kind: 'keyed'", - }, - }, - settings: { - serviceFile: 'packages/settings/settings/src/index.ts', - register: true, - patternFile: 'packages/settings/settings/src/index.ts', - cardSlot: 'settings.plugin.item', - cardSlotFiles: ['packages/client/ui-settings-plugins/src/**'], - transport: 'settingsScope', - transportFile: 'packages/client/ui-settings/src/client/settings-scope.ts', - transportPresent: true, - }, - balance: { - settingsFile: 'packages/settings/settings/src/index.ts', - settingsGetNeedle: 'get(ns:', - settingsGetPresent: true, - settingsDescribeNeedle: undefined, - providerFiles: [ - { - file: 'packages/llm/llm-deepseek/src/index.ts', - needles: ["export const name = 'llm-deepseek'", "apiKeyEnv: z.string().role('credential-ref')"], - }, - ], - }, - stepGuard: { - loopFile: 'packages/core/agent-loop/src/agent.ts', - loopNeedles: ["'agent/pre-step'", "append('user/message'"], - prependProofFile: 'packages/context/time-context/src/index.ts', - }, - }, - { - // Session format V4: the fold's switched families are unchanged (the - // system prompt still rides `system/message`), but the line retires the - // `settings.register` host face and the `settings.plugin.item` slot — the - // plugin's preferences card registers on the Plugins page's keyed - // `plugins.bundle.config` slot there, keeping the old registration for the - // V3 line (each slot exists on exactly one side, so the deferred injects - // pick their generation and never pend). V4 also rewrites the tool result: - // a first-class role-`tool` message with lifted `toolCallId`/`isError` - // (the fold reads both spellings). The baseline pins `0.1.7-rc.2`, the - // first release of the line with a complete npm dependency closure; rc.2 - // adds `startsSeries` to the first `request/header` and the - // `developer/message` tool-registry events. startsSeries is inert to the - // fold; registry messages contribute to injected context. + // The oldest supported line — Session format V4: the fold's switched + // families run the current dialect (`system/message` surface nodes, the + // embedded assistant streams, `startSeq`/`endSeq` replacements, + // `tool/ptc-dispatch`), the tool result is a first-class role-`tool` + // message with lifted `toolCallId`/`isError`, and plugin configuration + // derives from the entry's own Config schema (the Plugins page's keyed + // `plugins.bundle.config` seat over the `configForms` transport). The + // baseline pins `0.1.7-rc.2`, the first release of the line with a + // complete npm dependency closure; rc.2 adds `startsSeries` to the first + // `request/header` of a resumed series and the `developer/message` + // tool-registry events. startsSeries is inert to the fold; registry + // messages contribute to injected context. // 0.1.7 enforces plugin dsh-peer compatibility at startup and install - // (evaluatePluginCompatibility); this plugin's `>=0.1.5-rc.1` dsh peers + // (evaluatePluginCompatibility); this plugin's `>=0.1.7-rc.2` dsh peers // satisfy every supported line under the gate's includePrerelease check. id: 'v0.1.7-rc.2', tag: 'dsh-v0.1.7-rc.2', @@ -424,15 +320,13 @@ export const BASELINES: readonly Baseline[] = [ // registry's register/snapshot/checkpoint/restore contract (drive, // stateVersion, wire views), the platform module table, the right // Sidebar's tab + chip-title seats and guide-entry contract, the - // session-jump verb pair, the detail channel's Connection RPC faces, the + // session-jump verb, the detail channel's Connection RPC faces, the // Plugins page's keyed `plugins.bundle.config` card slot over the - // `configForms` transport, the balance settings faces, the step-boundary + // `configForms` transport, the balance settings face, the step-boundary // guard, and the durable event vocabulary (still the V4 generation — the - // session-format tree carries no v4-to-v5 migration). The pre-0.1.6 icon - // spellings stay absent exactly as on 0.1.7 (either-spelling resolution - // covers the range). Cordis stays 4.0.4, and the line continues to - // enforce the plugin dsh-peer gate this plugin's `>=0.1.5-rc.1` peers - // satisfy. + // session-format tree carries no v4-to-v5 migration). Cordis stays 4.0.4, + // and the line continues to enforce the plugin dsh-peer gate this + // plugin's `>=0.1.7-rc.2` peers satisfy. id: 'v0.2.0-rc.2', tag: 'dsh-v0.2.0-rc.2', cordis: '4.0.4', diff --git a/tests/client/compat/clientFaces.spec.ts b/tests/client/compat/clientFaces.spec.ts index 684d431..a80aeff 100644 --- a/tests/client/compat/clientFaces.spec.ts +++ b/tests/client/compat/clientFaces.spec.ts @@ -41,9 +41,9 @@ vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => { }, // The rich-text copy control's glyphs and clipboard writer; the // Plugin Info card's settings gear. - IconCopyOutline16: () => React.createElement('span', null), - IconCheckOutline16: () => React.createElement('span', null), - IconSettingsOutline14: () => React.createElement('span', null), + IconCopyOutlineRegular: () => React.createElement('span', null), + IconCheckOutlineRegular: () => React.createElement('span', null), + IconSettingsOutlineMedium: () => React.createElement('span', null), writeClipboard: async () => true, } }) diff --git a/tests/client/components/agentGraph.spec.ts b/tests/client/components/agentGraph.spec.ts index aa17981..f367798 100644 --- a/tests/client/components/agentGraph.spec.ts +++ b/tests/client/components/agentGraph.spec.ts @@ -56,7 +56,7 @@ class FakeSessions { }, } - open(id: string): void { + openSession(id: string): void { this.opened.push(id) } @@ -73,7 +73,9 @@ class FakeSessions { } function makeView(sessions: unknown, options: { locale?: 'en' | 'zh' } = {}) { - const ctx = new TestClientCtx({ locale: options.locale, services: { sessions } }) + // The session jump rides the view owner's verb (uiWorkspace.openSession). + const nav = { openSession: (id: string) => (sessions as { openSession?: (id: string) => void }).openSession?.(id) } + const ctx = new TestClientCtx({ locale: options.locale, services: { sessions, uiWorkspace: nav } }) return makeAgentGraph(asClientCtx(ctx), options.locale === 'zh' ? makeKit('zh') : kit) } @@ -307,7 +309,7 @@ describe('AgentGraph — the family tree', () => { test('a face without refreshSubagents still renders', async () => { const face = new FakeSessions(family()) - const bare: unknown = { list: face.list, open: (id: string) => face.open(id) } + const bare: unknown = { list: face.list } const View = makeView(bare) const m = await mount(h(View, { sessionId: 'root' })) assert.equal(queryAll(m.container, 'g.lc-agent-node').length, 3) diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index 48f7fc1..2854b61 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -599,9 +599,9 @@ describe('ContextBrowser header epochs', () => { await m.unmount() }) - test('a V3 system prompt rides the timeline systems list, fetched from its own event seq', async () => { - // The V3 generation: the header epoch carries NO system price (the prompt - // is a `system/message` surface node), so the section resolves the prompt + test('a system prompt rides the timeline systems list, fetched from its own event seq', async () => { + // The header epoch carries NO system price (the prompt is a + // `system/message` surface node), so the section resolves the prompt // from `data.systems` and reads its text off that node's seq. const headers: ContextHeaders = { headers: [{ seq: 15, time: 1500, tools: [{ name: 'x', tokens: 1 }] }] } const data = tl({ @@ -611,14 +611,14 @@ describe('ContextBrowser header epochs', () => { const asked: number[] = [] const fetchHeader = (seq: number): Promise => { asked.push(seq) - return Promise.resolve(seq === 7 ? { system: 'V3 PROMPT', tools: [] } : { tools: [] }) + return Promise.resolve(seq === 7 ? { system: 'THE PROMPT', tools: [] } : { tools: [] }) } const m = await mount(h(Browser, props({ data, headers, fetchHeader }))) assert.ok(text(catRow(m, 'system')).includes(kit.t('browser.items', { n: 1 }))) await click(catRow(m, 'system')) await flush() assert.deepEqual(asked, [7], 'the prompt is fetched from the system node, not the header epoch') - assert.ok(text(query(m.container, '.lc-br-body')).includes('V3 PROMPT')) + assert.ok(text(query(m.container, '.lc-br-body')).includes('THE PROMPT')) await m.unmount() }) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 77cf5c7..445a2bd 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -244,14 +244,14 @@ describe('ContextView — baseline gate', () => { const View = makeView(new TestClientCtx()) const m = await mount(h(View, { sessionId: 'sv-gated', - useProjection: projectionsFor(timeline({ unsupported: { current: '0.1.1-rc.2', minimum: '0.1.5-rc.1' } })), + useProjection: projectionsFor(timeline({ unsupported: { current: '0.1.1-rc.2', minimum: '0.1.7-rc.2' } })), })) // The modal pops over the tab, naming both versions. assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) const card = query(m.container, '.lc-gate-card') assert.ok(text(card).includes(DICT_EN['gate.title'])) assert.ok(text(card).includes('v0.1.1-rc.2')) - assert.ok(text(card).includes('v0.1.5-rc.1')) + assert.ok(text(card).includes('v0.1.7-rc.2')) // The cards keep rendering the fallback's zeroed data behind it. assert.ok(text(m.container).includes(DICT_EN['overview.title'])) assert.ok(text(m.container).includes(DICT_EN['trend.empty'])) diff --git a/tests/client/components/events.spec.ts b/tests/client/components/events.spec.ts index b839fad..1e38483 100644 --- a/tests/client/components/events.spec.ts +++ b/tests/client/components/events.spec.ts @@ -108,12 +108,12 @@ describe('EventList', () => { assert.equal(down.textContent, '−100') assert.equal(queryAll(rows[2], '.lc-event-at').length, 0) - // Model switch: real IconBranchOutline16 svg. + // Model switch: real IconBranchOutlineRegular svg. assert.ok(query(rows[3], '.lc-event-icon svg') instanceof SVGElement) assert.ok(text(rows[3]).includes('Model switched: a → b')) assert.equal(query(rows[3], '.lc-kind').textContent, 'Switch') - // Inject: real IconPlusOutline16 svg, positive token delta, position, time. + // Inject: real IconPlusOutlineRegular svg, positive token delta, position, time. assert.ok(query(rows[4], '.lc-event-icon svg') instanceof SVGElement) const up = query(rows[4], '.lc-event-tokens') assert.ok(up.className.includes('lc-up')) diff --git a/tests/client/components/images.spec.ts b/tests/client/components/images.spec.ts index 611669f..21c2acf 100644 --- a/tests/client/components/images.spec.ts +++ b/tests/client/components/images.spec.ts @@ -218,7 +218,7 @@ describe('AttachmentLightbox', () => { assert.equal(query(box, '.lc-att-lightbox-img').getAttribute('src'), 'blob:x') const close = query(box, '.lc-att-lightbox-close') assert.equal(close.getAttribute('aria-label'), 'Close') - assert.ok(query(box, 'svg') instanceof SVGElement) // real IconCloseOutline16 + assert.ok(query(box, 'svg') instanceof SVGElement) // real IconCloseOutlineRegular assert.equal(document.activeElement, close) // focus moved into the dialog await keydown('Enter') // non-Escape keys keep it open assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1) diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts index e253c6d..6cb1c8e 100644 --- a/tests/client/components/overviewPanel.spec.ts +++ b/tests/client/components/overviewPanel.spec.ts @@ -335,7 +335,7 @@ describe('OverviewPanel', () => { test('a card click opens the session through the harness verb', async () => { const opened: string[] = [] - const ctx = makeCtx({ open: (id: string) => { opened.push(id) } }) + const ctx = new TestClientCtx({ services: { uiWorkspace: { openSession: (id: string) => { opened.push(id) } } } }) const { m } = await openPanel(ctx) await click(query(m.container, '.lc-ov-grid > .lc-ov-session')) // The verb's own navigation returns the center column to the conversation diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts index c76d4f8..daf2382 100644 --- a/tests/client/components/settingsCard.spec.ts +++ b/tests/client/components/settingsCard.spec.ts @@ -1,20 +1,18 @@ -// SettingsCard (src/client/components/settingsCard.tsx) rendered with real -// React against the real DICT_EN strings; the select rows open the REAL -// Menu primitive (portaled into document.body) and pick through it. The -// "Open in Settings" jump path mounts the card pre-expanded (settingsJump.ts -// expand request), with the scroll best-effort against stubbed prototypes. +// The preference card (src/client/components/settingsCard.tsx): the +// Plugins-page seat rendered with real React against the real DICT_EN +// strings; the select rows open the REAL Menu primitive (portaled into +// document.body) and pick through it. import { createElement as h } from 'react' import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { makePluginConfigCard, makeSettingsCard } from '../../../src/client/components/settingsCard' +import { makePluginConfigCard } from '../../../src/client/components/settingsCard' import type { SettingsState } from '../../../src/client/settings' import { DICT_EN } from '../../../src/client/i18n' -import { requestCardExpand } from '../../../src/client/settingsJump' import { click, keydown, makeKit, mount, query, queryAll, text } from '../helpers/kit' const kit = makeKit() -const SettingsCard = makeSettingsCard(kit) +const PluginConfigCard = makePluginConfigCard(kit) function hookFor(state: SettingsState) { return (sel: (s: SettingsState) => T): T => sel(state) @@ -29,61 +27,34 @@ function menuItems(): HTMLElement[] { return queryAll(document.body, '[role="menu"] [role="menuitem"]') } -type ScrollIntoViewLike = (this: Element, arg?: unknown) => void - -/** Swap Element.prototype.scrollIntoView (absent in jsdom); returns the original. */ -function stubScrollIntoView(impl: ScrollIntoViewLike | undefined): ScrollIntoViewLike | undefined { - const proto = Element.prototype as unknown as { scrollIntoView?: ScrollIntoViewLike } - const original = proto.scrollIntoView - proto.scrollIntoView = impl - return original -} - -describe('SettingsCard', () => { +describe('PluginConfigCard (the Plugins-page seat)', () => { test('renders nothing without a settings hook or when the namespace is unavailable', async () => { - const m1 = await mount(h(SettingsCard, {})) + const m1 = await mount(h(PluginConfigCard, {})) assert.equal(m1.container.childElementCount, 0) await m1.unmount() - const m2 = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) })) + const m2 = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) })) assert.equal(m2.container.childElementCount, 0) await m2.unmount() }) - test('loading state renders the card with disabled selects; the head toggles the body', async () => { - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) })) - const card = query(m.container, '.lc-settings-card') - assert.ok(!card.className.includes('lc-settings-open')) - const head = query(m.container, '.lc-settings-head') - assert.equal(head.getAttribute('aria-expanded'), 'false') - assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.expand']}: ${DICT_EN['settings.title']}`) - assert.ok(text(m.container).includes(DICT_EN['settings.desc'])) - assert.equal(queryAll(m.container, '.lc-settings-select').length, 0) - - await click(head) - assert.equal(head.getAttribute('aria-expanded'), 'true') - assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.collapse']}: ${DICT_EN['settings.title']}`) - assert.ok(card.className.includes('lc-settings-open')) - const selects = queryAll(m.container, '.lc-settings-select') + test('renders the eight rows flat — no card chrome', async () => { + const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) })) + assert.equal(m.container.querySelector('.lc-settings-card'), null, 'no settings-section chrome') + assert.ok(query(m.container, '.lc-settings-prefs')) + const selects = queryAll(m.container, '.lc-settings-select') assert.equal(selects.length, 8) - assert.ok(selects.every(s => (s as HTMLButtonElement).disabled)) - // Loading is not ready: no read-only note. + assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled') assert.equal(m.container.querySelector('.lc-settings-note'), null) - - await click(head) - assert.equal(head.getAttribute('aria-expanded'), 'false') - assert.ok(!card.className.includes('lc-settings-open')) - assert.equal(queryAll(m.container, '.lc-settings-select').length, 0) await m.unmount() }) test('ready+writable: enabled selects pick through the real portaled Menu', async () => { const calls: [string, string][] = [] - const m = await mount(h(SettingsCard, { + const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf()), set: (field, value) => { calls.push([field, value]) }, })) - await click(query(m.container, '.lc-settings-head')) const selects = queryAll(m.container, '.lc-settings-select') assert.ok(selects.every(s => !s.disabled)) assert.equal(m.container.querySelector('.lc-settings-note'), null) @@ -109,13 +80,10 @@ describe('SettingsCard', () => { assert.ok(text(m.container).includes(DICT_EN['settings.insightsEntry'])) assert.ok(text(selects[1]).includes(DICT_EN['insightsEntry.show'])) await click(selects[1]) - assert.equal(selects[1].getAttribute('aria-expanded'), 'true') const entryItems = menuItems() assert.deepEqual(entryItems.map(i => text(i)), [DICT_EN['insightsEntry.show'], DICT_EN['insightsEntry.hide']]) await click(entryItems[1]) // 'Hide' assert.deepEqual(calls, [['defaultPlacement', 'sidebar'], ['insightsEntry', 'hide']]) - assert.equal(selects[1].getAttribute('aria-expanded'), 'false') - assert.equal(document.body.querySelector('[role="menu"]'), null) // The granularity row follows. assert.ok(text(selects[2]).includes(DICT_EN['gran.step'])) @@ -132,19 +100,14 @@ describe('SettingsCard', () => { assert.ok(text(selects[7]).includes(DICT_EN['files.sort.count'])) await click(selects[2]) - assert.equal(selects[2].getAttribute('aria-expanded'), 'true') const items = menuItems() - assert.equal(items.length, 2) assert.deepEqual(items.map(i => text(i)), [DICT_EN['gran.step'], DICT_EN['gran.turn']]) - await click(items[1]) // 'Turn' assert.deepEqual(calls, [ ['defaultPlacement', 'sidebar'], ['insightsEntry', 'hide'], ['defaultGranularity', 'turn'], ]) - assert.equal(selects[2].getAttribute('aria-expanded'), 'false') - assert.equal(document.body.querySelector('[role="menu"]'), null) // The trend-mode row writes the other field. await click(selects[3]) @@ -157,7 +120,6 @@ describe('SettingsCard', () => { ['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta'], ]) - assert.equal(document.body.querySelector('[role="menu"]'), null) // The duration-curve row writes its field through the same show/hide vocabulary. await click(selects[4]) @@ -171,7 +133,6 @@ describe('SettingsCard', () => { ['defaultTrendMode', 'delta'], ['defaultDurationCurve', 'hide'], ]) - assert.equal(document.body.querySelector('[role="menu"]'), null) // The delta-baseline row writes its field through the toolbar's vocabulary. await click(selects[5]) @@ -186,7 +147,6 @@ describe('SettingsCard', () => { ['defaultDurationCurve', 'hide'], ['defaultDeltaBase', 'turn'], ]) - assert.equal(document.body.querySelector('[role="menu"]'), null) // The tool-sort row leads the file-sort one. await click(selects[6]) @@ -206,7 +166,6 @@ describe('SettingsCard', () => { ['defaultDeltaBase', 'turn'], ['defaultToolSort', 'name'], ]) - assert.equal(document.body.querySelector('[role="menu"]'), null) // The file-sort row writes the last field. await click(selects[7]) @@ -240,11 +199,10 @@ describe('SettingsCard', () => { test('Menu onClose (Escape) closes an open select without picking', async () => { const calls: [string, string][] = [] - const m = await mount(h(SettingsCard, { + const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf()), set: (field, value) => { calls.push([field, value]) }, })) - await click(query(m.container, '.lc-settings-head')) const select = query(m.container, '.lc-settings-select') await click(select) assert.equal(select.getAttribute('aria-expanded'), 'true') @@ -256,10 +214,8 @@ describe('SettingsCard', () => { await m.unmount() }) - test('ready+readonly: disabled selects and the read-only note when open', async () => { - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ writable: false })) })) - // Note only renders once the body is open. - await click(query(m.container, '.lc-settings-head')) + test('ready+readonly: disabled selects and the read-only note', async () => { + const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ writable: false })) })) const note = query(m.container, '.lc-settings-note') assert.equal(note.getAttribute('role'), 'status') assert.equal(text(note), DICT_EN['settings.readOnly']) @@ -268,8 +224,7 @@ describe('SettingsCard', () => { }) test('a value matching no option falls back to the raw id; a missing set never throws', async () => { - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ placement: 'weird' as never })) })) - await click(query(m.container, '.lc-settings-head')) + const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ placement: 'weird' as never })) })) const selects = queryAll(m.container, '.lc-settings-select') assert.ok(text(selects[0]).includes('weird')) // props.set undefined: picking still closes the menu, no throw. @@ -278,112 +233,4 @@ describe('SettingsCard', () => { assert.equal(document.body.querySelector('[role="menu"]'), null) await m.unmount() }) - - test('a fresh expand request mounts the card open, scrolled into view — once', async () => { - const scrolled: Array<{ el: Element; arg: unknown }> = [] - const restore = stubScrollIntoView(function (this: Element, arg?: unknown) { - scrolled.push({ el: this, arg }) - }) - try { - requestCardExpand() - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) })) - const card = query(m.container, '.lc-settings-card') - assert.ok(card.className.includes('lc-settings-open')) - assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true') - assert.equal(queryAll(m.container, '.lc-settings-select').length, 8) - assert.equal(scrolled.length, 1, 'the card scrolls itself into view') - assert.deepEqual(scrolled[0].arg, { block: 'nearest' }) - assert.equal(scrolled[0].el, card) - await m.unmount() - - // Consumed once: a later mount starts collapsed again. - const again = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) })) - assert.equal(query(again.container, '.lc-settings-head').getAttribute('aria-expanded'), 'false') - assert.equal(scrolled.length, 1) - await again.unmount() - } finally { - stubScrollIntoView(restore) - } - }) - - test('a host whose scrollIntoView throws still mounts expanded', async () => { const restore = stubScrollIntoView(() => { throw new Error('no scrolling here') }) - try { - requestCardExpand() - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) })) - assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true') - await m.unmount() - } finally { - stubScrollIntoView(restore) - } - }) - - test('a pending request is consumed even when the card renders nothing', async () => { - requestCardExpand() - const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) })) - assert.equal(m.container.childElementCount, 0) - await m.unmount() - - const again = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) })) - assert.equal(query(again.container, '.lc-settings-head').getAttribute('aria-expanded'), 'false') - await again.unmount() - }) -}) - -describe('PluginConfigCard (the Plugins-page seat)', () => { - const PluginConfigCard = makePluginConfigCard(kit) - - test('renders nothing without a settings hook or when the namespace is unavailable', async () => { - const m1 = await mount(h(PluginConfigCard, {})) - assert.equal(m1.container.childElementCount, 0) - await m1.unmount() - - const m2 = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) })) - assert.equal(m2.container.childElementCount, 0) - await m2.unmount() - }) - - test('renders the eight rows flat — no card chrome, no expand request consumption', async () => { - requestCardExpand() - const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) })) - assert.equal(m.container.querySelector('.lc-settings-card'), null, 'no settings-section chrome') - assert.ok(query(m.container, '.lc-settings-prefs')) - assert.equal(m.container.querySelector('.lc-settings-head'), null) - // The rows render immediately: no expand/collapse leg. - const selects = queryAll(m.container, '.lc-settings-select') - assert.equal(selects.length, 8) - assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled') - assert.equal(m.container.querySelector('.lc-settings-note'), null) - await m.unmount() - }) - - test('ready rows pick through the portaled Menu; the read-only note renders without a disclosure', async () => { - const calls: [string, string][] = [] - const m = await mount(h(PluginConfigCard, { - useContextSettings: hookFor(stateOf({ writable: false })), - set: (field, value) => { calls.push([field, value]) }, - })) - const note = query(m.container, '.lc-settings-note') - assert.equal(text(note), DICT_EN['settings.readOnly']) - const selects = queryAll(m.container, '.lc-settings-select') - assert.ok(selects.every(s => s.disabled), 'read-only: the rows are disabled') - - const writable = await mount(h(PluginConfigCard, { - useContextSettings: hookFor(stateOf()), - set: (field, value) => { calls.push([field, value]) }, - })) - assert.equal(writable.container.querySelector('.lc-settings-note'), null) - const enabled = queryAll(writable.container, '.lc-settings-select') - assert.ok(enabled.every(s => !s.disabled)) - await click(enabled[7]) // the file-sort row trails - const items = menuItems() - assert.deepEqual(items.map(i => text(i)), [ - DICT_EN['files.sort.count'], - DICT_EN['files.sort.latest'], - DICT_EN['files.sort.path'], - ]) - await click(items[2]) // 'By path' - assert.deepEqual(calls, [['defaultFileSort', 'path']]) - await writable.unmount() - await m.unmount() - }) }) diff --git a/tests/client/components/upgradeGate.spec.ts b/tests/client/components/upgradeGate.spec.ts index 0a5d2be..dc01915 100644 --- a/tests/client/components/upgradeGate.spec.ts +++ b/tests/client/components/upgradeGate.spec.ts @@ -14,7 +14,7 @@ const kitZh = makeKit('zh') const Gate = makeUpgradeGate(kit) function render(sessionId: string | undefined, extra: { current?: string; minimum?: string } = {}) { - return mount(h(Gate, { sessionId, current: extra.current ?? '0.1.1-rc.2', minimum: extra.minimum ?? '0.1.5-rc.1' })) + return mount(h(Gate, { sessionId, current: extra.current ?? '0.1.1-rc.2', minimum: extra.minimum ?? '0.1.7-rc.2' })) } describe('UpgradeGate', () => { @@ -22,7 +22,7 @@ describe('UpgradeGate', () => { const en = await render('s-en') try { assert.match(text(en.container), /Update DeepSeek Harness/) - assert.match(text(en.container), /dsh-context supports DeepSeek Harness v0\.1\.5-rc\.1 or newer\./) + assert.match(text(en.container), /dsh-context supports DeepSeek Harness v0\.1\.7-rc\.2 or newer\./) assert.match(text(en.container), /v0\.1\.1-rc\.2/) assert.match(text(en.container), /Current/) assert.match(text(en.container), /Minimum required/) @@ -30,11 +30,11 @@ describe('UpgradeGate', () => { await en.unmount() } const ZhGate = makeUpgradeGate(kitZh) - const zh = await mount(h(ZhGate, { sessionId: 's-zh', current: '0.1.0', minimum: '0.1.5-rc.1' })) + const zh = await mount(h(ZhGate, { sessionId: 's-zh', current: '0.1.0', minimum: '0.1.7-rc.2' })) try { assert.match(text(zh.container), /请更新 DeepSeek Harness 版本/) - assert.match(text(zh.container), /dsh-context 插件支持 DeepSeek Harness v0\.1\.5-rc\.1 或更新版本。/) - assert.match(text(zh.container), /v0\.1\.5-rc\.1及更新的版本/) + assert.match(text(zh.container), /dsh-context 插件支持 DeepSeek Harness v0\.1\.7-rc\.2 或更新版本。/) + assert.match(text(zh.container), /v0\.1\.7-rc\.2及更新的版本/) assert.match(text(zh.container), /v0\.1\.0/) } finally { await zh.unmount() @@ -88,10 +88,10 @@ describe('UpgradeGate', () => { await click(query(m.container, '.lc-gate-ok')) assert.equal(m.container.querySelector('.lc-modal-backdrop'), null) // Switching to a fresh session shows its gate again on the same mount. - await m.update(h(Gate, { sessionId: 's-switch-b', current: '0.1.0', minimum: '0.1.5-rc.1' })) + await m.update(h(Gate, { sessionId: 's-switch-b', current: '0.1.0', minimum: '0.1.7-rc.2' })) assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) // Switching back to the dismissed session stays closed. - await m.update(h(Gate, { sessionId: 's-switch-a', current: '0.1.0', minimum: '0.1.5-rc.1' })) + await m.update(h(Gate, { sessionId: 's-switch-a', current: '0.1.0', minimum: '0.1.7-rc.2' })) assert.equal(m.container.querySelector('.lc-modal-backdrop'), null) await m.unmount() }) diff --git a/tests/client/dockMeasure.spec.ts b/tests/client/dockMeasure.spec.ts index 2b08fb6..1a5c192 100644 --- a/tests/client/dockMeasure.spec.ts +++ b/tests/client/dockMeasure.spec.ts @@ -31,14 +31,14 @@ describe('measureDock', () => { assert.deepEqual(measureDock(start), { left: 0, right: 0, frame: null }) }) - test('parses the sidebar tracks off the frame ancestor (V3 spelling, fixed right track)', () => { + test('parses the sidebar tracks off the frame ancestor (fixed right track)', () => { vi.stubGlobal('innerWidth', 1024) const frame = frameEl('280px minmax(0, 1fr) 360px') const start = chainOf(frame, document.createElement('div'), document.createElement('div')) assert.deepEqual(measureDock(start), { left: 280, right: 360, frame }) }) - test('parses the V4 minmax spelling: the right panel open and closed', () => { + test('parses the minmax spelling: the right panel open and closed', () => { vi.stubGlobal('innerWidth', 1280) const openFrame = frameEl('280px minmax(400px, 1fr) minmax(0px, 576px)') assert.deepEqual(measureDock(chainOf(openFrame, document.createElement('div'))), { left: 280, right: 576, frame: openFrame }) diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts index f2a4a1f..a357f10 100644 --- a/tests/client/helpers/harness.ts +++ b/tests/client/helpers/harness.ts @@ -87,7 +87,7 @@ export class TestClientCtx { setService(name: string, service: unknown): void { this.services.set(name, service) - // Cordis surfaces services as context properties (ctx.settingsScope …). + // Cordis surfaces services as context properties (ctx.configForms …). if (!(name in this)) { Object.defineProperty(this, name, { get: () => this.services.get(name), configurable: true }) } diff --git a/tests/client/historyPage.spec.ts b/tests/client/historyPage.spec.ts index 25be6ce..c9135b7 100644 --- a/tests/client/historyPage.spec.ts +++ b/tests/client/historyPage.spec.ts @@ -64,10 +64,10 @@ describe('pageNodesOf — the durable-page mapper', () => { assert.deepEqual(nodes.get(12), { kind: 'tool-result', seq: 12, call: null, content: [], isError: false }) }) - test('the call head falls back to the result block\u2019s toolCallId when the source omits it', () => { + test('the call head reads the message toolCallId when the source omits it', () => { const page = [ row('tool/call', 20, { callId: 'cb', name: 'read', arguments: '{"path":"a.ts"}' }), - row('tool/result', 21, { message: { content: [{ toolCallId: 'cb', content: [] }] } }), + row('tool/result', 21, { message: { role: 'tool', toolCallId: 'cb', content: [] } }), ] assert.deepEqual(pageNodesOf(page).get(21)?.call, { name: 'read', argsRaw: '{"path":"a.ts"}' }) }) @@ -118,25 +118,23 @@ describe('pageNodesOf — the durable-page mapper', () => { assert.deepEqual(pageNodesOf([row('assistant/message', 21, {})]).get(21), { kind: 'assistant', seq: 21, blocks: [] }) }) - test('tool results pair with their in-page call head; isError rides block or envelope', () => { + test('tool results pair with their in-page call head; isError rides the message mark', () => { const page = [ row('tool/call', 30, { callId: 'c9', name: 'bash', arguments: '{"command":"pwd"}' }), row('tool/result', 31, { - callId: 'c9', - error: true, - message: { source: { callId: 'c9' }, content: [{ toolCallId: 'c9', content: [{ type: 'text', text: 'out' }] }] }, + message: { role: 'tool', toolCallId: 'c9', isError: true, source: { kind: 'tool', callId: 'c9' }, content: [{ type: 'text', text: 'out' }] }, }), row('tool/result', 32, { - message: { source: { callId: 'missing' }, content: [{ isError: true, content: [] }] }, + message: { role: 'tool', toolCallId: 'missing', isError: true, source: { kind: 'tool', callId: 'missing' }, content: [] }, }), row('tool/result', 33, { message: {} }), ] const nodes = pageNodesOf(page) const paired = nodes.get(31) assert.deepEqual(paired?.call, { name: 'bash', argsRaw: '{"command":"pwd"}' }) - assert.deepEqual(paired?.content, [{ type: 'text', text: 'out' }]) - assert.equal(paired?.isError, true, 'envelope error flag') - assert.equal(nodes.get(32)?.isError, true, 'block error flag without a pairable call') + assert.deepEqual(paired?.content, [{ type: 'text', text: 'out' }], 'the direct content blocks') + assert.equal(paired?.isError, true, 'the message error mark') + assert.equal(nodes.get(32)?.isError, true, 'the mark without a pairable call') assert.equal(nodes.get(32)?.call, null) assert.deepEqual(nodes.get(33)?.content, []) assert.equal(nodes.get(33)?.isError, false) @@ -305,12 +303,12 @@ describe('makeContentFetcher — the per-session targeted read', () => { broken.dispose() }) - test('a V3 system/message event maps its prompt text (the epoch fetch reads both shapes)', async () => { + test('a system/message event maps its prompt text', async () => { const ctx = armHistoryFaces({ session: pageFace([ - ev('system/message', 7, { message: { content: [{ type: 'text', text: 'V3 prompt' }, { type: 'text', text: ' line two' }] } }), + ev('system/message', 7, { message: { content: [{ type: 'text', text: 'the prompt' }, { type: 'text', text: ' line two' }] } }), ]) }) const content = await makeHeaderFetcher('s')!(7) - assert.deepEqual(content, { system: 'V3 prompt line two', tools: [] }) + assert.deepEqual(content, { system: 'the prompt line two', tools: [] }) ctx.dispose() }) diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index a781d79..870e430 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -1,6 +1,6 @@ // Client entry (src/client/index.ts): the plugin's apply() wiring asserted // through the faithful harness-context seams — dictionaries, slots, the -// /context trigger source, and the deferred settingsScope inject — plus real +// /context trigger source, and the deferred configForms inject — plus real // renders of the registered components. import { createElement as h, type ReactElement } from 'react' @@ -10,7 +10,7 @@ import { DICT_EN, DICT_ZH } from '../../src/client/i18n' import { modalStoreOf, type ModalStore } from '../../src/client/modalStore' import type { SettingsField, SettingsScopeLike, SettingsState } from '../../src/client/settings' import { TestClientCtx, TestSessions, asClientCtx } from './helpers/harness' -import { click, mount, query, queryAll, text } from './helpers/kit' +import { mount, query, queryAll, text } from './helpers/kit' // The entry ships via `module.exports` (bundle handoff shape); its runtime // exports are the plugin triple, opaque to the static import type. @@ -43,6 +43,37 @@ function makeScope(snapshot: { status: string; value: unknown; writable: boolean return rec } +/** A configForms face serving the given form (the card seat's transport). */ +function formsServing(form: SettingsScopeLike): { + get(namespace: string): SettingsScopeLike + whileServed(namespaces: readonly string[], register: () => () => void): () => void +} { + return { + get: () => form, + whileServed: () => () => {}, + } +} + +/** A settings form whose snapshot the test drives (the emit path). */ +function scopeWith(snapshot: { status: string; value: unknown; writable: boolean }): SettingsScopeLike & { + emit(next: { status: string; value: unknown; writable: boolean }): void +} { + let current = snapshot + const listeners = new Set<() => void>() + return { + getSnapshot: () => current, + subscribe(listener) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set: () => Promise.resolve(), + emit(next) { + current = next + for (const listener of listeners) listener() + }, + } +} + describe('client entry: constants', () => { test('name and inject declare the plugin identity and hard dependencies', () => { assert.equal(name, 'dsh-context') @@ -229,22 +260,14 @@ describe('client entry: Context Insights page seats', () => { }) test('the insightsEntry preference unwinds and remounts the pair', async () => { - const ctx = new TestClientCtx() + const scope = scopeWith({ status: 'ready', value: {}, writable: true }) + const ctx = new TestClientCtx({ services: { configForms: formsServing(scope) } }) applyTo(ctx) - // The preference flips through the registered settings card's set verb - // (the configForms transport is absent here, so drive the store directly - // through the last registered settings card face). - const scope = makeScope({ status: 'ready', value: {}, writable: true }) - ctx.setService('settingsScope', { bind: () => scope }) - assert.equal(ctx.slots.of('settings.plugin.item').length, 1, 'the V3 card seat armed') - const face = ctx.slots.of('settings.plugin.item')[0].registration.inject?.() as { - hooks: { contextSettings: { getSnapshot(): SettingsState } } - set: (field: SettingsField, value: string) => void - } - face.set('insightsEntry', 'hide') + assert.equal(ctx.slots.of('main').length, 1, 'the default entry mounts the page') + scope.emit({ status: 'ready', value: { insightsEntry: 'hide' }, writable: true }) assert.equal(ctx.slots.of('main').length, 0, 'hiding the entry unwinds the page') assert.equal(ctx.slots.of('sidebar.panellist').length, 0, 'hiding unwinds the entry') - face.set('insightsEntry', 'show') + scope.emit({ status: 'ready', value: { insightsEntry: 'show' }, writable: true }) assert.equal(ctx.slots.of('main').length, 1) assert.equal(ctx.slots.of('sidebar.panellist').length, 1) ctx.dispose() @@ -253,41 +276,7 @@ describe('client entry: Context Insights page seats', () => { }) }) -describe('client entry: settingsScope inject', () => { - test('absent at apply time: the inject stays pending — no settings.plugin.item slot', () => { - const ctx = new TestClientCtx() - applyTo(ctx) - assert.equal(ctx.slots.of('settings.plugin.item').length, 0) - ctx.dispose() - }) - - test('armed later: the pending inject runs — scope attached and card slot registered', () => { - const ctx = new TestClientCtx() - applyTo(ctx) - const scope = makeScope({ status: 'ready', value: {}, writable: true }) - const specs: { namespace: string }[] = [] - ctx.setService('settingsScope', { - bind: (spec: { namespace: string }) => { - specs.push(spec) - return scope - }, - }) - assert.deepEqual(specs, [{ namespace: 'dsh-context' }]) - assert.equal(scope.subscribes, 1) - assert.equal(ctx.slots.of('settings.plugin.item').length, 1) - ctx.dispose() - }) - - test('defensive arm: service key present but undefined — early return, no slot, no throw', () => { - const ctx = new TestClientCtx() - ctx.setService('settingsScope', undefined) - applyTo(ctx) - assert.equal(ctx.slots.of('settings.plugin.item').length, 0) - ctx.dispose() - }) -}) - -describe('client entry: configForms inject (the Config-form generation)', () => { +describe('client entry: configForms inject', () => { /** A configForms stand-in capturing the whileServed registration. */ function fakeConfigForms(form: SettingsScopeLike): { gets: string[] @@ -338,7 +327,7 @@ describe('client entry: configForms inject (the Config-form generation)', () => const scope = makeScope({ status: 'ready', value: { defaultTrendMode: 'delta' }, writable: true }) const forms = fakeConfigForms(scope) ctx.setService('configForms', forms) - // The namespace follows the settingsScope generation's join key. + // The namespace is the entry id. assert.deepEqual(forms.gets, ['dsh-context']) assert.equal(scope.subscribes, 1, 'the settings store attached to the form') assert.deepEqual(forms.whileServedCalls, [['dsh-context']]) @@ -409,86 +398,7 @@ describe('client entry: right Sidebar Context tab', () => { }) }) -describe('client entry: settings card slot', () => { - function setup(): { - ctx: TestClientCtx - scope: ReturnType - registration: { name: string; key?: string; locale?: string; inject?: () => unknown } - component: (props: Record) => unknown - } { - const scope = makeScope({ status: 'loading', value: null, writable: false }) - const ctx = new TestClientCtx({ - services: { settingsScope: { bind: () => scope } }, - }) - applyTo(ctx) - const entry = ctx.slots.of('settings.plugin.item')[0] - return { - ctx, - scope, - registration: entry.registration as never, - component: entry.component as never, - } - } - - test('registers the keyed card; inject exposes the settings store and a set verb', () => { - const { ctx, scope, registration } = setup() - assert.equal(registration.name, 'settings.plugin.item') - assert.equal(registration.key, 'dsh-context') - assert.equal(registration.locale, 'dsh-context') - - const face = registration.inject?.() as { - hooks: { contextSettings: { getSnapshot(): SettingsState } } - set: (field: SettingsField, value: string) => void - } - assert.equal(face.hooks.contextSettings.getSnapshot().granularity, 'step') - face.set('defaultGranularity', 'turn') - assert.equal(face.hooks.contextSettings.getSnapshot().granularity, 'turn') - assert.deepEqual(scope.sets, [{ field: 'defaultGranularity', value: 'turn' }]) - ctx.dispose() - }) - - test('the component renders the card DOM (loading status → disabled selects)', async () => { - const { ctx, component } = setup() - const el = component({}) as ReactElement - assert.equal(typeof el.type, 'function') - assert.equal((el.type as { name: string }).name, 'SettingsCard') - const store = (ctx.slots.of('settings.plugin.item')[0].registration.inject?.() as { - hooks: { contextSettings: { getSnapshot(): SettingsState } } - }).hooks.contextSettings - const m = await mount(h(el.type as never, { - useContextSettings: (sel: (state: SettingsState) => T): T => sel(store.getSnapshot()), - })) - assert.ok(query(m.container, '.lc-settings-card')) - await click(query(m.container, '.lc-settings-head')) - const selects = queryAll(m.container, '.lc-settings-select') - assert.equal(selects.length, 8) - for (const s of selects) assert.ok((s as HTMLButtonElement).disabled) - await m.unmount() - ctx.dispose() - }) -}) - describe('client entry: placement gating', () => { - /** A settings scope whose snapshot the test drives (the emit path). */ - function scopeWith(snapshot: { status: string; value: unknown; writable: boolean }): SettingsScopeLike & { - emit(next: { status: string; value: unknown; writable: boolean }): void - } { - let current = snapshot - const listeners = new Set<() => void>() - return { - getSnapshot: () => current, - subscribe(listener) { - listeners.add(listener) - return () => { listeners.delete(listener) } - }, - set: () => Promise.resolve(), - emit(next) { - current = next - for (const listener of listeners) listener() - }, - } - } - /** The sidebar registry stand-in counting registrations and disposer calls. */ function registry(): { definitions: unknown[]; disposed: number; register(d: unknown): () => void } { const rec = { @@ -504,7 +414,7 @@ describe('client entry: placement gating', () => { test("the persisted 'sidebar' placement skips the conversation tab and keeps the sidebar", () => { const scope = scopeWith({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true }) - const ctx = new TestClientCtx({ services: { settingsScope: { bind: () => scope } } }) + const ctx = new TestClientCtx({ services: { configForms: formsServing(scope) } }) applyTo(ctx) assert.deepEqual(ctx.slots.of('conversation.view'), [], 'the dropped tab never registered') const tabs = registry() @@ -518,7 +428,7 @@ describe('client entry: placement gating', () => { test('a preference flip moves the registrations live; disposal unwinds everything', () => { const scope = scopeWith({ status: 'ready', value: {}, writable: true }) - const ctx = new TestClientCtx({ services: { settingsScope: { bind: () => scope } } }) + const ctx = new TestClientCtx({ services: { configForms: formsServing(scope) } }) applyTo(ctx) const tabs = registry() ctx.setService('sidebarRightTabs', tabs) diff --git a/tests/client/primitives.spec.ts b/tests/client/primitives.spec.ts index be27926..caac9e1 100644 --- a/tests/client/primitives.spec.ts +++ b/tests/client/primitives.spec.ts @@ -1,45 +1,39 @@ -// The generation-proof icon resolver (src/client/primitives.ts): each ui-primitives -// icon resolves across the range's two naming vocabularies — modern first, legacy -// fallback — and a name missing from BOTH (a future rename) degrades to a -// render-nothing component instead of the React #130 element-type crash. +// The defensive icon resolver (src/client/primitives.ts): each ui-primitives +// icon resolves by its export name; a name missing from the module (a future +// rename) or a namespace that throws on the read degrades to a render-nothing +// component instead of the React #130 element-type crash. import assert from 'node:assert/strict' import { describe, test } from 'vitest' import { resolveIcon, IconBranch, IconPlus, IconCheck, IconCopy, IconClose, IconSettings, IconChevronDown } from '../../src/client/primitives' describe('resolveIcon', () => { - const modern = function ModernIcon(): null { return null } - const legacy = function LegacyIcon(): null { return null } + const icon = function SomeIcon(): null { return null } - test('the modern spelling wins when both generations export the icon', () => { - assert.equal(resolveIcon('modern', 'legacy', { modern, legacy }), modern) - }) - - test('the legacy spelling backs the modern one up (through-0.1.5 generations)', () => { - assert.equal(resolveIcon('modern', 'legacy', { legacy }), legacy) + test('a present export resolves to the component', () => { + assert.equal(resolveIcon('icon', { icon }), icon) }) test('a name present but not a component renders nothing', () => { - const icon = resolveIcon('modern', 'legacy', { modern: 42, legacy: null }) - assert.equal(icon({}), null, 'the fallback component renders null') + const resolved = resolveIcon('icon', { icon: 42 }) + assert.equal(resolved({}), null, 'the fallback component renders null') }) - test('a name missing from both spellings renders nothing (future rename)', () => { - const icon = resolveIcon('IconGhostOutlineRegular', 'IconGhostOutline16', {}) - assert.equal(icon({}), null) + test('a missing name renders nothing (a future rename)', () => { + const resolved = resolveIcon('IconGhostOutlineRegular', {}) + assert.equal(resolved({}), null) }) test('a namespace that throws on property access degrades to the fallback', () => { // Partial module mocks and hostile namespaces throw on the READ itself. const hostile = new Proxy({}, { get() { throw new Error('hostile export read') } }) - const icon = resolveIcon('IconBranchOutlineRegular', 'IconBranchOutline16', hostile) - assert.equal(icon({}), null) + const resolved = resolveIcon('IconBranchOutlineRegular', hostile) + assert.equal(resolved({}), null) }) test('the module-level exports resolve against the pinned generation', () => { - // The devDep carries the legacy vocabulary, so these exercised the - // legacy branch at module load; on a modern harness the same seven - // exports resolve through the modern names instead. + // The devDep carries the supported vocabulary, so these resolved at + // module load. for (const icon of [IconBranch, IconPlus, IconCheck, IconCopy, IconClose, IconSettings, IconChevronDown]) { assert.equal(typeof icon, 'function', 'every seam resolves to a component') } diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index 223b8da..e2018d6 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -278,9 +278,9 @@ describe('timelineOf', () => { }) test('the unsupported gate record survives the sanitizing slow path only when well-formed', () => { - const kept = timelineOf({ current: 1, unsupported: { current: '0.1.1-rc.2', minimum: '0.1.5-rc.1' } }) + const kept = timelineOf({ current: 1, unsupported: { current: '0.1.1-rc.2', minimum: '0.1.7-rc.2' } }) assert.ok(kept !== null) - assert.deepEqual(kept.unsupported, { current: '0.1.1-rc.2', minimum: '0.1.5-rc.1' }) + assert.deepEqual(kept.unsupported, { current: '0.1.1-rc.2', minimum: '0.1.7-rc.2' }) for (const bad of ['x', null, {}, { current: 1, minimum: 'm' }, { current: 'c' }]) { const out = timelineOf({ current: 1, unsupported: bad }) assert.ok(out !== null) @@ -354,7 +354,7 @@ describe('timelineOf', () => { describe('unsupportedOf', () => { test('well-formed records pass through as plain data', () => { - assert.deepEqual(unsupportedOf({ current: '0.1.1-rc.2', minimum: '0.1.5-rc.1' }), { current: '0.1.1-rc.2', minimum: '0.1.5-rc.1' }) + assert.deepEqual(unsupportedOf({ current: '0.1.1-rc.2', minimum: '0.1.7-rc.2' }), { current: '0.1.1-rc.2', minimum: '0.1.7-rc.2' }) }) test('non-records and wrong-typed fields degrade to null', () => { @@ -854,8 +854,8 @@ describe('openSessionVia', () => { const ctxWith = (services: Record): ClientCtx => ({ get: (name: string) => services[name] }) as unknown as ClientCtx test('jumps through the view owner — every supported line\'s own verb (issue #90)', () => { - // Exactly the rc.2 composition: uiWorkspace serves the navigation verb, - // the sessions service carries no selection at all. + // Exactly the supported composition: uiWorkspace serves the navigation + // verb, the sessions service carries no selection at all. const opened: string[] = [] openSessionVia(ctxWith({ uiWorkspace: { openSession(id: string) { opened.push(id) } }, @@ -864,21 +864,6 @@ describe('openSessionVia', () => { assert.deepEqual(opened, ['s2']) }) - test('the retired sessions-service verb still serves as the degradation path', () => { - const opened: string[] = [] - openSessionVia(ctxWith({ sessions: { open: (id: string) => { opened.push(id) } } }), 's1') - assert.deepEqual(opened, ['s1']) - }) - - test('when both faces serve a verb, the view owner wins', () => { - const via: string[] = [] - openSessionVia(ctxWith({ - uiWorkspace: { openSession: (id: string) => { via.push(`workspace:${id}`) } }, - sessions: { open: (id: string) => { via.push(`sessions:${id}`) } }, - }), 's3') - assert.deepEqual(via, ['workspace:s3']) - }) - test('the verb is invoked bound to its service instance', () => { let seen: unknown const face = { @@ -889,17 +874,14 @@ describe('openSessionVia', () => { assert.equal(seen, 's4:s4') }) - test('a generation serving neither verb, or a verb-less face, swallows silently', () => { + test('a composition without the workspace module, or a verb-less face, swallows silently', () => { openSessionVia(ctxWith({}), 's5') - openSessionVia(ctxWith({ uiWorkspace: {}, sessions: null }), 's5') + openSessionVia(ctxWith({ uiWorkspace: {} }), 's5') openSessionVia(ctxWith({ uiWorkspace: { openSession: 7 } }), 's5') - openSessionVia(ctxWith({ sessions: {} }), 's5') - openSessionVia(ctxWith({ sessions: { open: 7 } }), 's5') }) test('a hostile face never throws into the click handler', () => { openSessionVia(ctxWith({ uiWorkspace: { openSession: () => { throw new Error('boom') } } }), 's6') - openSessionVia(ctxWith({ sessions: { open: () => { throw new Error('boom') } } }), 's6') // A traced proxy can throw on the property READ itself (issue #42). openSessionVia(ctxWith({ uiWorkspace: { get openSession() { throw new Error('boom') } } }), 's6') openSessionVia({ get: () => { throw new Error('boom') } } as unknown as ClientCtx, 's6') diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts index afc81e5..8a36ffa 100644 --- a/tests/client/settings.spec.ts +++ b/tests/client/settings.spec.ts @@ -1,13 +1,13 @@ // Settings binding (src/client/settings.ts): defaults, the observable store, -// scope attach/sync, preference parsing, and the local-echo set path. +// form attach/sync, preference parsing, and the local-echo set path. import assert from 'node:assert/strict' import { describe, test } from 'vitest' import { createContextSettings, type SettingsScopeLike } from '../../src/client/settings' /** - * A faithful in-memory settings scope (the harness settingsScope.bind - * contract: getSnapshot/subscribe/set), not a mock of plugin code. + * A faithful in-memory settings form (the harness configForms.get contract: + * getSnapshot/subscribe/set), not a mock of plugin code. */ class TestSettingsScope implements SettingsScopeLike { private snapshot: { status: string; value: unknown; writable: boolean } diff --git a/tests/client/settingsJump.spec.ts b/tests/client/settingsJump.spec.ts index 738d199..22306f1 100644 --- a/tests/client/settingsJump.spec.ts +++ b/tests/client/settingsJump.spec.ts @@ -1,12 +1,12 @@ // openPluginSettings (src/client/settingsJump.ts): the guarded DOM jump to -// the plugin's settings page — settings trigger first, Plugins section nav -// row second — plus the hostile degradations (missing chrome, throwing -// elements, already-open panel) and the expand request the surviving jump -// leaves for the plugin's settings card. jsdom supplies the real document. +// the plugin's preferences on the Plugins main panel — panel entry first, +// the bundle card's open control after the page settles — plus the hostile +// degradations (missing chrome, throwing elements, a card that never lists). +// jsdom supplies the real document. import assert from 'node:assert/strict' import { afterEach, describe, test } from 'vitest' -import { consumeCardExpand, openPluginSettings, requestCardExpand } from '../../src/client/settingsJump' +import { openPluginSettings } from '../../src/client/settingsJump' /** Immediate scheduler: captures the deferred runs in issue order. */ function syncSchedule(): { runs: Array<() => void>; schedule: (run: () => void, ms: number) => void } { @@ -14,7 +14,7 @@ function syncSchedule(): { runs: Array<() => void>; schedule: (run: () => void, return { runs, schedule: (run) => { runs.push(run) } } } -/** A sidebar-style settings trigger (dialog semantics + expanded flag). */ +/** A sidebar-style settings trigger (dialog semantics + expanded flag) — a distractor the jump must ignore. */ function trigger(doc: Document, expanded: 'true' | 'false'): HTMLButtonElement { const b = doc.createElement('button') b.setAttribute('aria-haspopup', 'dialog') @@ -30,153 +30,9 @@ function clicks(el: HTMLElement): { count: () => number } { return { count: () => n } } -describe('openPluginSettings', () => { +describe('openPluginSettings (the Plugins main panel)', () => { afterEach(() => { document.body.textContent = '' - consumeCardExpand() - }) - - test('clicks the collapsed trigger, then the Plugins section row', () => { - const t = trigger(document, 'false') - const tClicks = clicks(t) - const section = document.createElement('button') - section.textContent = '插件' - document.body.appendChild(section) - const sClicks = clicks(section) - const { runs, schedule } = syncSchedule() - - openPluginSettings(document, schedule) - - assert.equal(tClicks.count(), 1, 'the collapsed trigger is clicked immediately') - assert.equal(sClicks.count(), 0, 'the section row waits for the panel to open') - assert.equal(runs.length, 1) - runs[0]() - assert.equal(sClicks.count(), 1, 'the Plugins nav row is clicked once the panel opens') - }) - - test('the en-labeled section row matches too', () => { - trigger(document, 'false') - const section = document.createElement('button') - section.textContent = 'Plugins' - document.body.appendChild(section) - const sClicks = clicks(section) - const { runs, schedule } = syncSchedule() - - openPluginSettings(document, schedule) - runs[0]() - - assert.equal(sClicks.count(), 1) - }) - - test('the default schedule defers through window.setTimeout', async () => { - trigger(document, 'false') - const section = document.createElement('button') - section.textContent = '插件' - document.body.appendChild(section) - const sClicks = clicks(section) - - openPluginSettings(document) - await new Promise(resolve => { setTimeout(resolve, 120) }) - - assert.equal(sClicks.count(), 1) - }) - - test('an already-open panel skips the trigger but still selects the section', () => { - const t = trigger(document, 'true') - const tClicks = clicks(t) - const section = document.createElement('button') - section.textContent = '插件' - document.body.appendChild(section) - const sClicks = clicks(section) - const { runs, schedule } = syncSchedule() - - openPluginSettings(document, schedule) - runs[0]() - - assert.equal(tClicks.count(), 0, 'no re-click on an open panel') - assert.equal(sClicks.count(), 1) - }) - - test('a chrome with no settings trigger degrades to a silent no-op', () => { - const { runs, schedule } = syncSchedule() - assert.doesNotThrow(() => openPluginSettings(document, schedule)) - assert.equal(runs.length, 0, 'no deferred section selection without a trigger') - }) - - test('elements that throw on click never surface the error', () => { - const t = trigger(document, 'false') - t.click = () => { throw new Error('detached') } - const { runs, schedule } = syncSchedule() - assert.doesNotThrow(() => openPluginSettings(document, schedule)) - // The trigger's failure aborts this jump entirely — the guarded path. - assert.equal(runs.length, 0) - }) - - test('a throwing section row is contained by its own guard', () => { - trigger(document, 'false') - const section = document.createElement('button') - section.textContent = '插件' - section.click = () => { throw new Error('detached') } - document.body.appendChild(section) - const { runs, schedule } = syncSchedule() - - assert.doesNotThrow(() => openPluginSettings(document, schedule)) - assert.doesNotThrow(() => runs[0]()) - }) - - test('lookalike chrome is ignored: a menu trigger is not the settings dialog', () => { - const menu = document.createElement('button') - menu.setAttribute('aria-haspopup', 'menu') - menu.setAttribute('aria-expanded', 'false') - document.body.appendChild(menu) - const menuClicks = clicks(menu) - const { runs, schedule } = syncSchedule() - - openPluginSettings(document, schedule) - - assert.equal(menuClicks.count(), 0, 'only dialog triggers are candidates') - assert.equal(runs.length, 0) - }) - - test('a surviving jump leaves a fresh expand request for the settings card', () => { - trigger(document, 'false') - const section = document.createElement('button') - section.textContent = '插件' - document.body.appendChild(section) - const { schedule } = syncSchedule() - - openPluginSettings(document, schedule) - - assert.equal(consumeCardExpand(), true, 'the card mounts expanded on this jump') - assert.equal(consumeCardExpand(), false, 'the request is consumed once') - }) - - test('degraded jumps leave no expand request behind', () => { - const { runs, schedule } = syncSchedule() - openPluginSettings(document, schedule) - assert.equal(consumeCardExpand(), false, 'no settings dialog in this chrome: no request') - - const t = trigger(document, 'false') - t.click = () => { throw new Error('detached') } - assert.doesNotThrow(() => openPluginSettings(document, schedule)) - assert.equal(consumeCardExpand(), false, 'a failed trigger click leaves no stale request') - assert.equal(runs.length, 0) - }) - - test('the expand request is freshness-windowed (5s) and single-shot', () => { - requestCardExpand(1000) - assert.equal(consumeCardExpand(1000 + 4999), true, 'fresh within the window') - assert.equal(consumeCardExpand(1000 + 4999), false, 'already consumed') - - requestCardExpand(1000) - assert.equal(consumeCardExpand(1000 + 5000), false, 'stale past the window') - }) -}) - -describe('openPluginSettings: the Config-form generation (Plugins main panel)', () => { - afterEach(() => { - document.body.textContent = '' - consumeCardExpand() }) /** A sidebar-style Plugins panel entry (shipped aria-label). */ @@ -213,7 +69,7 @@ describe('openPluginSettings: the Config-form generation (Plugins main panel)', openPluginSettings(document, schedule) assert.equal(pClicks.count(), 1, 'the panel entry is clicked immediately') - assert.equal(tClicks.count(), 0, 'no settings-dialog legs') + assert.equal(tClicks.count(), 0, 'the settings-dialog chrome is never touched') assert.equal(runs.length, 1, 'the card poll is scheduled') // The list has not rendered the card yet: the poll keeps ticking. runs[0]() @@ -226,7 +82,27 @@ describe('openPluginSettings: the Config-form generation (Plugins main panel)', assert.equal(titleClicks.count(), 1, 'the title control opens the bundle page') assert.equal(switchClicks.count(), 0, 'the enable switch is never touched') assert.equal(runs.length, 2, 'the poll stops on success') - assert.equal(consumeCardExpand(), false, 'no expand request: this seat has no disclosure') + }) + + test('the en-labeled panel entry matches too', () => { + const panel = panelEntry(document, 'Plugins') + const pClicks = clicks(panel) + const { runs, schedule } = syncSchedule() + + openPluginSettings(document, schedule) + + assert.equal(pClicks.count(), 1) + assert.equal(runs.length, 1) + }) + + test('the default schedule defers through window.setTimeout', async () => { + const panel = panelEntry(document, '插件') + const pClicks = clicks(panel) + + openPluginSettings(document) + await new Promise(resolve => { setTimeout(resolve, 20) }) + + assert.equal(pClicks.count(), 1) }) test('the poll keeps ticking to its deadline and stops silently without a card', () => { @@ -242,6 +118,22 @@ describe('openPluginSettings: the Config-form generation (Plugins main panel)', assert.equal(runs.length, 0, 'the poll exhausted its ticks and stopped') }) + test('a chrome without the Plugins panel entry degrades to a silent no-op', () => { + trigger(document, 'false') + const { runs, schedule } = syncSchedule() + assert.doesNotThrow(() => openPluginSettings(document, schedule)) + assert.equal(runs.length, 0, 'no card poll without the panel entry') + }) + + test('a panel entry that throws on click never surfaces the error', () => { + const panel = panelEntry(document, '插件') + panel.click = () => { throw new Error('detached') } + const { runs, schedule } = syncSchedule() + assert.doesNotThrow(() => openPluginSettings(document, schedule)) + // The entry's failure aborts this jump entirely — the guarded path. + assert.equal(runs.length, 0) + }) + test('a hostile card (no title control, or one that throws) never surfaces an error', () => { panelEntry(document, '插件') const { runs, schedule } = syncSchedule() @@ -268,6 +160,6 @@ describe('openPluginSettings: the Config-form generation (Plugins main panel)', openPluginSettings(document, schedule) assert.equal(lClicks.count(), 0) - assert.equal(runs.length, 0, 'no dialog trigger either: silent no-op') + assert.equal(runs.length, 0, 'no panel entry: silent no-op') }) }) diff --git a/tests/compat/bundle.spec.ts b/tests/compat/bundle.spec.ts index 742fca0..cec89b7 100644 --- a/tests/compat/bundle.spec.ts +++ b/tests/compat/bundle.spec.ts @@ -83,7 +83,7 @@ describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/clie inject(_deps: string[], cb: (c: unknown) => void) { // Cordis fires the callback once every requested service exists; this // fake answers every name it knows up front, so it fires immediately. - // The settingsScope wiring guards on the absent binder itself. + // The configForms wiring guards on the absent service itself. cb(ctx) }, effect(fn: () => (() => void) | void) { diff --git a/tests/compat/matrix.spec.ts b/tests/compat/matrix.spec.ts index 8f989b0..4f86ca5 100644 --- a/tests/compat/matrix.spec.ts +++ b/tests/compat/matrix.spec.ts @@ -105,31 +105,23 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base assert.equal(report.poisonedSnapshotThrows, true) }) - test('settings: the tag\'s namespace surface matches the plugin\'s feature-detected faces', () => { - const settings = baseline.settings - // The register face the host half calls (and feature-detects): present - // through V3, gone on the V4+ Config-form generations. + test('settings: the retired register face stays gone (the plugin never calls it)', () => { + // The Config-form generation derives forms from each loader entry's own + // Config schema; `settings.register` retired with the older lines. The + // probe pins the absence, so a resurrection names the seam. assert.equal( - staging.dshHasString(baseline.tag, 'register(', settings.serviceFile), - settings.register, + staging.dshHasString(baseline.tag, 'register(', baseline.settings.serviceFile), + baseline.settings.register, 'the settings service register face', ) - // Where the generation enforces a namespace pattern it must accept the - // plugin literal; where it does not (the pattern source is gone), the - // register probe above already pins the plugin's inert path. - if (settings.patternFile === undefined) return - const pattern = staging.namespacePatternOf(baseline, settings.patternFile) - assert.ok(pattern !== null, 'the tag source carries NAMESPACE_PATTERN') - assert.equal(pattern.test('dsh-context'), true) }) test('balance: the settings face and the DeepSeek provider row the facts fold reads', () => { - // The balance route resolves the provider's settings row through the - // face this generation serves — the `get(ns)` section read through V3, - // the Config-form `describe()` projection on V4+ (the row matched by - // its own apiKeyEnv-bearing section shape). Presence asserted both - // ways, so a moved seam names itself instead of silently hiding the - // capsule. + // The balance route resolves the provider's settings row off the + // Config-form `describe()` projection (the row matched by its own + // apiKeyEnv-bearing section shape); the retired `get(ns)` section read + // is asserted absent, so a moved seam names itself instead of silently + // hiding the capsule. const balance = baseline.balance assert.equal( staging.dshHasString(baseline.tag, balance.settingsGetNeedle, balance.settingsFile), @@ -158,11 +150,11 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base test('client: the preferences card seat and transport match the generation', () => { // The keyed slot the browser half's card registers on and the settings - // transport it rides: `settings.plugin.item` + `settingsScope` through - // V3, the Plugins page's `plugins.bundle.config` + `configForms` from - // the Config-form generation on. Asserted BOTH ways (like the sidebar - // seam) so a moved seam cannot read as "unsupported here" — a slot the - // generation does not declare simply never receives the registration. + // transport it rides: the Plugins page's `plugins.bundle.config` + + // `configForms` on every supported line. Asserted BOTH ways (like the + // sidebar seam) so a moved seam cannot read as "unsupported here" — a + // slot the generation does not declare simply never receives the + // registration. const settings = baseline.settings assert.equal( settings.cardSlotFiles.some(pattern => @@ -223,7 +215,7 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base test('client: the session-jump seam of this generation (issue #90)', () => { // The view owner's navigation verb (the sidebar row click's own), with // the signature this line spells; and the retired sessions-service - // `open(id)` selection verb, declared exactly where the line still has it. + // `open(id)` selection verb, probed staying gone. const nav = baseline.client.sessionNav assert.equal( staging.dshHasString(baseline.tag, nav.workspaceNeedle, nav.workspaceFile), @@ -248,10 +240,9 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base }) test('client: the icon seams resolve on this generation (primitives.ts)', () => { - for (const [modern, legacy] of staging.ICON_SEAMS) { - const present = staging.dshHasString(baseline.tag, modern, 'packages/client/ui-primitives/src/**') - || staging.dshHasString(baseline.tag, legacy, 'packages/client/ui-primitives/src/**') - assert.equal(present, true, `icon seam: ${modern} | ${legacy}`) + for (const name of staging.ICON_SEAMS) { + const present = staging.dshHasString(baseline.tag, name, 'packages/client/ui-primitives/src/**') + assert.equal(present, true, `icon seam: ${name}`) } }) diff --git a/tests/compat/staging.ts b/tests/compat/staging.ts index 9cf3d63..d2857f9 100644 --- a/tests/compat/staging.ts +++ b/tests/compat/staging.ts @@ -85,13 +85,6 @@ export function ensureHostDeps(baseline: Baseline): void { if (run.status !== 0) throw new Error(`npm install ${JSON.stringify(deps)} failed: ${(run.stderr ?? '').trim().slice(0, 300)}`) } -/** The tag's settings namespace pattern, read off the file the baseline names (null when absent). */ -export function namespacePatternOf(baseline: Baseline, file: string): RegExp | null { - const source = dshShow(baseline.tag, file) - const match = /^const NAMESPACE_PATTERN = \/(.+)\/([gimsuy]*)$/m.exec(source) - return match === null ? null : new RegExp(match[1], match[2]) -} - export interface DriverReport { registered: boolean keys: string[] @@ -305,9 +298,8 @@ export function bundleRequires(): string[] { * The slot registrations the client half mounts on EVERY baseline (identical * spellings in each generation's sources). The preferences card seat and the * settings transport are asserted separately, per generation — the matrix - * spec pins them to the baseline's `settings` seam (`settings.plugin.item` + - * `settingsScope` through V3, the Plugins page's `plugins.bundle.config` + - * `configForms` from the Config-form generation on). + * spec pins them to the baseline's `settings` seam (`plugins.bundle.config` + + * `configForms` on every supported line). */ export const SLOT_SEAMS = [ 'conversation.view', @@ -316,29 +308,30 @@ export const SLOT_SEAMS = [ ] as const /** - * The ui-primitives icon seams the client bundle renders, as [modern, legacy] - * name pairs: the vocabulary renamed across the range (`…Outline16`/`…14` - * through 0.1.5, `…OutlineRegular`/`…OutlineMedium` from 0.1.6), and a - * missing name is `undefined` at the browser's runtime — React error #130. - * `src/client/primitives.ts` resolves whichever spelling the running - * generation serves; each pair must exist in at least one spelling per tag. + * The ui-primitives icon seams the client bundle renders: every supported + * line ships the `…OutlineRegular`/`…OutlineMedium` vocabulary, and a missing + * name is `undefined` at the browser's runtime — React error #130. + * `src/client/primitives.ts` resolves each by name; every one must exist per + * tag. */ export const ICON_SEAMS = [ - ['IconBranchOutlineRegular', 'IconBranchOutline16'], - ['IconPlusOutlineRegular', 'IconPlusOutline16'], - ['IconCheckOutlineRegular', 'IconCheckOutline16'], - ['IconCopyOutlineRegular', 'IconCopyOutline16'], - ['IconCloseOutlineRegular', 'IconCloseOutline16'], - ['IconSettingsOutlineMedium', 'IconSettingsOutline14'], - ['IconChevronDownOutlineMedium', 'IconChevronDownOutline14'], + 'IconBranchOutlineRegular', + 'IconPlusOutlineRegular', + 'IconCheckOutlineRegular', + 'IconCopyOutlineRegular', + 'IconCloseOutlineRegular', + 'IconSettingsOutlineMedium', + 'IconChevronDownOutlineMedium', + 'IconChevronUpOutlineMedium', + 'IconSearchOutlineRegular', ] as const /** * The event families the host fold switches on (src/host/fold.ts) — the UNION - * over every supported generation (developer/message is V4-only). The - * per-baseline probe asserts the baseline's own `foldEventTypes` subset, and - * a matrix test asserts this union equals the baselines' union — a fold case - * added without a baseline list fails loudly instead of going unprobed. + * over every supported generation. The per-baseline probe asserts the + * baseline's own `foldEventTypes` subset, and a matrix test asserts this + * union equals the baselines' union — a fold case added without a baseline + * list fails loudly instead of going unprobed. */ export const FOLD_EVENT_TYPES = [ 'request/header', 'request/context', 'step/start', 'step/end', diff --git a/tests/host/backfill.spec.ts b/tests/host/backfill.spec.ts index 6beb34e..d14aac4 100644 --- a/tests/host/backfill.spec.ts +++ b/tests/host/backfill.spec.ts @@ -284,26 +284,21 @@ describe('watchActivityBackfill', () => { assert.deepEqual(state.coldSnapshots, []) }) - test('the 0.1.7 face answers when the 3-arg spelling throws (the rc.2 cachedSnapshot signature)', async () => { - // dsh 0.1.7-rc.2 dropped the probe's offset parameter: a 3-arg call binds - // the branded offset onto `keys`, where `new Set(0)` throws as soon as a - // served record is reached. The probe must fall through to the newer - // 2-arg face instead of reading every served session as "not served". + test('the cached-rows probe rides the 2-arg face (header + keys)', async () => { + // The projection cache's `cachedSnapshot(meta, keys?)` — the lifecycle + // identity rides the header alone on every supported line. const ctx = new Context() const state = fakeState([{ header: { id: 'a', cwd: '/repo/a' } }]) - const arities: number[] = [] + const calls: number[] = [] const route: RouteBox = {} ctx.provide('connection', connectionOf(route)) ctx.provide('sessionQuery', { - listSessions: async () => { - arities.push(0) - return state.listed - }, + listSessions: async () => state.listed, }) ctx.provide('sessionProjectionCache', { - cachedSnapshot: (header: { id: string }, offsetOrKeys: unknown, keys?: unknown) => { - arities.push(keys === undefined ? 2 : 3) - if (keys !== undefined) throw new TypeError('offset is not iterable') + cachedSnapshot: (header: { id: string }, keys?: unknown) => { + calls.push(1) + assert.ok(Array.isArray(keys), 'the probe passes the keys as the second argument') return header.id === 'a' ? { asOfSeq: 0, values: { contextActivity: { days: {} }, contextTimeline: { ok: true } } } : undefined @@ -317,12 +312,11 @@ describe('watchActivityBackfill', () => { ctx.provide('sessions', { get: () => undefined }) const dispose = watchActivityBackfill(ctx) await trigger(route) - await until(() => (arities.length > 0 ? true : undefined), 'the corpus was queried') + await until(() => (calls.length > 0 ? true : undefined), 'the corpus was queried') await new Promise(resolve => setTimeout(resolve, 40)) dispose() - assert.deepEqual(state.coldReads, [], 'the newer face served the rows — no cold read') + assert.deepEqual(state.coldReads, [], 'the served rows skip the cold read') assert.deepEqual(state.coldSnapshots, []) - assert.deepEqual([...new Set(arities)].sort(), [0, 2, 3], 'the 3-arg spelling was tried first, the 2-arg face answered') }) test('a rejection landing AFTER abort skips the warn (the unload owns the silence)', async () => { diff --git a/tests/host/balance.spec.ts b/tests/host/balance.spec.ts index ea5eeef..7b1a898 100644 --- a/tests/host/balance.spec.ts +++ b/tests/host/balance.spec.ts @@ -64,12 +64,7 @@ function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch? return { ctx: ctx as unknown as Context, captured, disposers } } -/** The harness settings face serving one namespace's resolved section. */ -function settingsOf(section: unknown): { get(ns: string): unknown } { - return { get: (ns: string) => (ns === 'llm-deepseek' ? section : undefined) } -} - -/** The V4+ settings face projecting one row per configurable entry (`throws` fails the read). */ +/** The harness settings face projecting one row per configurable entry (`throws` fails the read). */ function settingsDescribeOf(rows: unknown, throws = false): { describe(): unknown } { return { describe() { @@ -79,6 +74,11 @@ function settingsDescribeOf(rows: unknown, throws = false): { describe(): unknow } } +/** The settings face serving one provider section, as the Config-form projection's `llm-deepseek` row. */ +function settingsOf(section: unknown): { describe(): unknown } { + return settingsDescribeOf([{ ns: 'llm-deepseek', value: section }]) +} + /** The harness credentials face serving one resolved value. */ function credentialsOf(value: unknown, calls?: string[]): { resolve(ref: string): Promise<{ value: unknown } | undefined> } { return { @@ -95,11 +95,11 @@ const CNY_BALANCE = { balance_infos: [{ currency: 'CNY', total_balance: '110.00', granted_balance: '10.00', topped_up_balance: '100.00' }], } -/** The all-configured ctx spec: a default section, a resolvable key. */ +/** The all-configured ctx spec: the provider row (its served value always declares the credential ref), a resolvable key. */ function configuredCtx(spec: CtxSpec = {}): CtxSpec { return { connection: { fetch: { register: () => () => {} } }, - settings: settingsOf({}), + settings: settingsOf({ apiKeyEnv: 'DEEPSEEK_API_KEY' }), credentials: credentialsOf('sk-test'), ...spec, } @@ -180,7 +180,7 @@ describe('balance route outcomes', () => { const refs: string[] = [] const { ctx, captured } = ctxOf({ connection: { fetch: { register: () => () => {} } }, - settings: settingsOf({}), + settings: settingsOf({ apiKeyEnv: 'DEEPSEEK_API_KEY' }), credentials: credentialsOf('sk-test', refs), }) watchBalanceChannel(ctx) @@ -230,7 +230,7 @@ describe('balance route outcomes', () => { const absent: CtxSpec[] = [ configuredCtx({ settings: undefined }), configuredCtx({ settings: {} }), - configuredCtx({ settings: { get: 'nope' } }), + configuredCtx({ settings: { describe: 'nope' } }), configuredCtx({ settings: settingsOf(undefined) }), configuredCtx({ settings: settingsOf('not a section') }), configuredCtx({ credentials: undefined }), @@ -292,8 +292,8 @@ describe('balance route outcomes', () => { }) }) -describe('settings faces across generations', () => { - test('the V4+ describe face serves the facts by the provider row\'s own shape', async () => { +describe('settings face (the Config-form projection)', () => { + test('the describe face serves the facts by the provider row\'s own shape', async () => { const refs: string[] = [] const { ctx, captured } = ctxOf({ connection: { fetch: { register: () => () => {} } }, @@ -345,38 +345,6 @@ describe('settings faces across generations', () => { assert.deepEqual(refs, ['PRODUCT_KEY']) }) - test('a V3 section that reads absent falls through to the describe face', async () => { - const refs: string[] = [] - const { ctx, captured } = ctxOf({ - connection: { fetch: { register: () => () => {} } }, - settings: { - get: () => undefined, - describe: () => [{ ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'FALLBACK_KEY' } }], - }, - credentials: credentialsOf('sk-fallback', refs), - }) - watchBalanceChannel(ctx) - stubPlatform(CNY_BALANCE) - await serve(captured) - assert.deepEqual(refs, ['FALLBACK_KEY']) - }) - - test('the V3 section wins when both faces are served', async () => { - const refs: string[] = [] - const { ctx, captured } = ctxOf({ - connection: { fetch: { register: () => () => {} } }, - settings: { - get: (ns: string) => (ns === 'llm-deepseek' ? { apiKeyEnv: 'V3_KEY' } : undefined), - describe: () => [{ ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'V4_KEY' } }], - }, - credentials: credentialsOf('sk-both', refs), - }) - watchBalanceChannel(ctx) - stubPlatform(CNY_BALANCE) - await serve(captured) - assert.deepEqual(refs, ['V3_KEY']) - }) - test('every broken describe shape serves a typed null without touching the platform', async () => { const faces: unknown[] = [ settingsDescribeOf('not an array'), diff --git a/tests/host/compat/registry.spec.ts b/tests/host/compat/registry.spec.ts index 9e55404..eb6b153 100644 --- a/tests/host/compat/registry.spec.ts +++ b/tests/host/compat/registry.spec.ts @@ -296,16 +296,3 @@ for (const [index, baseline] of BASELINES.entries()) { }) }) } - -describe('registry contract — settings namespace seam', () => { - // The settings module enforces `/^[a-z][a-z0-9-]*$/` on registered - // namespaces (inside `settings.register` on the 0.1.5 line). The plugin - // registers the raw literal (branded cast; on the Config-form generations - // the namespace is the entry id), so the literal must pass the pattern. - const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ - const SETTINGS_NAMESPACE = 'dsh-context' - - test('the plugin namespace passes the enforcement pattern of every baseline', () => { - assert.equal(NAMESPACE_PATTERN.test(SETTINGS_NAMESPACE), true) - }) -}) diff --git a/tests/host/compat/registryDriver.ts b/tests/host/compat/registryDriver.ts index 9c5c05e..612a23e 100644 --- a/tests/host/compat/registryDriver.ts +++ b/tests/host/compat/registryDriver.ts @@ -3,7 +3,6 @@ // source at each baseline tag so the always-on suite pins the plugin's units // against every supported registry generation: // -// dsh-v0.1.5-rc.1 packages/session/session-projection/src/index.ts // dsh-v0.1.7-rc.2 packages/session/session-projection/src/index.ts // dsh-v0.2.0-rc.2 packages/session/session-projection/src/index.ts // diff --git a/tests/host/fixtures/version/homes/baseline/profiles/node_modules/@deepseek-ai/dsh/package.json b/tests/host/fixtures/version/homes/baseline/profiles/node_modules/@deepseek-ai/dsh/package.json index fd851bd..cb23cb4 100644 --- a/tests/host/fixtures/version/homes/baseline/profiles/node_modules/@deepseek-ai/dsh/package.json +++ b/tests/host/fixtures/version/homes/baseline/profiles/node_modules/@deepseek-ai/dsh/package.json @@ -1 +1 @@ -{"name":"@deepseek-ai/dsh","version":"0.1.5-rc.1"} +{"name":"@deepseek-ai/dsh","version":"0.1.7-rc.2"} \ No newline at end of file diff --git a/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session-projection/package.json b/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session-projection/package.json index 4d18320..ab88fee 100644 --- a/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session-projection/package.json +++ b/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session-projection/package.json @@ -1 +1 @@ -{"name":"@deepseek-ai/dsh-session-projection","version":"0.1.5-rc.1"} +{"name":"@deepseek-ai/dsh-session-projection","version":"0.1.7-rc.2"} \ No newline at end of file diff --git a/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session/package.json b/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session/package.json index fda15c0..5b952b9 100644 --- a/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session/package.json +++ b/tests/host/fixtures/version/homes/module-skips-cli/profiles/node_modules/@deepseek-ai/dsh-session/package.json @@ -1 +1 @@ -{"name":"@deepseek-ai/dsh-session","version":"0.1.5-rc.1"} +{"name":"@deepseek-ai/dsh-session","version":"0.1.7-rc.2"} \ No newline at end of file diff --git a/tests/host/fixtures/version/homes/old/profiles/node_modules/@deepseek-ai/dsh/package.json b/tests/host/fixtures/version/homes/old/profiles/node_modules/@deepseek-ai/dsh/package.json index ccf2039..8a28e3c 100644 --- a/tests/host/fixtures/version/homes/old/profiles/node_modules/@deepseek-ai/dsh/package.json +++ b/tests/host/fixtures/version/homes/old/profiles/node_modules/@deepseek-ai/dsh/package.json @@ -1 +1 @@ -{"name":"@deepseek-ai/dsh","version":"0.1.1-rc.2"} +{"name":"@deepseek-ai/dsh","version":"0.1.5-rc.1"} \ No newline at end of file diff --git a/tests/host/fold-v3.spec.ts b/tests/host/fold-dialect.spec.ts similarity index 99% rename from tests/host/fold-v3.spec.ts rename to tests/host/fold-dialect.spec.ts index b8c67d6..6dd6492 100644 --- a/tests/host/fold-v3.spec.ts +++ b/tests/host/fold-dialect.spec.ts @@ -1,4 +1,4 @@ -// The supported log dialect (dsh 0.1.5-rc.1+, session formats V3/V4): +// The supported log dialect (dsh 0.1.7-rc.2+, session format V4): // `system/message` surface nodes, the embedded assistant stream, the // `startSeq`/`endSeq` replacement endpoints, `tool/ptc-dispatch`, and // `assistant/attempt` — each case the seam the fold must read, plus the @@ -43,7 +43,7 @@ describe('system/message — the system prompt as a surface node', () => { assert.equal(view.current.total, price + state.sums.user + state.sums.assistant, 'the prompt joins the composition total') }) - test('a system-less request header never clears log-sourced nodes (V3 headers carry no prompt)', () => { + test('a system-less request header never clears log-sourced nodes (headers carry no prompt)', () => { const prompt = 'prompt '.repeat(10) const { state } = driveTimeline([ { type: 'system/message', seq: 1, time: 1000, data: { message: { content: text(prompt) } }, surfaceOp: 'append' }, diff --git a/tests/host/fold-events.spec.ts b/tests/host/fold-events.spec.ts index d4764b7..37bdf4a 100644 --- a/tests/host/fold-events.spec.ts +++ b/tests/host/fold-events.spec.ts @@ -307,25 +307,27 @@ describe('tool/result skill tagging', () => { assert.equal(state.events.length, 0) }) - test('skill content nested two tool-result levels deep still tags', () => { + test('skill content nested inside a foreign wrapper block never tags', () => { + // The skill wrapper rides the result's own text blocks (first-level); a + // nested foreign block is a hostile shape — unscanned, never a throw. const ev: TimelineEvent = { type: 'tool/result', seq: 1, time: at(), data: { message: { - content: [{ type: 'tool-result', content: [{ type: 'tool-result', content: skillBody('deep') }] }], + content: [{ type: 'foreign-wrapper', content: [{ type: 'text', text: 'x' }] }], }, }, surfaceOp: 'append', } const { state } = driveTimeline([ev]) - assert.equal(state.surface.at(-1)?.skill, 'deep') - assert.equal(state.events[0].name, 'deep') + assert.equal(state.surface.at(-1)?.skill, undefined) + assert.equal(state.events.length, 0) }) - test('nested empty text yields no tag', () => { + test('an empty text block yields no tag', () => { const ev: TimelineEvent = { type: 'tool/result', seq: 1, time: at(), - data: { message: { content: [{ type: 'tool-result', toolCallId: 'e', content: [{ type: 'text', text: '' }] }] } }, + data: { message: { role: 'tool', toolCallId: 'e', content: [{ type: 'text', text: '' }] } }, surfaceOp: 'append', } const { state } = driveTimeline([ev]) diff --git a/tests/host/fold-ops.spec.ts b/tests/host/fold-ops.spec.ts index 6b022ba..b1597d6 100644 --- a/tests/host/fold-ops.spec.ts +++ b/tests/host/fold-ops.spec.ts @@ -84,24 +84,23 @@ describe('the file-op log — call/result pairing', () => { assert.equal(state.callNames.c1, undefined, 'the pending entry consumed at the result') }) - test('the error flag reads the envelope error object, the block isError, or the V4 message isError', () => { + test('the error flag reads the message isError mark', () => { const { state } = driveTimeline([ toolCall(1, { callId: 'c1', name: 'read', arguments: JSON.stringify({ file_path: 'a.ts' }) }), toolResult(2, { callId: 'c1', content: [{ type: 'text', text: 'no' }], error: true }), toolCall(3, { callId: 'c2', name: 'read', arguments: JSON.stringify({ file_path: 'b.ts' }) }), - // A block-level isError (no envelope error object). + // A hostile envelope-only mark (the retired pre-V4 spelling): the + // admission validation forbids `data.error` without the message mark, + // so a row carrying it alone reads as non-error. { type: 'tool/result', seq: 4, time: 4, data: { - callId: 'c2', - message: { content: [{ type: 'tool-result', toolCallId: 'c2', isError: true, content: [] }], source: { kind: 'tool', callId: 'c2' } }, + error: true, + message: { role: 'tool', toolCallId: 'c2', source: { kind: 'tool', callId: 'c2' }, content: [] }, }, } as unknown as TimelineEvent, - toolCall(5, { callId: 'c3', name: 'read', arguments: JSON.stringify({ file_path: 'c.ts' }) }), - // The V4 spelling: the mark lifted onto the message, no envelope error. - toolResult(6, { callId: 'c3', content: [{ type: 'text', text: 'v4' }], error: true, v4: true }), ]) - assert.deepEqual(state.fileOps.map(o => o.err), [true, true, true]) + assert.deepEqual(state.fileOps.map(o => o.err), [true, false]) }) test('a call whose raw arguments are not a string still pairs by name (ops degrade to nothing)', () => { diff --git a/tests/host/fold-surface.spec.ts b/tests/host/fold-surface.spec.ts index 714337e..35feb6a 100644 --- a/tests/host/fold-surface.spec.ts +++ b/tests/host/fold-surface.spec.ts @@ -49,36 +49,18 @@ describe('applySurface tool/result branch', () => { ]) const node = state.surface.at(-1) assert.equal(node?.tool, 'bash') - assert.equal(node?.err, undefined, 'no error flag without data.error') + assert.equal(node?.err, undefined, 'no error flag without the message mark') assert.deepEqual(state.callNames, {}, 'the consumed entry leaves the persisted map') }) - test('an unknown source callId falls through to the content block id', () => { - // Neither id is armed: the node ends up tool-less (a replay window where - // the tool/call event is gone). Note the fold assigns the lookup miss - // onto the node, so this state is not assertPlainJson-able. + test('an unknown source callId pairs nothing (a replay window where the tool/call event is gone)', () => { + // Note the fold assigns the lookup miss onto the node, so this state is + // not assertPlainJson-able. const { state } = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })]) assert.equal(state.surface.at(-1)?.tool, undefined) assert.deepEqual(state.callNames, {}) }) - test('a block toolCallId hit names the node when the source id is unknown', () => { - const ev: TimelineEvent = { - type: 'tool/result', seq: 2, time: at(), - data: { - callId: 'x', - message: { - source: { kind: 'tool', callId: 'x' }, - content: [{ type: 'tool-result', toolCallId: 'c1', content: text('ok') }], - }, - }, - surfaceOp: 'append', - } - const { state } = driveTimeline([toolCall(1, { callId: 'c1', name: 'bash' }), ev]) - assert.equal(state.surface.at(-1)?.tool, 'bash') - assert.deepEqual(state.callNames, {}, 'both the source id and the block id are consumed') - }) - test('consume-once rebuild keeps the other pending calls', () => { const { state } = driveTimeline([ toolCall(1, { callId: 'c1', name: 'bash' }), @@ -88,10 +70,13 @@ describe('applySurface tool/result branch', () => { assert.equal(state.callNames.c2?.name, 'read', 'the unanswered call stays armed') }) - test('a result with neither id leaves callNames untouched', () => { + test('a result without the source callId pairs nothing and leaves callNames untouched', () => { + // A hostile/foreign envelope: the admission validation requires the + // source callId, so a source-less row can only arrive hostile — it folds + // to an unnamed node instead of throwing. const ev: TimelineEvent = { type: 'tool/result', seq: 2, time: at(), - data: { message: { content: [{ type: 'tool-result', content: text('ok') }] } }, + data: { message: { role: 'tool', toolCallId: 'keep', content: text('ok') } }, surfaceOp: 'append', } const { state } = driveTimeline([toolCall(1, { callId: 'keep', name: 'read' }), ev]) @@ -99,22 +84,33 @@ describe('applySurface tool/result branch', () => { assert.equal(state.callNames.keep?.name, 'read') }) - test('data.error flags the node', () => { + test('the message isError flags the node', () => { const { state } = driveTimeline([ toolCall(1, { callId: 'c1', name: 'bash' }), toolResult(2, { callId: 'c1', content: text('boom'), error: true }), ]) - assert.equal(state.surface.at(-1)?.err, true) - }) - - test('the V4 message isError flags the node (no envelope error object)', () => { - const { state } = driveTimeline([ - toolCall(1, { callId: 'c1', name: 'bash' }), - toolResult(2, { callId: 'c1', content: text('boom'), error: true, v4: true }), - ]) const node = state.surface.at(-1) assert.equal(node?.err, true) - assert.equal(node?.tool, 'bash', 'the V4 source callId still pairs the node') + assert.equal(node?.tool, 'bash', 'the source callId still pairs the node') + }) + + test('the retired envelope-only error mark no longer flags the node', () => { + // The pre-V4 spelling (an event-level `data.error` with no message mark) + // is unreachable through the supported harnesses' admission validation; + // a hostile replay carrying it degrades to an unflagged node, never a + // throw. + const ev: TimelineEvent = { + type: 'tool/result', seq: 2, time: at(), + data: { + error: true, + message: { role: 'tool', toolCallId: 'c1', source: { kind: 'tool', callId: 'c1' }, content: text('boom') }, + }, + surfaceOp: 'append', + } + const { state } = driveTimeline([toolCall(1, { callId: 'c1', name: 'bash' }), ev]) + const node = state.surface.at(-1) + assert.equal(node?.err, undefined) + assert.equal(node?.tool, 'bash') }) }) diff --git a/tests/host/fold-timing.spec.ts b/tests/host/fold-timing.spec.ts index 39eeaa0..68acfae 100644 --- a/tests/host/fold-timing.spec.ts +++ b/tests/host/fold-timing.spec.ts @@ -145,26 +145,15 @@ describe('timing — tool call durations', () => { }) }) - test('repeated names accumulate; the block-id fallback prices too', () => { + test('repeated names accumulate into the per-name tally', () => { const call = (seq: number, callId: string, time: number): TimelineEvent => ({ type: 'tool/call', seq, time, data: { callId, name: 'bash', arguments: '{}' }, }) - const blockResult: TimelineEvent = { - type: 'tool/result', seq: 5, time: 9_000, - data: { - callId: 'x', - message: { - source: { kind: 'tool', callId: 'x' }, - content: [{ type: 'tool-result', toolCallId: 'c2', content: text('ok') }], - }, - }, - surfaceOp: 'append', - } const { state } = driveTimeline([ call(1, 'c1', 1_000), toolResult(2, { callId: 'c1', content: text('ok'), time: 3_000 }), call(4, 'c2', 5_000), - blockResult, + toolResult(5, { callId: 'c2', content: text('ok'), time: 9_000 }), ]) assert.equal(state.timing?.toolsMs, 2_000 + 4_000) assert.deepEqual(state.timing?.tools, { bash: { calls: 2, ms: 6_000 } }) diff --git a/tests/host/helpers/events.ts b/tests/host/helpers/events.ts index 83fd7f4..c741712 100644 --- a/tests/host/helpers/events.ts +++ b/tests/host/helpers/events.ts @@ -60,33 +60,26 @@ export function stepEnd(seq: number, opts: { time?: number } = {}): TimelineEven return { type: 'step/end', seq, time: at(opts.time) } } -/** tool/result: the model-visible message rides data.message with the tool source. */ +/** tool/result: the model-visible message rides data.message — the first-class + * tool-role message (the lifted `toolCallId`/`isError`, direct content) whose + * tool source carries the callId the fold pairs on. */ export function toolResult(seq: number, opts: { callId: string content: ContentBlock[] + /** The durable error mark: `message.isError`. */ error?: boolean - /** Drop the durable source (a legacy/foreign envelope). */ - noSource?: boolean - /** Drop the envelope callId (source carries it). */ - noEnvelopeId?: boolean /** The bounded presentation meta (search matches / read window) on the durable event. */ meta?: unknown time?: number - /** - * The V4 spelling: `role: 'tool'`, the lifted message `toolCallId`/`isError`, - * direct content (no wrapper block), and no envelope `error` object — the - * mark rides the message alone. - */ - v4?: boolean }): TimelineEvent { - const message: Record = opts.v4 === true - ? { role: 'tool', toolCallId: opts.callId, content: opts.content } - : { content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }] } - if (opts.noSource !== true) message.source = { kind: 'tool', callId: opts.callId } - if (opts.v4 === true && opts.error === true) message.isError = true + const message: Record = { + role: 'tool', + toolCallId: opts.callId, + source: { kind: 'tool', callId: opts.callId }, + content: opts.content, + } + if (opts.error === true) message.isError = true const data: Record = { message } - if (opts.noEnvelopeId !== true) data.callId = opts.callId - if (opts.v4 !== true && opts.error === true) data.error = true if (opts.meta !== undefined) data.meta = opts.meta return { type: 'tool/result', seq, time: at(opts.time), data, surfaceOp: 'append' } } diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts index 9fa12c7..60b53f8 100644 --- a/tests/host/index.spec.ts +++ b/tests/host/index.spec.ts @@ -210,8 +210,7 @@ describe('dsh-context host plugin', () => { // A file-tool pair: the fold-derived op log serves through the detail endpoint. const call = session.append('tool/call', { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: JSON.stringify({ file_path: 'src/a.ts' }) } as never) session.append('tool/result', { - callId: 'c1', - message: { content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }], source: { kind: 'tool', callId: 'c1' } }, + message: { role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }], source: { kind: 'tool', callId: 'c1' } }, } as never, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline @@ -279,7 +278,7 @@ describe('the baseline gate', () => { const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline assert.ok(timeline !== undefined, 'the fallback unit still delivers (no eternal loading)') - assert.deepEqual(timeline.unsupported, { current: '0.1.1-rc.2', minimum: BASELINE_DSH_VERSION }) + assert.deepEqual(timeline.unsupported, { current: '0.1.5-rc.1', minimum: BASELINE_DSH_VERSION }) assert.equal(timeline.current.total, 0, 'the log is NOT folded below the baseline') assert.equal(timeline.nodes.length, 0, 'real appends leave no surface nodes') assert.equal(timeline.model, undefined, 'no model metadata either') diff --git a/tests/host/pricing.spec.ts b/tests/host/pricing.spec.ts index fbc207a..4a36d1d 100644 --- a/tests/host/pricing.spec.ts +++ b/tests/host/pricing.spec.ts @@ -47,11 +47,14 @@ describe('estimateMessage', () => { assert.equal(estimateMessage({ content: [{ type: 'tool-call' }] }), 8) }) - test('tool-result blocks price their nested content recursively', () => { + test('a retired wrapper block prices as generic JSON (the meter parity branch)', () => { + // The supported log generation carries no wrapper blocks; a hostile one + // prices through the same structural-JSON arm the meter applies to every + // unknown block. + const block = { type: 'tool-result', content: [{ type: 'text', text: 'ok' }] } assert.equal( - estimateMessage({ content: [{ type: 'tool-result', content: [{ type: 'text', text: 'ok' }] }] }), - // inner text: ceil(2/4)+4 = 5; wrapper: +4; role: +4 - 13, + estimateMessage({ content: [block] }), + 4 + Math.ceil(JSON.stringify(block).length / 4) + 4, ) }) diff --git a/tests/host/settings.spec.ts b/tests/host/settings.spec.ts deleted file mode 100644 index 85ca6a6..0000000 --- a/tests/host/settings.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Integration tests for the plugin settings namespace (src/host/settings.ts) -// against the REAL cordis context and the REAL dsh-settings provider base — -// the dsh-canonical harness pattern: an in-memory SettingsProvider subclass, -// mounted as a plugin, with installSettings layering the namespace on top. - -import assert from 'node:assert/strict' -import { describe, test } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { SettingsProvider } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { installSettings, SETTINGS_NAMESPACE } from '../../src/host/settings' -import type { PluginSettings } from '../../src/host/settings' - -/** A provider implementing only the two storage primitives; the Service Definition owns the rest. */ -class MemorySettings extends SettingsProvider { - doc: Record - - constructor(ctx: ConstructorParameters[0], options?: { doc?: Record }) { - super(ctx) - this.doc = structuredClone(options?.doc ?? {}) - } - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc[String(ns)] = structuredClone(section) - return Promise.resolve() - } -} - -// The raw string is what the dsh settings `register` accepts at runtime; the -// branded cast satisfies the SettingsNamespace type face (see -// src/host/settings.ts). -const ns = SETTINGS_NAMESPACE as SettingsNamespace - -/** Poll until the inject callback inside installSettings has registered the namespace. */ -async function untilRegistered(ctx: Context): Promise { - for (let i = 0; i < 200; i++) { - if (ctx.settings.get(ns) !== undefined) return - await new Promise(resolve => setTimeout(resolve, 1)) - } - assert.fail('the dsh-context settings namespace was never registered') -} - -async function boot(doc?: Record) { - const ctx = new Context() - await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc }) - installSettings(ctx) - await untilRegistered(ctx) - return { ctx, provider: ctx.get('settings') as MemorySettings } -} - -describe('installSettings', () => { - test('the namespace is the plugin short name', () => { - assert.equal(SETTINGS_NAMESPACE, 'dsh-context') - }) - - test('registers the dsh-context namespace with schema defaults', async () => { - const { ctx } = await boot() - const descriptors = ctx.settings.describe() - assert.ok(descriptors.some(d => String(d.ns) === 'dsh-context'), 'the namespace is registered') - assert.deepEqual(ctx.settings.get(ns), { - defaultPlacement: 'all', - defaultGranularity: 'step', - defaultTrendMode: 'total', - defaultDeltaBase: 'step', - defaultToolSort: 'count', - defaultFileSort: 'count', - insightsEntry: 'show', - defaultDurationCurve: 'show', - }, 'schema defaults resolve') - }) - - test('updates flow through the real scope; invalid values reject', async () => { - const { ctx, provider } = await boot() - await ctx.settings.update(ns, { defaultGranularity: 'turn' }) - assert.deepEqual(ctx.settings.get(ns), { - defaultPlacement: 'all', - defaultGranularity: 'turn', - defaultTrendMode: 'total', - defaultDeltaBase: 'step', - defaultToolSort: 'count', - defaultFileSort: 'count', - insightsEntry: 'show', - defaultDurationCurve: 'show', - }, 'the update resolves over the schema defaults') - assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section') - - await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultDeltaBase: 'turn', defaultFileSort: 'path', insightsEntry: 'hide', defaultDurationCurve: 'hide' }) - assert.deepEqual(ctx.settings.get(ns), { - defaultPlacement: 'sidebar', - defaultGranularity: 'turn', - defaultTrendMode: 'delta', - defaultDeltaBase: 'turn', - defaultToolSort: 'count', - defaultFileSort: 'path', - insightsEntry: 'hide', - defaultDurationCurve: 'hide', - }, 'every preference field resolves independently') - - await assert.rejects( - ctx.settings.update(ns, { defaultGranularity: 'week' }), - 'an unknown granularity fails validation before anything persists', - ) - // The loose fields degrade instead of rejecting: a stale file sort, - // placement, tool sort, delta baseline, insights entry, or curve choice resolves to the default. - await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net', defaultDeltaBase: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' }) - assert.deepEqual(ctx.settings.get(ns), { - defaultPlacement: 'all', - defaultGranularity: 'turn', - defaultTrendMode: 'delta', - defaultDeltaBase: 'step', - defaultToolSort: 'count', - defaultFileSort: 'count', - insightsEntry: 'show', - defaultDurationCurve: 'show', - }, 'a stale value degrades to the schema default') - assert.deepEqual( - provider.doc['dsh-context'], - { defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultDeltaBase: 'net', defaultFileSort: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' }, - 'the stale value stays raw in storage and degrades at read', - ) - }) - - test('a stale persisted preference degrades to the default (loose)', async () => { - const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultDeltaBase: 'net', defaultFileSort: 'alpha', insightsEntry: 'gone', defaultDurationCurve: 'always' } }) - const value = ctx.settings.get(ns) as PluginSettings - assert.equal(value.defaultPlacement, 'all', 'the stale placement falls back instead of breaking the section') - assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section') - assert.equal(value.defaultDeltaBase, 'step', 'the stale delta baseline falls back instead of breaking the section') - assert.equal(value.defaultToolSort, 'count', 'the stale tool sort falls back instead of breaking the section') - assert.equal(value.defaultFileSort, 'count', 'the stale file sort falls back instead of breaking the section') - assert.equal(value.insightsEntry, 'show', 'the stale insights entry falls back to visible instead of breaking the section') - assert.equal(value.defaultDurationCurve, 'show', 'the stale curve choice falls back to visible instead of breaking the section') - assert.equal(value.defaultGranularity, 'step') - }) - - test('without a settings provider the install is inert', () => { - const ctx = new Context() - assert.doesNotThrow(() => installSettings(ctx)) - assert.equal(ctx.get('settings'), undefined, 'no provider composed, nothing registered') - }) - - test('a settings service without the register face (dsh V4+) stays inert', async () => { - // The V4+ settings service derives forms from the entry's own Config - // schema and no longer carries `settings.register`; the install must - // feature-detect the face instead of throwing inside the inject fiber. - const ctx = new Context() - ctx.provide('settings', { describe: () => [] }) - assert.doesNotThrow(() => installSettings(ctx)) - // Let the inject callback run; a mis-spelled call would fail the fiber. - await new Promise(resolve => setTimeout(resolve, 20)) - assert.ok(ctx.get('settings') !== undefined, 'the foreign service stays composed') - }) -}) diff --git a/tests/host/version.spec.ts b/tests/host/version.spec.ts index a5b0aaa..0d72098 100644 --- a/tests/host/version.spec.ts +++ b/tests/host/version.spec.ts @@ -178,10 +178,12 @@ beforeAll(() => { // baseline release (the gate must still trip from the running anchor), and // a resolving-but-unreadable library (the trusted witness with no answer). writeScratchHome('running-supported', '@deepseek-ai/dsh-session', - JSON.stringify({ name: '@deepseek-ai/dsh-session', version: '0.1.5-rc.1', exports: { '.': './lib/index.js' } }), + JSON.stringify({ name: '@deepseek-ai/dsh-session', version: '0.1.7-rc.2', exports: { '.': './lib/index.js' } }), { path: 'lib/index.js' }) + // The just-dropped line: below the baseline since the floor moved to + // 0.1.7-rc.2, so the gate must trip on it. writeScratchHome('running-old', '@deepseek-ai/dsh-session', - JSON.stringify({ name: '@deepseek-ai/dsh-session', version: '0.1.1-rc.2', exports: { '.': './lib/index.js' } }), + JSON.stringify({ name: '@deepseek-ai/dsh-session', version: '0.1.5-rc.1', exports: { '.': './lib/index.js' } }), { path: 'lib/index.js' }) writeScratchHome('running-decoy', '@deepseek-ai/dsh-session', JSON.stringify({ name: '@deepseek-ai/dsh-decoy', version: '9.9.9', exports: { '.': './lib/index.js' } }), @@ -199,13 +201,13 @@ describe('detectHarnessVersion — running anchor', () => { test('outranks a stale home mirror (issue #59: a healthy Desktop harness)', () => { // The mirror names an old global CLI while the running tree is a supported // release: the running anchor wins, so the gate never trips. - assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('old')), runningResolver('running-supported', scratch), ELSEWHERE), '0.1.5-rc.1') + assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('old')), runningResolver('running-supported', scratch), ELSEWHERE), '0.1.7-rc.2') }) test('a below-baseline running tree still trips the gate over a newer mirror', () => { // The reverse direction must hold too: a genuinely old harness is reported // even when the mirror names something newer. - assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('future')), runningResolver('running-old', scratch), ELSEWHERE), '0.1.1-rc.2') + assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('future')), runningResolver('running-old', scratch), ELSEWHERE), '0.1.5-rc.1') }) test('discards a witness inside this package own closure for the home anchor', () => { @@ -214,14 +216,14 @@ describe('detectHarnessVersion — running anchor', () => { // default package root (the real one, which contains the repo's // node_modules) performs the discard. const ctx = ctxWithHome(homeResolver('old')) - assert.equal(detectHarnessVersion(ctx, runningResolver('module-skips-cli')), '0.1.1-rc.2') + assert.equal(detectHarnessVersion(ctx, runningResolver('module-skips-cli')), '0.1.5-rc.1') }) test('never probes the CLI package from the running anchor', () => { // The fixture pins the CLI at 0.0.1 but the libraries at the baseline: // only a library answer may come back. const ctx = ctxWithHome(scratchResolver('empty')) - assert.equal(detectHarnessVersion(ctx, runningResolver('module-skips-cli'), ELSEWHERE), '0.1.5-rc.1') + assert.equal(detectHarnessVersion(ctx, runningResolver('module-skips-cli'), ELSEWHERE), '0.1.7-rc.2') }) test('a resolving witness with no readable version falls through to home', () => { @@ -248,8 +250,8 @@ describe('detectHarnessVersion — running anchor', () => { describe('detectHarnessVersion — home anchor', () => { test('reads the CLI package manifest of the running installation', () => { const ctx = (home: string) => ctxWithHome(homeResolver(home)) - assert.equal(detectHarnessVersion(ctx('old'), NO_RUNNING), '0.1.1-rc.2') - assert.equal(detectHarnessVersion(ctx('baseline'), NO_RUNNING), '0.1.5-rc.1') + assert.equal(detectHarnessVersion(ctx('old'), NO_RUNNING), '0.1.5-rc.1') + assert.equal(detectHarnessVersion(ctx('baseline'), NO_RUNNING), '0.1.7-rc.2') assert.equal(detectHarnessVersion(ctx('future'), NO_RUNNING), '0.2.0') assert.equal(detectHarnessVersion(ctx('dev'), NO_RUNNING), '0.0.0-dev') }) diff --git a/tests/shared/estimate.spec.ts b/tests/shared/estimate.spec.ts index 6938762..0bf9a65 100644 --- a/tests/shared/estimate.spec.ts +++ b/tests/shared/estimate.spec.ts @@ -1,8 +1,8 @@ // The shared token heuristics (src/shared/estimate.ts): the fixed-density // figures the host fold and the client boundary must agree on. The system -// prompt is priced from EITHER generation's payload — the V0/V2 envelope's -// rendered string or a V3 `system/message`'s content blocks — with the -// harness token-meter's own no-per-block-overhead rule. +// prompt is priced from a `system/message`'s content blocks (or a rendered +// string, at the legacy-wire boundary) — with the harness token-meter's own +// no-per-block-overhead rule. import assert from 'node:assert/strict' import { describe, test } from 'vitest' diff --git a/tests/shared/version.spec.ts b/tests/shared/version.spec.ts index b4580db..5e9faa5 100644 --- a/tests/shared/version.spec.ts +++ b/tests/shared/version.spec.ts @@ -44,18 +44,19 @@ describe('compareVersions', () => { describe('meetsBaseline', () => { test(`the supported baseline is ${BASELINE_DSH_VERSION}`, () => { - assert.equal(BASELINE_DSH_VERSION, '0.1.5-rc.1') + assert.equal(BASELINE_DSH_VERSION, '0.1.7-rc.2') }) test('baseline and above pass; anything below fails', () => { - assert.equal(meetsBaseline('0.1.5-rc.1'), true, 'the baseline itself') - assert.equal(meetsBaseline('0.1.5-rc.2'), true) - assert.equal(meetsBaseline('0.1.5'), true, 'the final release of the baseline line') - assert.equal(meetsBaseline('0.1.7-rc.2'), true) - assert.equal(meetsBaseline('0.1.7-alpha.1'), true, 'a newer X.Y.Z outranks any channel (the 0.1.7 previews pass)') - assert.equal(meetsBaseline('0.1.3-alpha.2'), false) - assert.equal(meetsBaseline('0.1.5-beta.3'), false, 'beta is below rc at equal X.Y.Z') - assert.equal(meetsBaseline('0.1.5-alpha.1'), false) + assert.equal(meetsBaseline('0.1.7-rc.2'), true, 'the baseline itself') + assert.equal(meetsBaseline('0.1.7-rc.3'), true) + assert.equal(meetsBaseline('0.1.7'), true, 'the final release of the baseline line') + assert.equal(meetsBaseline('0.2.0-rc.2'), true, 'the newest supported line') + assert.equal(meetsBaseline('0.2.0-alpha.1'), true, 'a newer X.Y.Z outranks any channel (the 0.2.0 previews pass)') + assert.equal(meetsBaseline('0.1.7-rc.1'), false, 'an older rc of the baseline line') + assert.equal(meetsBaseline('0.1.7-alpha.1'), false, 'alpha is below rc at equal X.Y.Z') + assert.equal(meetsBaseline('0.1.6'), false, 'a hypothetical final of the dropped cycle') + assert.equal(meetsBaseline('0.1.5-rc.1'), false, 'the previous floor, now gated') assert.equal(meetsBaseline('0.1.2-rc.1'), false) assert.equal(meetsBaseline('0.0.9'), false) })