refactor(experimental): register plugin recommendations as an action card kind

Plugin recommendations now proposes through the shared propose_action tool
instead of a dsh-action answer block. The Host registers the
enable_official_plugin kind with the action card service: the
official-plugins skill, details holding the plugin name, the Enable verb
with global access, and the enabled, already-enabled and failed phases,
whose data carries the application outcome and the management error code.
The package drops its own projection, Remote act, status notice and
persisted record; it keeps the describe Remote for card text. The bundle
inserts the shared card rows beside its own, as Personalized pages does.
The browser half registers a card body in the shared frame that keeps the
catalog text cache, the reserved loading lines, the single details target
and the Enable and Not now footer. The skill's submission section now asks
for one propose_action call.
This commit is contained in:
ZiyaZhang committed 2026-10-22 07:18:05 -07:00
1 parent c6d9a964d3
commit fcb7340be1
63 files changed
+708 -1654

No files matched your search

+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 02cd44eb5166d665
zh: de0ee0e2ccd07a2a
en: a65a5f6deaadd8b3
zh: a412bd0fccd52090
+6
View File
@@ -11,6 +11,9 @@ flowchart LR
svc_actionCards["ctx.actionCards<br/>Optional action cards"]
pkg_experimental_creation_recommendations["experimental-creation-recommendations"]
pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"]
pkg_experimental_plugin_recommendations["experimental-plugin-recommendations"]
svc_pluginRecommendations["ctx.pluginRecommendations<br/>Explicit-need plugin recommendations"]
pkg_experimental_client_ui_plugin_recommendations["experimental-client-ui-plugin-recommendations"]
pkg_headless["headless"]
svc_headlessStartup["ctx.headlessStartup<br/>Headless invocation options"]
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
@@ -355,6 +358,7 @@ flowchart LR
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_plugin_recommendations --> svc_pluginRecommendations
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
@@ -522,6 +526,7 @@ flowchart LR
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginMarket --> pkg_client_ui_plugin_market
svc_pluginMarket --> pkg_plugin_market
svc_pluginRecommendations --> pkg_experimental_client_ui_plugin_recommendations
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
svc_profileContext --> pkg_plugin_manager
svc_ptcRuntime --> pkg_tool_executor_ptc
@@ -627,6 +632,7 @@ flowchart LR
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.actionCards` | `service` | [`experimental-action-cards`](../packages/experimental/action-cards) | - | [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations), [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers. |
| `ctx.pluginRecommendations` | `service` | [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | - | [`experimental-client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | - | Reads the current catalog text of an official plugin card and enables the plugin through the profile plugin manager after a click. |
| `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | Supplies parsed task and run options to the headless runner and its sandbox-policy configuration. |
| `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | Serves reasoning translations, reuses saved results, and joins ordinary Session activation for uncached inactive Sessions. |
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | Resolves Google or Bing requests and retains shared experimental records through the active Session writer. |
+6
View File
@@ -13,6 +13,9 @@ flowchart LR
svc_actionCards["ctx.actionCards<br/>Optional action cards"]
pkg_experimental_creation_recommendations["experimental-creation-recommendations"]
pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"]
pkg_experimental_plugin_recommendations["experimental-plugin-recommendations"]
svc_pluginRecommendations["ctx.pluginRecommendations<br/>Explicit-need plugin recommendations"]
pkg_experimental_client_ui_plugin_recommendations["experimental-client-ui-plugin-recommendations"]
pkg_headless["headless"]
svc_headlessStartup["ctx.headlessStartup<br/>Headless invocation options"]
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
@@ -357,6 +360,7 @@ flowchart LR
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_plugin_recommendations --> svc_pluginRecommendations
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
@@ -524,6 +528,7 @@ flowchart LR
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginMarket --> pkg_client_ui_plugin_market
svc_pluginMarket --> pkg_plugin_market
svc_pluginRecommendations --> pkg_experimental_client_ui_plugin_recommendations
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
svc_profileContext --> pkg_plugin_manager
svc_ptcRuntime --> pkg_tool_executor_ptc
@@ -629,6 +634,7 @@ flowchart LR
| ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.actionCards` | `service` | [`experimental-action-cards`](../packages/experimental/action-cards) | - | [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations), [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | 负责卡片工具、卡片状态、用户操作和状态通知;各卡片种类注册自己的动作和处理器。 |
| `ctx.pluginRecommendations` | `service` | [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | - | [`experimental-client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | - | 读取官方插件卡片的当前目录文本,并在用户点击后通过配置插件管理器启用该插件。 |
| `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | 将解析后的任务和运行选项提供给无界面运行器及其 sandbox-policy 配置。 |
| `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | 为展开的思考内容提供有界的 Session 翻译请求,不改变模型可见历史。 |
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | 解析 Google 或 Bing 请求,并可选保留统一的实验性 Session 记录。 |
+1 -14
View File
@@ -1135,20 +1135,6 @@ export interface InspectorOptions {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-inspector -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-plugin-recommendations -->
<a id="deepseek-aidsh-experimental-plugin-recommendations"></a>
## `@deepseek-ai/dsh-experimental-plugin-recommendations`
- `refs`: `z` (`zod`)
- `source`: [`packages/experimental/plugin-recommendations/src/index.ts:26`](../packages/experimental/plugin-recommendations/src/index.ts)
```ts config-catalog
/** Validated card limits after schema defaults. */
export type Config = z.infer<typeof Config>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-plugin-recommendations -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-ptc-runtime-python -->
<a id="deepseek-aidsh-experimental-ptc-runtime-python"></a>
@@ -4799,6 +4785,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-plugin-recommendations` | — | [`packages/experimental/plugin-recommendations/src/index.ts`](../packages/experimental/plugin-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) |
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
+1 -14
View File
@@ -1137,20 +1137,6 @@ export interface InspectorOptions {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-inspector -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-plugin-recommendations -->
<a id="deepseek-aidsh-experimental-plugin-recommendations"></a>
## `@deepseek-ai/dsh-experimental-plugin-recommendations`
- `refs`: `z` (`zod`)
- `source`: [`packages/experimental/plugin-recommendations/src/index.ts:26`](../packages/experimental/plugin-recommendations/src/index.ts)
```ts config-catalog
/** Validated card limits after schema defaults. */
export type Config = z.infer<typeof Config>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-plugin-recommendations -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-ptc-runtime-python -->
<a id="deepseek-aidsh-experimental-ptc-runtime-python"></a>
@@ -4801,6 +4787,7 @@ export interface Config {
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-plugin-recommendations` | — | [`packages/experimental/plugin-recommendations/src/index.ts`](../packages/experimental/plugin-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) |
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
+1 -2
View File
@@ -519,7 +519,6 @@ flowchart TD
pkg_deliverable_file_references --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
pkg_experimental_plugin_recommendations --> pkg_llm
pkg_experimental_plugin_recommendations --> pkg_session
pkg_experimental_plugin_recommendations --> pkg_skill
pkg_experimental_plugin_recommendations --> pkg_typert_protocol
pkg_experimental_webworker_runtime --> pkg_client_connection
@@ -1649,7 +1648,7 @@ flowchart TD
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
| [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
+1 -2
View File
@@ -521,7 +521,6 @@ flowchart TD
pkg_deliverable_file_references --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
pkg_experimental_plugin_recommendations --> pkg_llm
pkg_experimental_plugin_recommendations --> pkg_session
pkg_experimental_plugin_recommendations --> pkg_skill
pkg_experimental_plugin_recommendations --> pkg_typert_protocol
pkg_experimental_webworker_runtime --> pkg_client_connection
@@ -1651,7 +1650,7 @@ flowchart TD
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
| [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
@@ -1,28 +0,0 @@
# Bilingual-pair consistency record for 2026-10-22-plugin-recommendations.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-22-plugin-recommendations.md
/:
en: 3d5a424ae7d7e46b
zh: c22a8e4a62d8cc1a
/2026-10-22-plugin-recommendations:
en: 92b8553e1606c2bb
zh: f3343b31008e4e5f
/2026-10-22-plugin-recommendations/summary:
en: c55b63304dc3787d
zh: 2197a9a312195130
/2026-10-22-plugin-recommendations/table-of-contents:
en: 6cf314a069ceb451
zh: 9adff678e9ecab3b
/2026-10-22-plugin-recommendations/declaration:
en: 6907136f28e48c90
zh: 02f8298485f3d8e7
/2026-10-22-plugin-recommendations/compatibility:
en: 0db4fa5dc1583f36
zh: be89c6db13b7473c
/2026-10-22-plugin-recommendations/verification:
en: 1d97b1ca30d97866
zh: b91530d7ae9c1985
/2026-10-22-plugin-recommendations/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
@@ -1,60 +0,0 @@
---
description: "Records a persistence type transition and its compatibility acknowledgement."
kind: persistence-change
---
# 2026-10-22-plugin-recommendations
English | [中文](2026-10-22-plugin-recommendations.zh.md)
## Summary
Adds official-plugin status attribution to logged model context.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: 2026-10-22-plugin-recommendations
baseline: false
changes:
- root: "event:agent/inbox/spliced"
previous: "2026-10-20-personalized-page-status"
after: "dde4d210811d670552c70fa7a9864ae650cef357590ad60ef1778c9b7dd1d7bf"
decision: same-version
- root: "event:developer/message"
previous: "2026-10-20-personalized-page-status"
after: "1adeb5417a9565c55ad63138034d1c51daf6a75f8964c46352691a1d94188265"
decision: same-version
- root: "event:session/title-llm-request"
previous: "2026-10-20-personalized-page-status"
after: "651a11c7869dfbe778ff23bf7e94567b141470b5dbad4a146f93189a4ff24646"
decision: same-version
- root: "event:user/message"
previous: "2026-10-20-personalized-page-status"
after: "1ca126460ab395a27ca1d236203c0a8b58eddcc316d681484691bced1b220045"
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
Existing messages and decision records are unchanged. Readers without this plugin preserve the new source kind and its JSON metadata and derive the recorded text as ordinary context. Only the producer uses the through sequence to suppress already delivered notices; it grants no authority, and reading a session never replays the reported card decision. Decision receipts use the existing ignorable plugin-record envelope.
<a id="verification"></a>
## Verification
Focused plugin-recommendation projection, status, action and Loader/HMR composition tests: 33 passed; changed Host modules have 100% coverage. Status tests cover delivery on the next request, acknowledgement only after the notice is logged, cancellation, rejection, plugin reload, Session reopen and a receipt without a logged proposal.
<a id="dev-note"></a>
## Dev Note
None.
@@ -1,257 +0,0 @@
{
"formatVersion": 2,
"roots": [
{"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"dde4d210811d670552c70fa7a9864ae650cef357590ad60ef1778c9b7dd1d7bf"},
{"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"1adeb5417a9565c55ad63138034d1c51daf6a75f8964c46352691a1d94188265"},
{"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"651a11c7869dfbe778ff23bf7e94567b141470b5dbad4a146f93189a4ff24646"},
{"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"1ca126460ab395a27ca1d236203c0a8b58eddcc316d681484691bced1b220045"}
],
"types": [],
"nodes": {
"0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]},
"062f94b162cfd50f": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"},
"07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"},
"09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]},
"0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"0fe52387f6505270": {"kind":"literal","value":"plan-mode"},
"11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]},
"1379f0b72275994d": {"kind":"literal","value":"developer/message"},
"16a51dd31994f8d8": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"16d0507cb94b4cfc": {"kind":"literal","value":"relay"},
"1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"},
"1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
"1adeb5417a9565c5": {"kind":"object","properties":[{"name":"data","type":"4ccafbd2333db70a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]},
"1af4d670721bd327": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"1ca126460ab395a2": {"kind":"object","properties":[{"name":"data","type":"3b08753672126186","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]},
"1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
"1f0133217eeeffd8": {"kind":"literal","value":"tool-call"},
"1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"},
"1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
"2013e9f315078a16": {"kind":"array","element":"3b08753672126186"},
"2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]},
"224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"},
"24b10f7cdae64928": {"kind":"literal","value":"runtime-context"},
"24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
"260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"2765e9119edf5816": {"kind":"literal","value":"model"},
"277b61f9c6f81704": {"kind":"literal","value":"file"},
"28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"},
"2e4debca12f2c449": {"kind":"literal","value":"team-message"},
"2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]},
"2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"},
"312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
"328eb9a6ac89d6d8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]},
"383cf73ae4748e5b": {"kind":"literal","value":"recall"},
"3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"3b08753672126186": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"576b520757158af6","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["personalized-pages","plugin-recommendations","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
"3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]},
"3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"},
"3e2ece4967cfebe9": {"kind":"literal","value":"next-step"},
"3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"},
"408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]},
"4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"4ccafbd2333db70a": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"85610bad3515b96d","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"4cd48f3e5108bd6e": {"kind":"primitive","type":"string"},
"4e44c9118152f682": {"kind":"literal","value":"text"},
"4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]},
"4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"},
"4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]},
"52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"53bb37430d38c725": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
"5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"576b520757158af6": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","062f94b162cfd50f","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","16a51dd31994f8d8","1938c8c20f1cba7c","1af4d670721bd327","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","328eb9a6ac89d6d8","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53bb37430d38c725","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5b25a1523822e01a","5bb69b88517f6214","5bc66269cfaf614c","5c98a6a7047c3589","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","66b2e4f3954442bc","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","9529cbfe9fc86799","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b03a3f57be0e2056","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","ca146eefc6285fee","cb1c8959e1726f82","cda2345a9be9d139","d2cc8a54a6f1abc9","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","deaae5cd30987d4f","df948d3c80162763","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","f99e707d67fd708b","fc2ae6962f9f8801","fcf81d3c58c13593","fe1a7acdeed4d0f5"]},
"58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"},
"589d0d91239681f8": {"kind":"literal","value":"developer"},
"58f94390b982f8a5": {"kind":"literal","value":"image/png"},
"5b25a1523822e01a": {"kind":"object","properties":[{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
"5c98a6a7047c3589": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"},
"5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
"5e88c0eb7dd6462d": {"kind":"union","types":["09f3f8dc417f2782","3b08753672126186","4b259f7f8f0a44ad","85610bad3515b96d","fa87537f4a9c1e25"]},
"6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"},
"651a11c7869dfbe7": {"kind":"object","properties":[{"name":"data","type":"70a6380591b15f2d","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]},
"65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]},
"66b2e4f3954442bc": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"68f44f59e91c4139": {"kind":"literal","value":"user"},
"6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"},
"6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"6dacb1584f2bfbe2": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"},
"6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]},
"6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"6f9e00383836275e": {"kind":"literal","value":"snapshot"},
"70a6380591b15f2d": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"e49519b11e614a05","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]},
"7382c4fc95fe8c5d": {"kind":"literal","value":"remove"},
"7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
"78a9be340090b735": {"kind":"literal","value":"tmux-context"},
"7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"},
"7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"7f13860ee89a2d35": {"kind":"literal","value":"session-reference"},
"7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]},
"80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"8067b0d232c350f1": {"kind":"literal","value":"time-context"},
"814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]},
"82470020b5bfcb1a": {"kind":"literal","value":"agent-message"},
"825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"},
"83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"83d6916021eede8f": {"kind":"literal","value":"tool-removal"},
"83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"85610bad3515b96d": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"576b520757158af6","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["personalized-pages","plugin-recommendations","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"85d6d96ff8842974": {"kind":"literal","value":"answered"},
"85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"},
"85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
"8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"8af841fc9b265ade": {"kind":"literal","value":"system"},
"8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]},
"8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]},
"904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]},
"938f7c7357bc403e": {"kind":"primitive","type":"null"},
"93a56a85fc8d91b5": {"kind":"literal","value":"plugin-recommendations"},
"9529cbfe9fc86799": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"95a3ffccbf3c8afc": {"kind":"literal","value":"owner"},
"95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"},
"9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"},
"96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]},
"99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"},
"99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
"9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"},
"9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"a13d8f379222ece8": {"kind":"literal","value":"tool"},
"a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
"a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"a720757f704a2512": {"kind":"literal","value":"tool-registry"},
"a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"},
"aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"aa93010d0e521ad7": {"kind":"primitive","type":"boolean"},
"aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"},
"ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]},
"ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
"aec150797b655c5f": {"kind":"literal","value":"catalog"},
"b03a3f57be0e2056": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
"b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"},
"b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
"b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"b765ee31033cea46": {"kind":"literal","value":"set"},
"b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
"b815d7b91cdd9fc6": {"kind":"literal","value":"goal"},
"ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"},
"bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"},
"bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"},
"c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]},
"c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]},
"c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"},
"c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"},
"ca146eefc6285fee": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"d2cc8a54a6f1abc9": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"d3e2c037628540ed": {"kind":"object","properties":[{"name":"inserted","type":"2013e9f315078a16","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]},
"d4e4d575abeb4b72": {"kind":"literal","value":"canceled"},
"d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]},
"d9edf1862e4471d6": {"kind":"literal","value":"schedule"},
"dacf3d5f3e4ff619": {"kind":"literal","value":true},
"db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"db6c5246911d47a7": {"kind":"literal","value":"webhook"},
"dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]},
"dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
"dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
"dde4d210811d6705": {"kind":"object","properties":[{"name":"data","type":"d3e2c037628540ed","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]},
"de25352d2dc41963": {"kind":"literal","value":"user/message"},
"de30b1fa12c75f81": {"kind":"literal","value":"image/gif"},
"deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]},
"df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"},
"df948d3c80162763": {"kind":"object","properties":[{"name":"kind","type":"93a56a85fc8d91b5","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"e1ba710db2f3e4e4": {"kind":"literal","value":"personalized-pages"},
"e438a9d4fb883b3e": {"kind":"literal","value":"append"},
"e49519b11e614a05": {"kind":"array","element":"5e88c0eb7dd6462d"},
"e8de0f457fed5b9f": {"kind":"literal","value":"notice"},
"ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"},
"edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
"f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]},
"f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]},
"f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"f68c335306535a00": {"kind":"literal","value":"user-approval"},
"f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
"f6cb462f808309e3": {"kind":"literal","value":"replace"},
"f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"f88f1b7b6bb733f6": {"kind":"literal","value":1},
"f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"f99e707d67fd708b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"e1ba710db2f3e4e4","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]},
"fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"},
"fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"},
"fc2ae6962f9f8801": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]},
"fcf427290c6213c2": {"kind":"primitive","type":"never"},
"fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}
}
}
@@ -1,60 +0,0 @@
---
description: "记录持久化类型更改及其兼容性确认。"
kind: persistence-change
---
# 2026-10-22-plugin-recommendations
[English](2026-10-22-plugin-recommendations.md) | 中文
## 概述
为写入模型上下文的官方插件状态新增来源标记。
## 目录
- [声明](#declaration)
- [兼容性](#compatibility)
- [验证](#verification)
- [开发备注](#dev-note)
<a id="declaration"></a>
## 声明
```yaml persistence-change
schemaVersion: 1
id: 2026-10-22-plugin-recommendations
baseline: false
changes:
- root: "event:agent/inbox/spliced"
previous: "2026-10-20-personalized-page-status"
after: "dde4d210811d670552c70fa7a9864ae650cef357590ad60ef1778c9b7dd1d7bf"
decision: same-version
- root: "event:developer/message"
previous: "2026-10-20-personalized-page-status"
after: "1adeb5417a9565c55ad63138034d1c51daf6a75f8964c46352691a1d94188265"
decision: same-version
- root: "event:session/title-llm-request"
previous: "2026-10-20-personalized-page-status"
after: "651a11c7869dfbe778ff23bf7e94567b141470b5dbad4a146f93189a4ff24646"
decision: same-version
- root: "event:user/message"
previous: "2026-10-20-personalized-page-status"
after: "1ca126460ab395a27ca1d236203c0a8b58eddcc316d681484691bced1b220045"
decision: same-version
```
<a id="compatibility"></a>
## 兼容性
已有消息和操作记录不变。未加载本插件的读取器保留新来源种类及其 JSON 元数据,并将已记录文本作为普通上下文派生。只有写入方使用 through 序号抑制已送达通知;它不授予权限,读取会话也不会重放被报告的卡片决定。操作记录沿用现有可忽略的插件记录信封。
<a id="verification"></a>
## 验证
插件推荐的投影、状态、操作和 Loader/HMR 组合专项测试 33 项通过,修改的 Host 模块覆盖率为 100%。状态测试覆盖下一次请求送达、通知写入日志后才确认、取消、拒绝、插件重载、重新打开 Session,以及没有对应提案的记录。
<a id="dev-note"></a>
## 开发备注
无。
@@ -15,8 +15,8 @@
en: 14c57f30e676f6e3
zh: b1a0ccf26fab3727
/session-persistence-formats/format-references:
en: c2e30937a37a498d
zh: 56ffaeae6e072276
en: e3511a4ac3bc4457
zh: 828f3c39f80d5370
/session-persistence-formats/scope-and-evidence:
en: b220062139ddac1e
zh: 7c4c0efe05dafa9c
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 630 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 622 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 630 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 622 |
<!-- persistence-format-index:end -->
+4 -15
View File
@@ -404,27 +404,16 @@ Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-
### `ctx.pluginRecommendations` — `PluginRecommendations`
Internal Remote and effect owner assembled by the plugin-recommendation feature.
Catalog lookups for card text and the enabling behind the `enable_official_plugin` card.
```ts cordis-catalog
/**
* Read the current catalog text for a logged proposal's card.
* Read the current catalog text for a logged card.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the block names no available official plugin.
* @param callId - The card's `propose_action` call id, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the card names no available official plugin.
*/
@Remote async describe(agent: Agent, callId: ToolCallId): Promise<RecommendationDetails>
/**
* Decide one logged recommendation. Concurrent actions on the same card share
* one operation; a settled success or dismissal never enables twice. Failed
* activation remains retryable. Profile selection survives session disposal.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @param action - Human choice.
* @returns An observed outcome, a refusal before mutation, or an unconfirmed result if reload interrupts the wait.
*/
@Remote act(agent: Agent, callId: ToolCallId, action: 'enable' | 'dismiss'): Promise<RecommendationActionResult>
```
Types: [Agent](core.md) · [ToolCallId](core.md)
+4 -15
View File
@@ -404,27 +404,16 @@ Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-
### `ctx.pluginRecommendations` — `PluginRecommendations`
Internal Remote and effect owner assembled by the plugin-recommendation feature.
Catalog lookups for card text and the enabling behind the `enable_official_plugin` card.
```ts cordis-catalog
/**
* Read the current catalog text for a logged proposal's card.
* Read the current catalog text for a logged card.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the block names no available official plugin.
* @param callId - The card's `propose_action` call id, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the card names no available official plugin.
*/
@Remote async describe(agent: Agent, callId: ToolCallId): Promise<RecommendationDetails>
/**
* Decide one logged recommendation. Concurrent actions on the same card share
* one operation; a settled success or dismissal never enables twice. Failed
* activation remains retryable. Profile selection survives session disposal.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @param action - Human choice.
* @returns An observed outcome, a refusal before mutation, or an unconfirmed result if reload interrupts the wait.
*/
@Remote act(agent: Agent, callId: ToolCallId, action: 'enable' | 'dismiss'): Promise<RecommendationActionResult>
```
Types: [Agent](core.zh.md) · [ToolCallId](core.zh.md)
+4 -4
View File
@@ -9,8 +9,8 @@
en: 6cc2cd9ced70bb09
zh: 1c0e6cb4ba2ed637
/tool-schema-catalog/tool-package-map:
en: 3b44fba80c4b6baf
zh: 45f8962c849cdc20
en: 7885592c710ce003
zh: c89bd1909196347f
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
en: e0143916bb634762
zh: e0143916bb634762
@@ -60,8 +60,8 @@
en: 94f08f350e306557
zh: 94f08f350e306557
/tool-schema-catalog/deepseek-ai-dsh-experimental-action-cards/propose-action:
en: b2afbd46036c0eb1
zh: 0983902a9034482b
en: c289411d447b015a
zh: 3a770388e90df4df
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user:
en: e713a4a11a15d13c
zh: e713a4a11a15d13c
+2 -2
View File
@@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages bundle supplies the only shipped kind. |
| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages and Plugin recommendations bundles supply the shipped kinds. |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. |
| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
@@ -539,7 +539,7 @@ Show the user one optional action card under this answer. Call only as the skill
Source: [`packages/experimental/action-cards/src/index.ts`](../packages/experimental/action-cards/src/index.ts)
Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages bundle supplies the only shipped kind.
Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages and Plugin recommendations bundles supply the shipped kinds.
<a id="deepseek-aidsh-tool-ask-user"></a>
+2 -2
View File
@@ -23,7 +23,7 @@
| `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - |
| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,目前只有个性化页面 Bundle 提供种类。 |
| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,个性化页面和插件推荐两个 Bundle 提供随产品发布的种类。 |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 |
| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
@@ -543,7 +543,7 @@
来源:[`packages/experimental/action-cards/src/index.ts`](../packages/experimental/action-cards/src/index.ts)
只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,目前只有个性化页面 Bundle 提供种类。
只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,个性化页面和插件推荐两个 Bundle 提供随产品发布的种类。
<a id="deepseek-aidsh-tool-ask-user"></a>
+2 -2
View File
@@ -18,8 +18,8 @@
en: b55aeb927737b90d
zh: 0b215fcb5c2c7908
/packages-experimental/packages:
en: a2a6d1e85d04484c
zh: 76d6de87b2355b29
en: 4d32c0a6c009a2ca
zh: 47bf9acd64100263
/packages-experimental/related-documentation:
en: 1cb9d3448d44e48d
zh: cd326d70a746b896
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
/deepseek-ai-dsh-experimental-action-cards/use-this-package:
en: e64653a62247236d
zh: d035f7cf50071696
en: fe9d7180d7976c20
zh: e95542d3e55457d5
/deepseek-ai-dsh-experimental-action-cards/use-this-package/register-a-card-kind:
en: a1414c3cfa8e204f
zh: 90eb7a7010972743
@@ -27,8 +27,8 @@
en: 5640f0436038aefc
zh: 5ebb181d81ad2c7e
/deepseek-ai-dsh-experimental-action-cards/further-exploration:
en: db407bb756ee5641
zh: af5d25f4f2351ee6
en: 1966ab8d7063c450
zh: 033c2c0d258a257f
/deepseek-ai-dsh-experimental-action-cards/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -60,8 +60,8 @@
en: 58785543e2428dba
zh: 4b082f77b3b2f482
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work:
en: a8b122edac907461
zh: d1003e2830e410c1
en: a697d11b37f71864
zh: 135a4a04e50ab659
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: bd8a8936229e035e
+3 -3
View File
@@ -25,7 +25,7 @@ An experiment can offer one optional action below an answer, such as creating a
<a id="use-this-package"></a>
## Use this package
The [Personalized pages bundle](../creation-recommendations/README.md) inserts this service and its [browser half](../client-ui-action-cards/README.md); enabling that bundle enables both. The service has one configuration field:
The [Personalized pages](../creation-recommendations/README.md) and [Plugin recommendations](../plugin-recommendations/README.md) bundles each insert this service and its [browser half](../client-ui-action-cards/README.md); enabling either bundle enables both. The service has one configuration field:
| Field | Default | Meaning |
|---|---|---|
@@ -74,7 +74,7 @@ The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live r
## Further Exploration
- [Action card display](../client-ui-action-cards/README.md) — the browser frame, kind bodies and the process row.
- [Personalized pages](../creation-recommendations/README.md) — the shipped card kind.
- [Personalized pages](../creation-recommendations/README.md) and [Plugin recommendations](../plugin-recommendations/README.md) — the shipped card kinds.
- [Tools](../../core/tools/README.md) — conditional registration and step admission.
- [Session projection](../../session/session-projection/README.md) — the card state the browser reads.
@@ -123,7 +123,7 @@ The notice is appended after the request's other new context and never rewrites
- Calls dispatched from code, as in PTC presets, are refused: the log records them as program dispatches rather than tool calls, so no card could follow them.
- Only one card is offered per turn across all kinds, and one unstarted card per conversation.
- The projection reads `queuedMessageLimit` while its state version stays fixed. A changed limit applies to Sessions folded again from their log; cached projection rows keep the suppression decided under the earlier limit.
- The rows of this service and its browser half are inserted by the Personalized pages bundle. A second kind's bundle that inserts the same rows conflicts with it; shared row ownership between bundles is deferred until a second kind ships.
- The Personalized pages and Plugin recommendations bundles both insert the rows of this service and its browser half. With both bundles enabled the Loader keeps one entry per row id, and both bundle pages list these rows, so switching or configuring a row there changes the one shared entry.
<a id="dev-note"></a>
### Dev Note
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
[个性化页面 Bundle](../creation-recommendations/README.zh.md) 会插入本服务及其[浏览器端](../client-ui-action-cards/README.zh.md);启用该 Bundle 即同时启用两者。本服务只有一个配置字段:
[个性化页面](../creation-recommendations/README.zh.md)和[插件推荐](../plugin-recommendations/README.zh.md)两个 Bundle 都会插入本服务及其[浏览器端](../client-ui-action-cards/README.zh.md);启用其中任一个即同时启用两者。本服务只有一个配置字段:
| 字段 | 默认值 | 含义 |
|---|---|---|
@@ -74,7 +74,7 @@ Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在
## 延伸阅读
- [操作卡片显示](../client-ui-action-cards/README.zh.md) — 浏览器端卡片框架、种类正文与过程行。
- [个性化页面](../creation-recommendations/README.zh.md) — 随产品提供的卡片种类。
- [个性化页面](../creation-recommendations/README.zh.md)和[插件推荐](../plugin-recommendations/README.zh.md) — 随产品提供的卡片种类。
- [工具](../../core/tools/README.zh.md) — 条件注册与每步准入。
- [会话投影](../../session/session-projection/README.zh.md) — 浏览器读取的卡片状态。
@@ -123,7 +123,7 @@ Latest status of action cards from user actions and background work. These are s
- 从代码中发起的调用(例如 PTC 预设中)会被拒绝:日志把它们记为程序派发而不是工具调用,因此不可能有卡片跟随。
- 所有种类合计每轮只展示一张卡片,每个对话只保留一张尚未开始的卡片。
- 投影读取 `queuedMessageLimit`,状态版本却不随它变化。修改后的上限只作用于重新从日志折叠的会话;已缓存的投影行保留按旧上限得出的“未展示”结果。
- 本服务及其浏览器端的行由个性化页面 Bundle 插入。第二个种类的 Bundle 若插入相同的行会与其冲突;多个 Bundle 共享这些行的方式留到第二个种类发布时再定。
- 个性化页面和插件推荐两个 Bundle 都会插入本服务及其浏览器端的行。两个 Bundle 都启用时,Loader 对每个行 id 只保留一个条目;两个 Bundle 的详情页都会列出这些行,在那里开关或配置某一行,改的是同一个共享条目。
<a id="dev-note"></a>
### 开发备注
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package:
en: 09d6dec2adb9699a
zh: b6ae739fe314df54
en: 163d385e91a5df38
zh: 95bdde9e844cca51
/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package/register-a-card-body:
en: 2ab65c8c258e694a
zh: a785becbe7015e0e
@@ -24,8 +24,8 @@
en: bdfd09e39b6e0edf
zh: 06f2da1c16a66547
/deepseek-ai-dsh-experimental-client-ui-action-cards/further-exploration:
en: c24d6ca9095832e7
zh: b2ee0066a865dc5f
en: 55e4d74a33cdeeb3
zh: f601d5a4dcf63ec5
/deepseek-ai-dsh-experimental-client-ui-action-cards/model-experience:
en: f80a4b656c108f22
zh: a3e30f46fa7136ac
@@ -25,7 +25,7 @@ Users see an optional action card below a completed answer: its title, a brief t
<a id="use-this-package"></a>
## Use this package
Enable the [Personalized pages bundle](../creation-recommendations/README.md), which inserts this package beside its [Host service](../action-cards/README.md). This browser contribution has no configuration fields.
Enable the [Personalized pages](../creation-recommendations/README.md) or [Plugin recommendations](../plugin-recommendations/README.md) bundle; each inserts this package beside its [Host service](../action-cards/README.md). This browser contribution has no configuration fields.
### Register a card body
@@ -59,7 +59,7 @@ A `tool.call.toolview` entry keyed `propose_action` renders the process row from
## Further Exploration
- [Action cards](../action-cards/README.md) — the tool, card state and user actions behind the frame.
- [Personalized page cards](../client-ui-creation-recommendations/README.md) — the shipped card body.
- [Personalized page cards](../client-ui-creation-recommendations/README.md) and [recommendation cards](../client-ui-plugin-recommendations/README.md) — the shipped card bodies.
- [Slots](../../client/ui-slots/README.md) — Turn tail and tool row registration.
<a id="model-experience"></a>
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
启用[个性化页面 Bundle](../creation-recommendations/README.zh.md),它会在本包旁插入对应的 [Host 服务](../action-cards/README.zh.md)。本浏览器组件没有配置字段。
启用[个性化页面](../creation-recommendations/README.zh.md)或[插件推荐](../plugin-recommendations/README.zh.md) Bundle,它们都会在本包旁插入对应的 [Host 服务](../action-cards/README.zh.md)。本浏览器组件没有配置字段。
### 注册卡片正文
@@ -59,7 +59,7 @@ kind: "package-reference"
## 延伸阅读
- [操作卡片](../action-cards/README.zh.md) — 框架背后的工具、卡片状态与用户操作。
- [个性化页面卡片](../client-ui-creation-recommendations/README.zh.md) — 随产品提供的卡片正文。
- [个性化页面卡片](../client-ui-creation-recommendations/README.zh.md)和[推荐卡片](../client-ui-plugin-recommendations/README.zh.md) — 随产品提供的卡片正文。
- [插槽](../../client/ui-slots/README.zh.md) — 轮次尾部与工具行的注册。
<a id="model-experience"></a>
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/client-ui-plugin-recommendations/README.md
/:
en: d5743911a355b62e
zh: 944e6ec8c09b7358
en: 56ef0772b237c7f1
zh: 02f3a41d3b778505
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations:
en: f0fc11779f589f44
zh: e6f828ecaa30460b
@@ -15,20 +15,20 @@
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/use-this-package:
en: 62e446e9cb825f36
zh: 620506f3986eb9a9
en: 7a588dae45ca8bef
zh: 9ebf6d4b9af830c3
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/understand-the-implementation:
en: d53f149533596c28
zh: 77e539467885f600
en: 2b19ddd10d6b758a
zh: 63446f98ec3f3769
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/further-exploration:
en: 857c404e794d16f9
zh: 9e907212efe09dd9
en: 4fd18141ccbba60b
zh: 137a74a945ac3f23
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/model-experience:
en: 20ebf700f98ca7ac
zh: 78302003761dee92
en: d73a452f8bf206b7
zh: 67db0a1b3a5767cf
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/model-experience/kv-cache-effect:
en: 4faf0dd1beff482e
zh: 77ea65782433f221
zh: a2794bbdb956d557
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/known-limitations-and-deferred-work:
en: a1dd9c7a4fbd5971
zh: 8909053269549211
@@ -1,5 +1,5 @@
---
description: "Show official plugin recommendations after completed answers."
description: "Show official plugin recommendation cards after completed answers."
kind: "package-reference"
---
@@ -25,7 +25,7 @@ Users see one plugin recommendation below the completed answer, with the plugin'
<a id="use-this-package"></a>
## Use this package
Enable the [Plugin recommendations bundle](../plugin-recommendations/README.md). It composes this browser contribution with the Host skill and action service. This contribution has no configuration fields.
Enable the [Plugin recommendations bundle](../plugin-recommendations/README.md). This browser contribution has no configuration fields; it fills the shared [action card frame](../client-ui-action-cards/README.md) for `enable_official_plugin` cards.
-----
@@ -35,9 +35,9 @@ Enable the [Plugin recommendations bundle](../plugin-recommendations/README.md).
<details>
<summary>Implementation internals — click to expand</summary>
The Turn tail reads the proposals of the `pluginRecommendations` Session projection and shows the one admitted in its Turn once that Turn has ended, for any end reason; the projection admits at most one proposal per Turn, and the chat hides the fence itself. A proposal the projection marks never offered renders nothing. The card asks the Host for the plugin's current catalog text and selection; a name outside the eligible catalog shows no actions. The browser contribution keeps returned catalog text by plugin name for its lifetime, so a revisited card renders it at once while its own lookup refreshes the selection. Until a plugin's first lookup returns, placeholder lines and the disabled actions hold the title, description and footer in place. Session projection decisions drive the card after reload. The optional Remote and all UI registrations are removed together on unload.
The plugin mounts its catalog-text Remote, then registers its body with the shared card frame, which places the card after its Turn and supplies the card, its recorded decision and card-bound actions. The body asks the Host for the plugin's current catalog text and selection and renders the header with the Official badge, the details target, status, and Enable and Not now through the frame's action; a name outside the eligible catalog shows no actions. The plugin keeps returned catalog text by plugin name for its lifetime, so a revisited card renders it at once while its own lookup refreshes the selection. Until a plugin's first lookup returns, the card shows the proposed title, a placeholder description line and the disabled actions, so its layout holds. The Remote, the body registration and its dictionaries share one plugin lifetime.
Cards use the application font, the deliverables card border and background, a distinct title and compact body scale, and a separate action footer. The built entry supplies generated Remote descriptors to the source-tested mount lifecycle.
Cards use the application font, a distinct title and compact body scale, and a separate action footer. The built entry supplies generated Remote descriptors to the source-tested mount lifecycle.
- [Source](src/client/index.ts)
- [Tests](tests/)
@@ -49,14 +49,13 @@ Cards use the application font, the deliverables card border and background, a d
<a id="further-exploration"></a>
## Further Exploration
- [Recommendation service](../plugin-recommendations/README.md) — eligibility, activation and persistence.
- [Session projection](../../session/session-projection/README.md) — the proposals and card states the card reads.
- [Slots](../../client/ui-slots/README.md) — completed-Turn presentation.
- [Recommendation service](../plugin-recommendations/README.md) — eligibility, enabling and decisions.
- [Action card display](../client-ui-action-cards/README.md) — the frame, kind bodies and the process row.
<a id="model-experience"></a>
## Model Experience
Indirectly, through card actions: the Host recommendation service adds a logged status notice to the next model request. The browser contribution adds no model tools or prompt content.
Indirectly, through card actions: the shared Host card service adds a logged status notice to the next model request. The browser contribution adds no model tools or prompt content.
#### KV Cache effect
@@ -1,5 +1,5 @@
---
description: "在已完成的回答末尾显示官方插件推荐。"
description: "在完整回答之后显示官方插件推荐卡片。"
kind: "package-reference"
---
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
启用[插件推荐 Bundle](../plugin-recommendations/README.zh.md),即可组合本界面和 Host skill、操作服务。本界面没有配置字段。
启用[插件推荐 Bundle](../plugin-recommendations/README.zh.md)。这个浏览器端贡献没有配置项;它为 `enable_official_plugin` 卡片填充共享的[操作卡片框架](../client-ui-action-cards/README.zh.md)。
-----
@@ -35,9 +35,9 @@ kind: "package-reference"
<details>
<summary>实现细节 — 点击展开</summary>
回合末尾插槽读取 `pluginRecommendations` Session 投影中的提案,在回合结束后(无论以何种原因结束)显示该回合接纳的那一条;投影每个回合最多接纳一条提案,聊天界面隐藏围栏本身。投影标为未展示的提案不渲染。卡片向 Host 读取插件当前的目录文本和启用状态;不在可推荐目录里的名字不显示操作按钮。浏览器端在插件生命周期内按插件名记住读到的目录文本,再次进入会话时卡片立即显示,同时卡片自己的查询会刷新启用状态。某个插件的第一次查询返回前,卡片用占位行和禁用的按钮占住标题、描述和操作区的位置。Session 投影恢复刷新后的选择状态。卸载时同时移除可选 Remote 和所有界面注册。
插件先挂载读取目录文本的 Remote,再向共享的卡片框架注册正文;框架把卡片放在所在回合之后,并提供卡片、已记录的决定和绑定到卡片的操作。正文向 Host 读取插件当前的目录文本和启用状态,渲染带「官方」标记的标题栏、详情点击区域、状态,以及通过框架操作发出的启用和暂不需要;不在可推荐目录里的名字不显示操作按钮。插件在自身生命周期内按插件名记住读到的目录文本,再次进入会话时卡片立即显示,同时卡片自己的查询会刷新启用状态。某个插件的第一次查询返回前,卡片显示提议的标题、一行描述占位和禁用的按钮,布局不跳动。Remote、正文注册和词典共用同一个插件生命周期。
卡片沿用应用字体和交付物卡片的边框与背景,通过标题、正文和独立操作区区分信息层级。构建入口将生成的 Remote 描述传给可直接从源码测试的挂载流程。
卡片沿用应用字体,通过标题、正文和独立操作区区分信息层级。构建入口将生成的 Remote 描述传给可直接从源码测试的挂载流程。
- [Source](src/client/index.ts)
- [Tests](tests/)
@@ -48,19 +48,17 @@ kind: "package-reference"
<a id="further-exploration"></a>
## 延伸阅读
- [推荐服务](../plugin-recommendations/README.zh.md) — 推荐范围、启用和持久化。
- [Session 投影](../../session/session-projection/README.zh.md) — 卡片读取的提案和卡片状态。
- [Slots](../../client/ui-slots/README.zh.md) — 回合末尾呈现。
- [推荐服务](../plugin-recommendations/README.zh.md) — 推荐范围、启用和决定。
- [操作卡片显示](../client-ui-action-cards/README.zh.md) — 框架、种类正文和过程行。
<a id="model-experience"></a>
## 模型体验
间接影响来自卡片操作:Host 推荐服务在下一次模型请求中追加一条已记录的状态通知。本界面不增加模型工具或提示词。
间接影响来自卡片操作:共享的 Host 卡片服务在下一次模型请求中追加一条已记录的状态通知。本界面不增加模型工具或提示词。
#### KV Cache effect
卡片不增加模型上下文。启用操作可能通过 Host 改变后续工具声明。
卡片不增加模型上下文。启用操作可能通过 Host 改变之后的工具声明。
## 已知限制与后续工作
@@ -8,7 +8,7 @@
"types": "lib/types/index.d.ts",
"license": "MIT",
"name": "@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations",
"description": "Completed-answer plugin recommendation cards",
"description": "Official plugin card body with catalog text and enabling",
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
@@ -33,10 +33,8 @@
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-chat",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-plugin-manager"
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-experimental-client-ui-action-cards"
]
}
},
@@ -47,12 +45,8 @@
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"react": "^18.2.0",
@@ -60,7 +54,11 @@
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-package-manifest": "workspace:*"
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/dsh-experimental-client-ui-action-cards": "workspace:*",
"@deepseek-ai/dsh-experimental-action-cards": "workspace:*",
"zod": "4.4.3",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*"
},
"files": [
"locale/*.json",
@@ -1,4 +1,3 @@
.card { margin: 16px 0; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); overflow: hidden; background: var(--dsw-alias-bg-layer-1); font-family: var(--dsw-font-family); color: var(--dsw-alias-label-primary); }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 20px; background: var(--dsw-alias-bg-module-platform); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
.badge { font-size: 12px; }
.body { padding: 20px; }
@@ -1,131 +1,144 @@
/** In-conversation card; catalog text comes from the Host and durable state from the Session projection. */
import { useEffect, useState } from 'react'
/** Body of an `enable_official_plugin` card: the plugin's catalog text, its details link, the user's choice and its outcome. */
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { z } from 'zod'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import type {
PluginRecommendation, RecommendationActionResult, RecommendationDetails, RecommendationView,
} from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { ActionCardBodyProps } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client'
import type { PluginDetails, RecommendationDetails } from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import type { RecommendationKey } from './locales.ts'
import css from './Card.module.css'
/** Catalog text the Host returned for an available plugin. */
export type CatalogDetails = Extract<RecommendationDetails, { readonly status: 'available' }>
/** Session-bound callbacks, language and the plugin-lifetime catalog text, without a service in the component. */
export interface CardInjected {
act: (callId: ToolCallId, action: 'enable' | 'dismiss') => Promise<RecommendationActionResult | undefined>
describe: (callId: ToolCallId) => Promise<RecommendationDetails | undefined>
/** Keep a plugin's catalog text so later cards of the same plugin render it at once. */
remember: (name: string, details: CatalogDetails) => void
openDetails: (packageName: string) => void
resolveText: (text: LocalizedText) => string
hooks: { locale: HostObservable<string>; catalog: HostObservable<Readonly<Record<string, CatalogDetails>>> }
/** Plugin-lifetime services the body uses besides the frame's props. */
export interface PluginCardServices {
/** Catalog text by plugin name, kept for this plugin's lifetime so later cards of the same plugin render it at once. */
readonly catalog: SnapshotStore<Readonly<Record<string, CatalogDetails>>>
/**
* Read the current catalog text of a card's plugin.
* @param sessionId - the displayed Session.
* @param callId - the card's call id.
* @returns the Host's answer, or undefined when the reply was lost.
*/
readonly describe: (sessionId: SessionId, callId: ToolCallId) => Promise<RecommendationDetails | undefined>
/** Open the plugin's details page without enabling it. */
readonly openDetails: (packageName: string) => void
/** Pick the interface language's text. */
readonly resolveText: (text: LocalizedText) => string
}
type CardProps = { recommendation: PluginRecommendation } & Pick<PropsRuntime<'conversation.chat.turnTail'>, 'useProjection'>
& PropsLocale<'pluginRecommendations'> & InjectFace<CardInjected>
/** Body props: the frame's card props plus the plugin-lifetime services. */
export type PluginCardProps = ActionCardBodyProps<'pluginRecommendations'> & PluginCardServices
/** The projection admits at most one proposal per turn; should several exist, the lowest call id is shown. */
function proposalOf(view: RecommendationView | undefined, turn: number): PluginRecommendation | undefined {
let found: PluginRecommendation | undefined
for (const proposal of Object.values(view?.proposals ?? {})) {
if (proposal.turn === turn && (found === undefined || proposal.callId < found.callId)) found = proposal
const detailsSchema = z.object({ name: z.string() }) satisfies z.ZodType<PluginDetails>
/** Recorded decision data the body reads; see `PluginData` for its meaning. */
const dataSchema = z.object({
application: z.enum(['restart-required', 'overridden', 'cancelled']).optional(), errorCode: z.string().optional(),
})
type CardData = z.infer<typeof dataSchema>
/** Refusal reasons with their own copy; any other reason reads as unavailable. */
const REASON_KEYS: readonly RecommendationKey[] = ['unknown', 'unavailable', 'access-denied', 'not-live-root', 'closing']
function reasonKey(reason: string): RecommendationKey {
return REASON_KEYS.find(key => key === reason) ?? 'unavailable'
}
/** Status copy of a recorded phase; `restart-required` and `overridden` refine enabled, `cancelled` refines failed. */
function statusKey(phase: string, data: CardData): RecommendationKey | undefined {
switch (phase) {
case 'dismissed': case 'replaced': case 'already-enabled': return phase
case 'enabled': return data.application === 'restart-required' || data.application === 'overridden' ? data.application : 'enabled'
case 'failed': return data.application === 'cancelled' ? 'cancelled' : 'failed'
default: return undefined
}
return found
}
/** Place the proposal admitted in a turn after that turn's final answer, once the turn has ended.
* @param props - Turn location, projection reader and session-bound actions.
* @returns the recommendation card, or null when none was offered.
/**
* Display one plugin card: its current catalog text, the model's reason, the user's choice and its outcome.
* Until the plugin's first catalog lookup returns, a placeholder line and the disabled actions hold the final layout.
* @param props - card, state, actions and plugin-lifetime services.
* @returns the card body.
*/
// Each optional bundle owns its card and locale contracts until the shared action-card layer owns this tail.
/* jscpd:ignore-start */
export function RecommendationTail({ turn, ...props }: Pick<PropsRuntime<'conversation.chat.turnTail'>, 'turn' | 'useProjection'>
& PropsLocale<'pluginRecommendations'> & InjectFace<CardInjected>) {
const view = props.useProjection('pluginRecommendations')
const recommendation = proposalOf(view, turn.turn)
const state = view?.decisions[recommendation?.callId ?? '']
return recommendation === undefined || state === 'suppressed' || turn.end === undefined || turn.data.get('turn-tail')?.closing == null
? null : <RecommendationCard {...props} recommendation={recommendation} />
}
/* jscpd:ignore-end */
/** Display one recommendation, its current catalog text, and the persisted outcome of the user's choice.
* Until the first catalog lookup of a plugin returns, placeholder lines and the disabled actions hold the final layout.
* @param props - Session-bound recommendation, actions and locale.
* @returns the card below its completed answer.
*/
export function RecommendationCard({
recommendation, useProjection, useLocale, useCatalog, act, describe, remember, openDetails, resolveText, t,
}: CardProps) {
useLocale(value => value)
const view = useProjection('pluginRecommendations')
const state = view?.decisions[recommendation.callId]
const cached = useCatalog(catalog => Object.hasOwn(catalog, recommendation.name) ? catalog[recommendation.name] : undefined)
export function PluginCardBody({
proposal, decision, ready, sessionId, summary, act, catalog, describe, openDetails, resolveText, t,
}: PluginCardProps) {
const details = detailsSchema.safeParse(proposal.details)
const name = details.success ? details.data.name : undefined
const subscribe = useCallback((listener: () => void) => catalog.subscribe(listener), [catalog])
const cached = useSyncExternalStore(subscribe, () => {
const entries = catalog.getSnapshot()
return name !== undefined && Object.hasOwn(entries, name) ? entries[name] : undefined
})
// This card's latest lookup: undefined while loading, null when the Host could not be reached.
const [lookup, setLookup] = useState<RecommendationDetails | null>()
const [pending, setPending] = useState(false)
const [error, setError] = useState<RecommendationKey | undefined>()
const [error, setError] = useState<RecommendationKey>()
const phase = decision?.phase
useEffect(() => {
if (name === undefined) return undefined
let current = true
describe(recommendation.callId).then(
describe(sessionId, proposal.callId).then(
(value) => {
if (!current) return
if (value?.status === 'available') remember(recommendation.name, value)
if (value?.status === 'available') catalog.set({ ...catalog.getSnapshot(), [name]: value })
setLookup(value ?? null)
},
() => { if (current) setLookup(null) },
)
return () => { current = false }
}, [describe, remember, recommendation.callId, recommendation.name, state])
}, [catalog, describe, sessionId, proposal.callId, name, phase])
if (name === undefined) return <div className={css.body}>{summary}</div>
// Remembered text shows at once; this card's own refusal or lost reply still decides what it offers.
const details = lookup === null || lookup?.status === 'refused' ? lookup : cached ?? lookup
const loading = details === undefined
const available = details?.status === 'available' ? details : undefined
const settled = state ?? (available?.enabled ? 'already-enabled' : undefined)
const open = (available !== undefined || loading) && !view?.invalid && (settled === undefined || settled === 'failed' || settled === 'cancelled')
const run = async (action: 'enable' | 'dismiss') => {
const current = lookup === null || lookup?.status === 'refused' ? lookup : cached ?? lookup
const loading = current === undefined
const available = current?.status === 'available' ? current : undefined
const parsed = dataSchema.safeParse(decision?.data ?? {})
const data: CardData = parsed.success ? parsed.data : {}
const status = phase === undefined ? available?.enabled ? 'already-enabled' : undefined : statusKey(phase, data)
const open = (available !== undefined || loading) && (phase === undefined ? available?.enabled !== true : phase === 'failed')
const failure = phase === 'failed' && data.errorCode !== undefined ? data.errorCode === 'incompatible-version' ? 'version-error' : 'management-error' : undefined
const title = available === undefined ? proposal.title : resolveText(available.title)
const run = async (verb: 'enable' | 'dismiss') => {
setPending(true)
setError(undefined)
try {
const result = await act(recommendation.callId, action)
const result = await act(verb)
if (result === undefined) setError('transport')
else if (result.status !== 'settled') setError(result.reason)
else if (result.errorCode !== undefined) setError(result.errorCode === 'incompatible-version' ? 'version-error' : 'management-error')
else if (result.status === 'refused' || result.status === 'unconfirmed') setError(reasonKey(result.reason))
} catch {
// The Host operation may have committed before a connection loss; retry is idempotent.
setError('transport')
} finally { setPending(false) }
}
const title = available === undefined ? recommendation.name : resolveText(available.title)
return <section className={css.card} aria-label={t('heading')} aria-busy={loading || undefined} data-plugin-recommendation={recommendation.callId}>
return <>
<header className={css.header}><span>{t('heading')}</span><span className={css.badge}>{t('official')}</span></header>
<div className={css.body}>
<button type="button" className={css.details} onClick={() => { openDetails(recommendation.name) }}
aria-label={t('detailsFor').replace('{title}', title)}>
<strong className={css.title}>{loading ? <span className={css.placeholder} /> : title}</strong>
<span className={css.reason}>{recommendation.reason}</span>
<div className={css.body} aria-busy={loading || undefined}>
<button type="button" className={css.details} onClick={() => { openDetails(name) }} aria-label={t('detailsFor').replace('{title}', title)}>
<strong className={css.title}>{title}</strong>
<span className={css.reason}>{proposal.brief}</span>
{(loading || available !== undefined) && <span className={css.description}>
{available === undefined ? <span className={css.placeholder} /> : resolveText(available.description)}
</span>}
</button>
{settled !== undefined && <p role="status" className={css.status}>{t(settled)}</p>}
{view?.invalid ? <p role="alert" className={css.status}>{t('invalid-history')}</p>
: details === null ? <p role="alert" className={css.status}>{t('transport')}</p>
: details?.status === 'refused' ? <p role="alert" className={css.status}>{t(details.reason)}</p>
: open && error !== undefined && <p role="alert" className={css.status}>{t(error)}</p>}
{status !== undefined && <p role="status" className={css.status}>{t(status)}</p>}
{failure !== undefined && <p role="alert" className={css.status}>{t(failure)}</p>}
{current === null ? <p role="alert" className={css.status}>{t('transport')}</p>
: current?.status === 'refused' ? <p role="alert" className={css.status}>{t(current.reason)}</p>
: open && error !== undefined && <p role="alert" className={css.status}>{t(error)}</p>}
{open && <footer className={css.footer}>
<span className={css.scope}>{t('scope')}</span>
<div className={css.actions}>
<Button variant="ghost" size="sm" disabled={pending || loading || view === undefined} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={pending || loading || view === undefined} onClick={() => { void run('enable') }}>
{t(pending ? 'enabling' : settled === undefined ? 'enable' : 'retry')}
<Button variant="ghost" size="sm" disabled={pending || loading || !ready} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={pending || loading || !ready} onClick={() => { void run('enable') }}>
{t(pending ? 'enabling' : phase === 'failed' ? 'retry' : 'enable')}
</Button>
</div>
</footer>}
</div>
</section>
</>
}
@@ -5,16 +5,14 @@ export const zh = {
detailsFor: '查看详情:{title}',
'version-error': '插件与当前 DSH 版本不兼容,请更新 DSH 后重试。',
'management-error': '无法启用插件,请打开详情检查错误后重试。',
'invalid-history': '无法读取此会话的推荐记录。请更新 DSH,或新建会话后重试。',
enable: '启用',
dismiss: '暂不需要',
enabling: '正在处理…',
scope: '启用后可在所有会话中使用',
applied: '已启用',
enabled: '已启用',
'already-enabled': '已在插件页开启,可查看详情了解运行状态',
dismissed: '已忽略',
replaced: '已替换',
suppressed: '未展示',
'restart-required': '已保存,重启 DSH 后生效',
overridden: '已保存,但被其他配置覆盖。请在插件页检查。',
failed: '启用失败。可重试或在插件页检查。',
@@ -38,16 +36,14 @@ export const en = {
detailsFor: 'View details: {title}',
'version-error': 'This plugin is incompatible with this DSH version. Update DSH and retry.',
'management-error': 'Could not enable the plugin. Open its details to check the error and retry.',
'invalid-history': 'This conversation has an unreadable recommendation receipt. Update DSH or try in a new conversation.',
enable: 'Enable',
dismiss: 'Not now',
enabling: 'Working…',
scope: 'Available in all conversations when enabled',
applied: 'Enabled',
enabled: 'Enabled',
'already-enabled': 'Already selected in Plugins. Open details to check its runtime status.',
dismissed: 'Dismissed',
replaced: 'Replaced',
suppressed: 'Not offered',
'restart-required': 'Saved. Restart DSH to apply.',
overridden: 'Saved, but another configuration overrides it. Check Plugins.',
failed: 'Could not enable. Retry or check Plugins.',
@@ -68,16 +64,14 @@ export const ja = {
detailsFor: '詳細を表示:{title}',
'version-error': 'このプラグインは現在の DSH バージョンと互換性がありません。DSH を更新してから再試行してください。',
'management-error': 'プラグインを有効にできませんでした。詳細を開いてエラーを確認し、再試行してください。',
'invalid-history': 'この会話の推薦記録を読み取れません。DSH を更新するか、新しい会話で試してください。',
enable: '有効にする',
dismiss: '今はしない',
enabling: '処理中…',
scope: '有効にするとすべての会話で使えます',
applied: '有効化済み',
enabled: '有効化済み',
'already-enabled': 'プラグイン画面で有効になっています。詳細で動作状況を確認できます。',
dismissed: '却下済み',
replaced: '置き換え済み',
suppressed: '非表示',
'restart-required': '保存しました。適用するには DSH を再起動してください。',
overridden: '保存しましたが、別の設定で上書きされています。プラグイン画面を確認してください。',
failed: '有効にできませんでした。再試行するか、プラグイン画面を確認してください。',
@@ -1,61 +1,49 @@
/** Register completed-answer recommendations and their localized actions. */
/** Register the official plugin card body, its catalog-text Remote and its localized copy. */
import { createElement } from 'react'
import type { Context } from '@deepseek-ai/cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import type {} from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client'
import type {} from '@deepseek-ai/dsh-experimental-plugin-recommendations/remote'
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import { RecommendationTail, type CardInjected, type CatalogDetails } from './Card.tsx'
import { PluginCardBody, type CatalogDetails, type PluginCardServices } from './Card.tsx'
import { en, ja, zh, type RecommendationKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { pluginRecommendations: RecommendationKey }
}
/** Registries available before this plugin mounts its optional Remote. */
export const inject = ['slots', 'locale', 'remote', 'pluginNavigation']
/** Services the body needs; the shared card plugin provides `actionCards`. */
export const inject = ['locale', 'remote', 'actionCards', 'pluginNavigation']
/** Mount the optional Remote and UI for the same plugin lifetime.
/** Mount the catalog-text Remote, then register the body for the same plugin lifetime.
* @param ctx - Client plugin context.
* @param contribution - Generated Remote descriptors supplied by the built entry.
* @returns disposer removing the UI before the Remote contribution.
* @returns disposer removing the body before the Remote contribution.
*/
export async function mountRecommendations(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
// Each independently optional bundle owns its Remote and dependent UI lifetime.
/* jscpd:ignore-start */
const unmount = await ctx.remote.$mount(contribution)
const ui = ctx.inject(['remote.pluginRecommendations'], registerUi)
try { await ui } catch (error) { await ui.dispose(); await unmount(); throw error }
return async () => { await ui.dispose(); await unmount() }
/* jscpd:ignore-end */
const body = ctx.inject(['remote.pluginRecommendations'], registerBody)
try { await body } catch (error) { await body.dispose(); await unmount(); throw error }
return async () => { await body.dispose(); await unmount() }
}
function registerUi(ctx: Context): void {
// Catalog text by plugin name for this plugin lifetime: a revisited card renders it before its own lookup returns.
const catalog = createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({})
function registerBody(ctx: Context): void {
const services: PluginCardServices = {
// Catalog text by plugin name for this plugin lifetime: a revisited card renders it before its own lookup returns.
catalog: createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({}),
describe: async (sessionId, callId) => {
const result = await ctx.remote.pluginRecommendations.describe(sessionId, callId)
return result.ok ? result.value : undefined
},
openDetails: (packageName) => { ctx.pluginNavigation.openBundle(packageName) },
resolveText: text => ctx.locale.resolveText(text),
}
ctx.effect(() => ctx.locale.register('pluginRecommendations', { en, zh, ja }), 'plugin-recommendations: locale')
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail', id: 'plugin-recommendation', locale: 'pluginRecommendations',
inject: (sessionId: SessionId): CardInjected => ({
openDetails: (packageName) => { ctx.pluginNavigation.openBundle(packageName) },
hooks: { locale: {
getSnapshot: () => ctx.locale.getSnapshot().active,
subscribe: listener => ctx.locale.subscribe(listener),
}, catalog },
remember: (name, details) => { catalog.set({ ...catalog.getSnapshot(), [name]: details }) },
resolveText: text => ctx.locale.resolveText(text),
describe: async (callId) => {
const result = await ctx.remote.pluginRecommendations.describe(sessionId, callId)
return result.ok ? result.value : undefined
},
act: async (callId, action) => {
const result = await ctx.remote.pluginRecommendations.act(sessionId, callId, action)
return result.ok ? result.value : undefined
},
}),
}, RecommendationTail))
ctx.effect(() => ctx.actionCards.register({
action: 'enable_official_plugin', locale: 'pluginRecommendations',
Body: props => createElement(PluginCardBody, { ...props, ...services }),
}), 'plugin-recommendations: card body')
}
@@ -1,64 +0,0 @@
/** Real registries validate session binding, the plugin-lifetime catalog and HMR removal. */
import { Context } from '@deepseek-ai/cordis'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { expect, it, onTestFinished, vi } from 'vitest'
import { mountRecommendations, inject } from '../src/client/mount.ts'
const apply = (ctx: Context) => mountRecommendations(ctx, { package: '@deepseek-ai/dsh-experimental-plugin-recommendations', descriptors: [] })
import { apply as hostApply } from '../src/index.ts'
import type { CardInjected } from '../src/client/Card.tsx'
it('binds actions to the displayed session and removes registrations on unload', async () => {
hostApply()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const openBundle = vi.fn()
ctx.provide('pluginNavigation', { openBundle })
await ctx.plugin(SlotRegistry)
const slots = ctx.slots
slots.register({ name: 'root', children: { 'conversation.chat.turnTail': {
kind: 'list', scope: 'session',
} } }, (_props: { renderSlot?: unknown }) => null)
const act = vi.fn().mockResolvedValue({ ok: true, value: { status: 'settled', outcome: 'applied' } })
const describe = vi.fn().mockResolvedValue({ ok: true, value: { status: 'refused', reason: 'unknown' } })
const unmount = vi.fn(async () => {})
ctx.provide('remote', { pluginRecommendations: { act, describe }, $mount: vi.fn(async () => unmount) })
ctx.provide('remote.pluginRecommendations', { act, describe })
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
const fiber = await ctx.plugin({ inject, apply })
const entry = slots.entries('conversation.chat.turnTail')[0]!
const resolve = entry.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => CardInjected)
const bound = resolve(SessionId('session-one'))
bound.openDetails('@deepseek-ai/dsh-experimental-badge-skill-bundle')
expect(openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-badge-skill-bundle')
expect(bound.hooks.locale.getSnapshot()).toBe(locale.getSnapshot().active)
const off = bound.hooks.locale.subscribe(vi.fn())
off()
expect(bound.resolveText({ en: 'Session search', zh: '会话搜索' })).toBe(locale.resolveText({ en: 'Session search', zh: '会话搜索' }))
expect(await bound.describe(ToolCallId('proposal'))).toEqual({ status: 'refused', reason: 'unknown' })
expect(describe).toHaveBeenCalledWith('session-one', 'proposal')
describe.mockResolvedValueOnce({ ok: false })
expect(await bound.describe(ToolCallId('proposal'))).toBeUndefined()
expect(await bound.act(ToolCallId('proposal'), 'enable')).toEqual({ status: 'settled', outcome: 'applied' })
expect(act).toHaveBeenCalledWith('session-one', 'proposal', 'enable')
act.mockResolvedValueOnce({ ok: false })
expect(await bound.act(ToolCallId('proposal'), 'dismiss')).toBeUndefined()
const details = { status: 'available', title: 'Search', description: 'Find past sessions', enabled: false } as const
bound.remember('official-search', details)
expect(resolve(SessionId('session-two')).hooks.catalog.getSnapshot()).toEqual({ 'official-search': details })
await fiber.dispose()
expect(unmount).toHaveBeenCalledOnce()
expect(slots.entries('conversation.chat.turnTail')).toHaveLength(0)
const remounted = await ctx.plugin({ inject, apply })
const fresh = slots.entries('conversation.chat.turnTail')[0]!.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => CardInjected)
expect(fresh(SessionId('session-one')).hooks.catalog.getSnapshot()).toEqual({})
await remounted.dispose()
vi.spyOn(locale, 'register').mockImplementation(() => { throw new Error('registration failed') })
const failed = ctx.plugin({ inject, apply })
await expect(failed.await()).rejects.toThrow('registration failed')
await failed.dispose()
expect(unmount).toHaveBeenCalledTimes(3)
})
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
/** The body registers into the shared card registry with its copy and Remote, and leaves all three on unload. */
import { act, cleanup, render, screen } from '@testing-library/react'
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ActionCardDescriptor } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client'
import { FoldedText } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/src/client/Card.tsx'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { mountRecommendations, inject } from '../src/client/mount.ts'
import { apply as hostApply } from '../src/index.ts'
const apply = (ctx: Context) => mountRecommendations(ctx, { package: '@deepseek-ai/dsh-experimental-plugin-recommendations', descriptors: [] })
afterEach(cleanup)
it('registers the plugin body, its copy and Remote for the plugin lifetime and shares catalog text between cards', async () => {
hostApply()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const unregister = vi.fn()
const register = vi.fn((_descriptor: ActionCardDescriptor<'pluginRecommendations'>) => unregister)
ctx.provide('actionCards', { register })
const openBundle = vi.fn()
ctx.provide('pluginNavigation', { openBundle })
const describe = vi.fn().mockResolvedValue({ ok: true, value: { status: 'available', title: { en: 'Session search' },
description: { en: 'Search past conversations' }, enabled: false } })
const unmount = vi.fn(async () => {})
ctx.provide('remote', { pluginRecommendations: { describe }, $mount: vi.fn(async () => unmount) })
ctx.provide('remote.pluginRecommendations', { describe })
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
const fiber = await ctx.plugin({ inject, apply })
const descriptor = register.mock.calls[0]![0]
expect(descriptor).toMatchObject({ action: 'enable_official_plugin', locale: 'pluginRecommendations' })
expect(locale.bind('pluginRecommendations')('heading')).toBe('Recommended plugin')
const proposal = { callId: ToolCallId('card'), turn: 1, action: 'enable_official_plugin', title: 'Search', brief: 'Find old sessions.',
details: { name: 'official-search' } }
const card = () => <descriptor.Body proposal={proposal} decision={undefined} ready sessionId={SessionId('session-one')} summary={null}
Folded={FoldedText} act={vi.fn()} t={locale.bind('pluginRecommendations')} />
render(card())
await act(async () => {})
expect(describe).toHaveBeenCalledWith('session-one', 'card')
screen.getByRole('button', { name: 'View details: Session search' }).click()
expect(openBundle).toHaveBeenCalledWith('official-search')
cleanup()
describe.mockReturnValueOnce(new Promise(() => {}))
render(card())
expect(screen.getByText('Search past conversations')).toBeTruthy()
cleanup()
describe.mockResolvedValueOnce({ ok: false })
render(card())
await act(async () => {})
expect(screen.getByRole('alert').textContent).toBe('Could not confirm the result. Refresh and try again.')
await fiber.dispose()
expect(unregister).toHaveBeenCalledOnce()
expect(unmount).toHaveBeenCalledOnce()
expect(locale.bind('pluginRecommendations')('heading')).toBe('heading')
vi.spyOn(locale, 'register').mockImplementation(() => { throw new Error('registration failed') })
const failed = ctx.plugin({ inject, apply })
await expect(failed.await()).rejects.toThrow('registration failed')
await failed.dispose()
expect(unmount).toHaveBeenCalledTimes(2)
})
@@ -1,51 +1,47 @@
// @vitest-environment jsdom
import { useSyncExternalStore, type ComponentProps } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { RecommendationCardState, RecommendationDetails, RecommendationView } from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore, type SnapshotSelectorHook } from '@deepseek-ai/dsh-client-store'
import { RecommendationCard, RecommendationTail, type CatalogDetails } from '../src/client/Card.tsx'
import { en, ja, zh, type RecommendationKey } from '../src/client/locales.ts'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import { FoldedText } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/src/client/Card.tsx'
import type { ActionCardFields, ActionCardResult } from '@deepseek-ai/dsh-experimental-action-cards/types'
import type { RecommendationDetails } from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import { PluginCardBody, type CatalogDetails, type PluginCardProps } from '../src/client/Card.tsx'
import { en, ja, zh } from '../src/client/locales.ts'
afterEach(cleanup)
type Props = ComponentProps<typeof RecommendationCard>
const callId = ToolCallId('call-plugin-1')
const ACTION = 'enable_official_plugin'
type Act = (verb: string, payload?: ActionCardFields) => Promise<ActionCardResult | undefined>
type Describe = PluginCardProps['describe']
const available: RecommendationDetails = {
status: 'available', title: { en: 'Session search', zh: '会话搜索' }, description: { en: 'Search past conversations', zh: '检索过去的会话' }, enabled: false,
}
const proposal = { callId, turn: 1, action: ACTION, title: '搜索旧会话', brief: '帮你找到上周讨论的方案', details: { name: 'official-search' } as ActionCardFields }
const recommendation = { callId: ToolCallId('block:m1'), name: 'official-search', reason: '帮你找到上周讨论的方案' }
/** The plugin-lifetime catalog mount.ts creates, with the selector hook the slot binds to it. */
function catalog() {
const store = createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({})
const useCatalog: SnapshotSelectorHook<Readonly<Record<string, CatalogDetails>>> = selector =>
useSyncExternalStore(listener => store.subscribe(listener), () => selector(store.getSnapshot()))
const remember = vi.fn((name: string, details: CatalogDetails) => { store.set({ ...store.getSnapshot(), [name]: details }) })
return { store, useCatalog, remember }
return createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({})
}
function props(state?: RecommendationCardState, details: RecommendationDetails = available, view?: Partial<RecommendationView>,
{ useCatalog, remember } = catalog()): Props {
function props(decision?: { phase: string; data?: ActionCardFields }, details: RecommendationDetails = available, store = catalog()) {
return {
recommendation,
useProjection: () => ({ decisions: state === undefined ? {} : { 'block:m1': state }, proposals: { 'block:m1': { ...recommendation, turn: 1 } }, ...view }),
useLocale: <Value,>(selector: (value: string) => Value) => selector('zh-CN'),
useCatalog, remember,
act: vi.fn(async () => ({ status: 'settled', outcome: 'applied' })),
describe: vi.fn(async () => details),
openDetails: vi.fn(),
proposal, decision: decision === undefined ? undefined : { callId, action: ACTION, ...decision }, ready: true,
sessionId: SessionId('session-1'), summary: <strong>{proposal.title}</strong>, Folded: FoldedText,
act: vi.fn<Act>(async () => ({ status: 'settled', decision: { callId, action: ACTION, phase: 'enabled' } })),
catalog: store, describe: vi.fn<Describe>(async () => details), openDetails: vi.fn<(name: string) => void>(),
resolveText: (text: LocalizedText) => typeof text === 'string' ? text : text.zh ?? text.en,
t: (key: RecommendationKey) => zh[key],
} as Props
t: makeTranslate(zh),
} satisfies PluginCardProps
}
async function shown(value: Props) {
render(<RecommendationCard {...value} />)
await waitFor(() => { expect(value.describe).toHaveBeenCalledWith('block:m1') })
async function shown(value: PluginCardProps) {
const view = render(<PluginCardBody {...value} />)
await waitFor(() => { expect(value.describe).toHaveBeenCalledWith('session-1', callId) })
await act(async () => {})
return view
}
it('keeps every dictionary aligned', () => {
@@ -53,50 +49,12 @@ it('keeps every dictionary aligned', () => {
expect(Object.keys(ja).sort()).toEqual(Object.keys(zh).sort())
})
it('shows the proposal admitted in a turn once that turn has ended for any reason, and none for other turns', async () => {
type Tail = ComponentProps<typeof RecommendationTail>
const value = props()
const getTurnData = vi.fn().mockReturnValue({ closing: {} })
const end = (reason: NonNullable<Tail['turn']['end']>['data']['reason']): Tail['turn']['end'] => ({ type: 'turn/end', seq: SessionSeq(1), time: 0, data: { turn: 1, reason } })
const tail: Tail = {
...value,
turn: {
turn: 1, start: undefined, end: end({ kind: 'completed' }), status: 'closed', steps: [],
data: { get: getTurnData, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
},
}
const view = (proposals: RecommendationView['proposals'], decisions: RecommendationView['decisions'] = {}): Tail['useProjection'] =>
() => ({ decisions, proposals })
const { rerender } = render(<RecommendationTail {...tail} useProjection={() => undefined} />)
expect(screen.queryByRole('region')).toBeNull()
rerender(<RecommendationTail {...tail} useProjection={view({ 'block:m3': { ...recommendation, callId: ToolCallId('block:m3'), turn: 2 } })} />)
expect(screen.queryByRole('region')).toBeNull()
const second = { ...recommendation, callId: ToolCallId('block:m2'), name: 'second', turn: 1 }
rerender(<RecommendationTail {...tail} useProjection={view({ 'block:m2': second, 'block:m1': { ...recommendation, turn: 1 } })} />)
expect(screen.getAllByRole('region')).toHaveLength(1)
expect(screen.getByRole('region').getAttribute('data-plugin-recommendation')).toBe('block:m1')
rerender(<RecommendationTail {...tail} useProjection={view({ 'block:m1': { ...recommendation, turn: 1 }, 'block:m2': second })} />)
expect(screen.getByRole('region').getAttribute('data-plugin-recommendation')).toBe('block:m1')
rerender(<RecommendationTail {...tail} useProjection={props('suppressed').useProjection} />)
expect(screen.queryByRole('region')).toBeNull()
for (const reason of [{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'interrupted' }, { kind: 'error', error: { code: 'UNKNOWN', message: 'fixture' } }] as const) {
rerender(<RecommendationTail {...tail} turn={{ ...tail.turn, end: end(reason) }} />)
expect(screen.getAllByRole('region')).toHaveLength(1)
}
rerender(<RecommendationTail {...tail} turn={{ ...tail.turn, end: undefined }} />)
expect(screen.queryByRole('region')).toBeNull()
getTurnData.mockReturnValue(undefined)
rerender(<RecommendationTail {...tail} />)
expect(screen.queryByRole('region')).toBeNull()
await act(async () => {})
})
it('shows the Host catalog text and reason as one details target, the scope, and enables only on click', async () => {
it('shows the catalog text and reason as one details target with the official badge, and enables only on click', async () => {
const value = props()
await shown(value)
expect(screen.getByRole('region', { name: '推荐插件' })).toBeTruthy()
expect(screen.getByText('推荐插件')).toBeTruthy()
expect(screen.getByText('官方')).toBeTruthy()
const details = screen.getByRole('button', { name: '查看详情:会话搜索' })
for (const text of ['会话搜索', '帮你找到上周讨论的方案', '检索过去的会话']) expect(within(details).getByText(text)).toBeTruthy()
expect(details.textContent).toBe('会话搜索帮你找到上周讨论的方案检索过去的会话')
expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([details.textContent, '暂不需要', '启用'])
expect(screen.getByText('启用后可在所有会话中使用')).toBeTruthy()
@@ -104,134 +62,149 @@ it('shows the Host catalog text and reason as one details target, the scope, and
fireEvent.click(details)
expect(value.openDetails).toHaveBeenCalledWith('official-search')
fireEvent.click(screen.getByRole('button', { name: '启用' }))
await waitFor(() => { expect(value.act).toHaveBeenCalledWith('block:m1', 'enable') })
await waitFor(() => { expect(value.act).toHaveBeenCalledExactlyOnceWith('enable') })
})
it('allows dismissal and disables both actions until the request settles', async () => {
const value = props()
const pending = Promise.withResolvers<Awaited<ReturnType<Props['act']>>>()
value.act = vi.fn(() => pending.promise)
const pending = Promise.withResolvers<ActionCardResult | undefined>()
value.act = vi.fn<Act>(() => pending.promise)
await shown(value)
fireEvent.click(screen.getByRole('button', { name: '暂不需要' }))
expect(screen.getByRole('button', { name: '正在处理…' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '暂不需要' }).hasAttribute('disabled')).toBe(true)
expect(value.act).toHaveBeenCalledWith('block:m1', 'dismiss')
await act(async () => { pending.resolve({ status: 'settled', outcome: 'dismissed' }) })
expect(value.act).toHaveBeenCalledWith('dismiss')
await act(async () => { pending.resolve({ status: 'settled', decision: { callId, action: ACTION, phase: 'dismissed' } }) })
})
it.each(['applied', 'dismissed', 'replaced', 'restart-required', 'overridden'] as const)('shows the settled %s state without actions', async (state) => {
await shown(props(state))
expect(screen.getByRole('status').textContent).toBe(zh[state])
it.each([
[{ phase: 'enabled' }, zh.enabled],
[{ phase: 'enabled', data: { application: 'restart-required' } }, zh['restart-required']],
[{ phase: 'enabled', data: { application: 'overridden' } }, zh.overridden],
[{ phase: 'already-enabled' }, zh['already-enabled']],
[{ phase: 'dismissed' }, zh.dismissed],
[{ phase: 'replaced' }, zh.replaced],
] as const)('shows the settled %j state without actions', async (decision, copy) => {
await shown(props(decision))
expect(screen.getByRole('status').textContent).toBe(copy)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
expect(screen.queryByRole('button', { name: '重试启用' })).toBeNull()
})
it.each(['failed', 'cancelled'] as const)('offers a retry after %s', async (state) => {
await shown(props(state))
expect(screen.getByRole('status').textContent).toBe(zh[state])
expect(screen.getByRole('button', { name: '重试启用' })).toBeTruthy()
it('names no status for a phase it does not know, and offers no action', async () => {
await shown(props({ phase: 'future-phase', data: { application: 42 } }))
expect(screen.queryByRole('status')).toBeNull()
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it('shows an enabled plugin as already enabled and a name outside the catalog without actions', async () => {
it.each([
[{ phase: 'failed' }, zh.failed, undefined],
[{ phase: 'failed', data: { application: 'cancelled' } }, zh.cancelled, undefined],
[{ phase: 'failed', data: { errorCode: 'incompatible-version' } }, zh.failed, zh['version-error']],
[{ phase: 'failed', data: { errorCode: 'operation-error' } }, zh.failed, zh['management-error']],
] as const)('offers a retry after %j', async (decision, status, alert) => {
const value = props(decision)
await shown(value)
expect(screen.getByRole('status').textContent).toBe(status)
expect(screen.queryByRole('alert')?.textContent).toBe(alert)
fireEvent.click(screen.getByRole('button', { name: '重试启用' }))
await waitFor(() => { expect(value.act).toHaveBeenCalledWith('enable') })
})
it('shows a plugin already selected in Plugins as enabled and a name outside the catalog without actions', async () => {
await shown(props(undefined, { ...available, enabled: true }))
expect(screen.getByRole('status').textContent).toBe(zh['already-enabled'])
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
cleanup()
await shown(props(undefined, { status: 'refused', reason: 'unavailable' }))
expect(screen.getByRole('alert').textContent).toBe(zh.unavailable)
expect(screen.getByText('official-search')).toBeTruthy()
expect(screen.getByText('搜索旧会话')).toBeTruthy()
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it('reports an unreachable Host and an unreadable history without actions', async () => {
it('reports an unreachable Host without actions', async () => {
const lost = props()
lost.describe = vi.fn(async () => undefined)
lost.describe = vi.fn<Describe>(async () => undefined)
await shown(lost)
expect(screen.getByRole('alert').textContent).toBe(zh.transport)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
cleanup()
const rejected = props()
rejected.describe = vi.fn(async () => { throw new Error('offline') })
rejected.describe = vi.fn<Describe>(async () => { throw new Error('offline') })
await shown(rejected)
expect(screen.getByRole('alert').textContent).toBe(zh.transport)
cleanup()
await shown(props(undefined, available, { invalid: true }))
expect(screen.getByRole('alert').textContent).toBe(zh['invalid-history'])
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it('keeps actions disabled until the card state loads', async () => {
const value = props()
value.useProjection = () => undefined
await shown(value)
await shown({ ...props(), ready: false })
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(true)
})
it('holds the title, description and action lines while the first catalog lookup is pending', async () => {
it('holds the description and action lines while the first catalog lookup is pending', async () => {
const loading = props()
const describe = Promise.withResolvers<RecommendationDetails | undefined>()
loading.describe = vi.fn(() => describe.promise)
const { container } = render(<RecommendationCard {...loading} />)
const region = screen.getByRole('region', { name: '推荐插件' })
expect(region.getAttribute('aria-busy')).toBe('true')
expect(screen.queryByText('official-search')).toBeNull()
expect(container.querySelectorAll('strong > span, button span > span')).toHaveLength(2)
expect(screen.getByText('帮你找到上周讨论的方案')).toBeTruthy()
const lookup = Promise.withResolvers<RecommendationDetails | undefined>()
loading.describe = vi.fn<Describe>(() => lookup.promise)
const { container } = render(<PluginCardBody {...loading} />)
const body = container.querySelector('[aria-busy="true"]')
expect(body).not.toBeNull()
expect(within(screen.getByRole('button', { name: '查看详情:搜索旧会话' })).getByText('搜索旧会话')).toBeTruthy()
expect(container.querySelectorAll('button span > span')).toHaveLength(1)
expect(screen.getByText('启用后可在所有会话中使用')).toBeTruthy()
for (const name of ['暂不需要', '启用']) expect(screen.getByRole('button', { name }).hasAttribute('disabled')).toBe(true)
await act(async () => { describe.resolve(available) })
expect(region.hasAttribute('aria-busy')).toBe(false)
await act(async () => { lookup.resolve(available) })
expect(body!.hasAttribute('aria-busy')).toBe(false)
expect(within(screen.getByRole('button', { name: '查看详情:会话搜索' })).getByText('检索过去的会话')).toBeTruthy()
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(false)
expect(loading.remember).toHaveBeenCalledExactlyOnceWith('official-search', available)
expect(loading.catalog.getSnapshot()).toEqual({ 'official-search': available })
cleanup()
const pending = props()
const left = props()
const late = Promise.withResolvers<RecommendationDetails | undefined>()
pending.describe = vi.fn(() => late.promise)
render(<RecommendationCard {...pending} />).unmount()
left.describe = vi.fn<Describe>(() => late.promise)
render(<PluginCardBody {...left} />).unmount()
await act(async () => { late.resolve(available) })
expect(pending.remember).not.toHaveBeenCalled()
expect(left.catalog.getSnapshot()).toEqual({})
const failing = props()
const rejected = Promise.withResolvers<RecommendationDetails | undefined>()
failing.describe = vi.fn(() => rejected.promise)
render(<RecommendationCard {...failing} />).unmount()
failing.describe = vi.fn<Describe>(() => rejected.promise)
render(<PluginCardBody {...failing} />).unmount()
await act(async () => { rejected.reject(new Error('late')) })
})
it('renders a remembered plugin at once on a later card while refusals and lost replies still decide its actions', async () => {
const shared = catalog()
await shown(props(undefined, available, undefined, shared))
expect(shared.store.getSnapshot()).toEqual({ 'official-search': available })
await shown(props(undefined, available, shared))
cleanup()
const revisit = props(undefined, available, undefined, shared)
const revisit = props(undefined, available, shared)
const lookup = Promise.withResolvers<RecommendationDetails | undefined>()
revisit.describe = vi.fn(() => lookup.promise)
render(<RecommendationCard {...revisit} />)
expect(screen.getByRole('region').hasAttribute('aria-busy')).toBe(false)
revisit.describe = vi.fn<Describe>(() => lookup.promise)
const { container } = render(<PluginCardBody {...revisit} />)
expect(container.querySelector('[aria-busy]')).toBeNull()
expect(within(screen.getByRole('button', { name: '查看详情:会话搜索' })).getByText('检索过去的会话')).toBeTruthy()
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(false)
await act(async () => { lookup.resolve({ ...available, enabled: true }) })
expect(screen.getByRole('status').textContent).toBe(zh['already-enabled'])
cleanup()
await shown(props(undefined, { status: 'refused', reason: 'unavailable' }, undefined, shared))
await shown(props(undefined, { status: 'refused', reason: 'unavailable' }, shared))
expect(screen.getByRole('alert').textContent).toBe(zh.unavailable)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
cleanup()
const lost = props(undefined, available, undefined, shared)
lost.describe = vi.fn(async () => undefined)
await shown(lost)
expect(screen.getByRole('alert').textContent).toBe(zh.transport)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it('renders only the frame summary when the card names no plugin', () => {
const value = { ...props(), proposal: { ...proposal, details: {} } }
render(<PluginCardBody {...value} />)
expect(screen.getByText('搜索旧会话')).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
expect(value.describe).not.toHaveBeenCalled()
})
it.each([
[undefined, zh.transport],
[{ status: 'refused', reason: 'access-denied' }, zh['access-denied']],
[{ status: 'refused', reason: 'read-only' }, zh.unavailable],
[{ status: 'unconfirmed', reason: 'closing' }, zh.closing],
[{ status: 'settled', outcome: 'failed', errorCode: 'incompatible-version' }, zh['version-error']],
[{ status: 'settled', outcome: 'failed', errorCode: 'operation-error' }, zh['management-error']],
] as const)('explains an unsuccessful click: %j', async (result, message) => {
const value = props()
value.act = vi.fn(async () => result)
value.act = vi.fn<Act>(async () => result)
await shown(value)
fireEvent.click(screen.getByRole('button', { name: '启用' }))
expect((await screen.findByRole('alert')).textContent).toBe(message)
@@ -239,7 +212,7 @@ it.each([
it('treats a lost reply as unconfirmed rather than failed', async () => {
const value = props()
value.act = vi.fn(async () => { throw new Error('connection reset') })
value.act = vi.fn<Act>(async () => { throw new Error('connection reset') })
await shown(value)
fireEvent.click(screen.getByRole('button', { name: '启用' }))
expect((await screen.findByRole('alert')).textContent).toBe(zh.transport)
@@ -8,9 +8,6 @@
"src"
],
"references": [
{
"path": "../../client/ui-renderer"
},
{
"path": "../../../vendor/cordis"
},
@@ -20,15 +17,6 @@
{
"path": "../../client/locale/tsconfig.client.json"
},
{
"path": "../../client/ui-conversation/tsconfig.client.json"
},
{
"path": "../../client/ui-chat/tsconfig.client.json"
},
{
"path": "../../client/ui-session"
},
{
"path": "../../client/ui-slots"
},
@@ -55,6 +43,12 @@
},
{
"path": "../../util/package-manifest"
},
{
"path": "../action-cards"
},
{
"path": "../client-ui-action-cards"
}
]
}
@@ -9,23 +9,23 @@
en: a59118891a7040bf
zh: 405bdb33f06112fa
/deepseek-ai-dsh-experimental-plugin-recommendations/summary:
en: 062f8e00e4b21695
zh: fbe8f6b8b50706fd
en: 9bf4699d4f05f511
zh: 33f63aeb459401b3
/deepseek-ai-dsh-experimental-plugin-recommendations/table-of-contents:
en: d152484eb41ac6b4
zh: 59094206ec49ad20
/deepseek-ai-dsh-experimental-plugin-recommendations/use-this-package:
en: 6a5925a4c8288845
zh: f703434207aabf4d
en: 63ea2d66d52802f2
zh: d255cd8ea2dc391d
/deepseek-ai-dsh-experimental-plugin-recommendations/use-this-package/outcomes-and-recovery:
en: f84aacebeaa41082
zh: 645daf481f1d5fc7
en: 4461d246f02849d3
zh: 0bbaf682f1ca2610
/deepseek-ai-dsh-experimental-plugin-recommendations/understand-the-implementation:
en: c0afaad68ef835fc
zh: be7624e028d58215
en: 5f77de16669dd4b0
zh: 14895a989167f399
/deepseek-ai-dsh-experimental-plugin-recommendations/further-exploration:
en: ade347158209b886
zh: 005d0555cc380c92
en: 716e44387bd2d3ea
zh: 1a58c0a36d4f18d7
/deepseek-ai-dsh-experimental-plugin-recommendations/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -33,17 +33,17 @@
en: 79e932b35e50619a
zh: 821b4d95d6ce1d22
/deepseek-ai-dsh-experimental-plugin-recommendations/model-experience/official-plugin-recommendations/what-the-model-sees:
en: 41179d43d288aaf4
zh: 75974cd8270410fd
en: f2a3c254850bdb76
zh: 16bf36f578734430
/deepseek-ai-dsh-experimental-plugin-recommendations/model-experience/official-plugin-recommendations/token-effect:
en: 6aedaa5e5956b24c
zh: 284d70285be785d7
en: 7d77c79ce8927492
zh: c287e0e4f5e22149
/deepseek-ai-dsh-experimental-plugin-recommendations/model-experience/official-plugin-recommendations/kv-cache-effect:
en: 086a06d4732a88cc
zh: 610597117579cfa4
en: e6575a3adcaa7d1c
zh: e5323a4571ecc8aa
/deepseek-ai-dsh-experimental-plugin-recommendations/known-limitations-and-deferred-work:
en: 0f2c45e2eef7a454
zh: 8f7014a055c7548e
en: c125168cfcac534a
zh: a47521944075543f
/deepseek-ai-dsh-experimental-plugin-recommendations/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Users can discover a relevant built-in plugin while discussing a concrete need. The Agent answers first, then offers one card at the completed Turn’s end; the user can open its plugin details, choose Enable, or choose Not now. Enabling changes the whole profile and persists across restarts. The Agent learns the result with the next request; clicking starts no model turn. Users can later disable or remove the plugin from its details page.
Users can discover a relevant built-in plugin while discussing a concrete need or asking how to do something in DSH. The Agent answers first, then offers one card at the completed Turn’s end; the user can open its plugin details, choose Enable, or choose Not now. Enabling changes the whole profile and persists across restarts. The Agent learns the result with the next request; clicking starts no model turn. Users can later disable or remove the plugin from its details page.
## Table of Contents
@@ -25,19 +25,13 @@ Users can discover a relevant built-in plugin while discussing a concrete need.
<a id="use-this-package"></a>
## Use this package
Open Plugins → More → Experimental plugins and enable Plugin recommendations. Shipped profiles leave this experimental bundle disabled. Enabling pins it to the main Plugins list; disabling returns it to the secondary page only. Its one configuration field, `queuedMessageLimit`, is the number of queued next-turn messages at or above which the current turn offers no card; the default is 2.
Open Plugins → More → Experimental plugins and enable Plugin recommendations. Shipped profiles leave this experimental bundle disabled. Enabling pins it to the main Plugins list; disabling returns it to the secondary page only. The bundle also enables the shared [action card service](../action-cards/README.md) and its browser half; that service's `queuedMessageLimit` sets how many queued next-turn messages stop the current turn's card. This bundle has no configuration of its own.
```yaml
- name: @deepseek-ai/dsh-experimental-plugin-recommendations
config:
queuedMessageLimit: 2
```
The experiment works with or without Personalized pages. Both read the same answer-block format through `@deepseek-ai/dsh-util-action-block`, and neither package depends on the other. Each admits at most one card per turn from its own blocks. For several independent needs, guidance writes one block per answer: an explicit user priority wins, and otherwise an existing plugin takes precedence over a new page; other needs stay in the answer. An answer that still carries both kinds of block shows both cards.
The experiment works with or without Personalized pages, and neither package depends on the other. Both bundles insert the same card-service rows, and the Loader keeps one entry for each. A Turn shows at most one card across both kinds: the first accepted `propose_action` call of the Turn becomes the card. For several independent needs, guidance follows an explicit user priority and otherwise prefers an existing plugin over a new page; other needs stay in the answer.
### Outcomes and recovery
The card appears once the Turn whose answer carries the block ends, for any end reason. Enable requires permission to modify the profile. The Host rechecks catalog eligibility before changing it. A saved selection can require restart or be overridden; failed and cancelled changes can be retried from the same card. A plugin already selected elsewhere settles as already enabled without another change. A later proposal replaces an undecided card, which then shows Replaced; a proposal written while `queuedMessageLimit` or more messages are queued, or after this experiment's card in the same turn, is never offered. A lost response leaves the outcome uncertain until the Session projection reconnects; repeated card actions are idempotent.
The card appears after the Turn of its accepted call ends. Enable requires permission to modify the profile. The Host rechecks the plugin's catalog eligibility when the user acts, so a card naming a plugin outside the eligible catalog says so and offers no Enable. A saved selection can require restart or be overridden; failed and cancelled changes can be retried from the same card. A plugin already selected elsewhere settles as already enabled without another change. A later proposal replaces an undecided card, which then shows Replaced; a proposal written while `queuedMessageLimit` or more messages are queued is never offered. A lost response leaves the outcome uncertain until the Session projection reconnects; repeated card actions are idempotent.
-----
@@ -47,11 +41,11 @@ The card appears once the Turn whose answer carries the block ends, for any end
<details>
<summary>Implementation internals — click to expand</summary>
The Host entry is a function plugin. Its `apply` assembles the internal `pluginRecommendations` Remote service with `ctx.plugin`. That service owns feature state, effect lifetimes and typed button calls; it is not a separately configurable or replaceable YAML entry, and adds no service to `dsh-base`. Disabling the feature disposes its internal service, skill provider and listeners and drains its owned asynchronous work. This follows the existing `command-feedback` composition of `sessionFeedback`.
The Host entry is a function plugin. Its `apply` assembles the internal `pluginRecommendations` Remote service with `ctx.plugin`; the service reads catalog text for cards and enables plugins for its card kind. It is not a separately configurable or replaceable YAML entry and adds no service to `dsh-base`. Disabling the feature removes its skill, card kind and Remote and drains its owned enabling work.
The plugin adds no model tool and no system-prompt section. It registers the `official-plugins` skill; loading it appends the current eligible catalog from `pluginManager`. A proposal is one fenced `dsh-action` JSON object with `action: "enable_official_plugin"`, `name` and `reason` at the end of the final answer. The Session projection reads the answer's last valid official-plugin block through `@deepseek-ai/dsh-util-action-block`, identifies the card by the message id, and applies the same per-turn reservation, queue suppression and replacement rules as Personalized pages, without depending on that package. Events a forked Session inherits create no proposals or receipts, so a fork starts without cards. The projection’s client view carries the admitted proposals with their Turns beside the card states. The card asks the Host for the plugin's current title, description and selection, so names outside the eligible catalog show no actions. Only shipped, optional, official plugins that are available, writable and error-free are eligible.
The plugin adds no model tool and no system-prompt section. It registers the `official-plugins` skill; loading it appends the current eligible catalog from `pluginManager`. It registers the `enable_official_plugin` kind with the shared [action card service](../action-cards/README.md): the `official-plugins` skill, `details` holding the plugin `name`, and the Enable verb, which requires global Remote access. The service owns the `propose_action` tool, the card projection, Not now, the Remote action and the status notice. This plugin enables the named plugin through Plugin Manager and records `enabled`, `already-enabled` or `failed`; the decision data carries `application` when the saved selection needs a restart, is overridden or was cancelled, and the management `errorCode` of a failed change. Not now is recorded for an unstarted or failed card. Only shipped, optional, official plugins that are available, writable and error-free are eligible; the name is checked when the user acts, not when the model proposes. The Remote `describe(sessionId, callId)` returns the current title, description and selection of a card's plugin, or a refusal.
Concurrent actions on one card share an operation, and disposal stops local follow-up without waiting on a profile mutation queued behind its own HMR unload. Plugin Manager owns an accepted mutation; an unconfirmed card rechecks the profile when clicked again. Profile files own installation state; experimental Session records own only the historical decision. Unreadable receipts block actions and show a recovery message without stalling other Session projections. Each decision is queued as a status notice for the next model request through the existing request-preparation hook; delivery is acknowledged only after the notice is logged.
Concurrent actions on one card share one operation, and disposal stops local follow-up without waiting on a profile mutation queued behind its own HMR unload. Plugin Manager owns an accepted mutation; an unconfirmed card rechecks the profile when clicked again. Profile files own installation state; the card decision records only the historical outcome.
- [Source](src/index.ts) — registration and service entry.
- [Tests](tests/) — actions, recovery and lifecycle evidence.
@@ -64,9 +58,9 @@ Concurrent actions on one card share an operation, and disposal stops local foll
## Further Exploration
- [Plugin manager](../../boot/plugin-manager/README.md) — profile changes.
- [Session projection](../../session/session-projection/README.md) — durable derived state.
- [Recommendation cards](../client-ui-plugin-recommendations/README.md) — completed-answer placement.
- [Personalized pages](../creation-recommendations/README.md) — the other card action, written in the same block format.
- [Action cards](../action-cards/README.md) — the card tool, card state and user actions.
- [Recommendation cards](../client-ui-plugin-recommendations/README.md) — the card body.
- [Personalized pages](../creation-recommendations/README.md) — the other card kind.
<a id="model-experience"></a>
## Model Experience
@@ -75,15 +69,15 @@ Concurrent actions on one card share an operation, and disposal stops local foll
#### What the model sees
The existing `skill` tool loads `official-plugins` when the user asks how to do or use something in DSH, asks whether DSH can do something, or says a capability is missing or should change, such as tools, input, displayed thinking or titles, session search, terminals, worktrees, agent teams, desktop control, automation, access, approval prompts, output badges or signatures, and plugins or plugin sources. The catalog entry tells the Agent that such a question is not a request to develop DSH and to load the skill before reading files or source. Loading returns matching policy, the block format, six short examples and the current catalog with names, localized titles, descriptions and enabled state. An enabled match gets usage guidance without a card; a partial match states its limit. Ordinary tasks, quoted wishes, discussion-only and declined requests get no card. The block is the submission; nothing changes until the user clicks Enable. The next request after a click carries a status notice: a fact, not a request for more work, and enabling authorizes nothing beyond that plugin.
The existing `skill` tool loads `official-plugins` when the user asks how to do or use something in DSH, asks whether DSH can do something, or says a capability is missing or should change, such as tools, input, displayed thinking or titles, session search, terminals, worktrees, agent teams, desktop control, automation, access, approval prompts, output badges or signatures, and plugins or plugin sources. The catalog entry tells the Agent that such a question is not a request to develop DSH and to load the skill before reading files or source. Loading returns matching policy, the submission rule, six short examples and the current catalog with names, localized titles, descriptions and enabled state. An enabled match gets usage guidance without a card; a partial match states its limit. Ordinary tasks, quoted wishes, discussion-only and declined requests get no card. The proposal is one [`propose_action`](../action-cards/README.md#model-experience) call in the same Turn as the skill load, with `action: "enable_official_plugin"`, the plugin's catalog `title`, a one-sentence `brief` in the user's language and `details` holding the catalog `name`; the call itself is the submission, and nothing changes until the user clicks Enable. The next request after a click carries the shared status notice with this kind's guidance: a fact, not a request for more work, and enabling authorizes nothing beyond that plugin.
#### Token effect
While enabled, requests carry only the 495-character catalog entry, within the skill catalog's 500-character limit; no tool schema or prompt section is added. A skill load appends about 10K characters: a 3.3K-character policy plus the catalog, which grows with the number of eligible plugins. A recommendation needs one skill load and no further model round trip: the block is part of the final answer. A changed card appends one short status notice to the next request.
While enabled, requests carry only the 495-character catalog entry, within the skill catalog's 500-character limit; the shared card tool is declared only after a card skill loads. A skill load appends about 10K characters: a 3.2K-character policy plus the catalog, which grows with the number of eligible plugins. A recommendation needs one skill load and one `propose_action` call before the final answer. A changed card appends one short status notice to the next request.
#### KV Cache effect
Skill contents and proposal blocks append to the transcript; the common system prompt and tool list are unchanged, so toggling this experiment changes only its catalog entry. Enabling a recommended plugin can change later tool declarations and their cache reuse.
Skill contents and the proposal call append to the transcript; the common system prompt is unchanged, and the card tool joins the declarations only after a card skill loads, as the [action cards](../action-cards/README.md#model-experience) page describes. Enabling a recommended plugin can change later tool declarations and their cache reuse.
## Known Limitations and Deferred Work
@@ -93,8 +87,7 @@ The manual real-model input set is in `evaluation/intent-cases.json`; unit tests
- Only shipped, optional, official plugins are eligible; missing packages and third-party registries are excluded.
- Recommendations require a live root conversation; there is no unsolicited opportunity discovery.
- The block parser is shared through `@deepseek-ai/dsh-util-action-block`; the turn rules, status notice and card tail are copies of the Personalized pages code until a shared action-card package owns them.
- A historical receipt does not continuously track changes later made on the Plugins page.
- A historical decision does not continuously track changes later made on the Plugins page.
<a id="dev-note"></a>
### Dev Note
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
用户讨论具体需求时,可以发现相关的内置插件。Agent 先正常回答,再在本轮完成后提供一张卡片;用户可以打开插件详情、点击启用或选择暂不需要。启用会修改整个 profile,并在重启后保留。Agent 在下一次请求时得知结果;点击本身不会开始新的模型轮次。用户之后可在插件详情页关闭或移除该插件。
用户讨论具体需求、或问 DSH 里某件事怎么做时,可以发现相关的内置插件。Agent 先正常回答,再在本轮完成后提供一张卡片;用户可以打开插件详情、点击启用或选择暂不需要。启用会修改整个 profile,并在重启后保留。Agent 在下一次请求时得知结果;点击本身不会开始新的模型轮次。用户之后可在插件详情页关闭或移除该插件。
## 目录
@@ -25,19 +25,13 @@ kind: "package-bundle"
<a id="use-this-package"></a>
## 使用此包
打开插件 → 更多 → 实验性插件,启用「插件推荐」。内置 profile 默认不启用这个实验 bundle。启用后它会固定显示在插件主列表;关闭后只回到二级页。唯一的配置项 `queuedMessageLimit` 表示排队等待后续轮次的消息达到多少条时,本轮不再出卡,默认为 2。
打开插件 → 更多 → 实验性插件,启用「插件推荐」。内置 profile 默认不启用这个实验 bundle。启用后它会固定显示在插件主列表;关闭后只回到二级页。这个 bundle 同时启用共享的[操作卡片服务](../action-cards/README.zh.md)及其浏览器端;该服务的 `queuedMessageLimit` 决定排队等待后续轮次的消息达到多少条时本轮不再出卡。本 bundle 没有自己的配置项。
```yaml
- name: @deepseek-ai/dsh-experimental-plugin-recommendations
config:
queuedMessageLimit: 2
```
本实验可以单独使用,也可以和「个性化页面」同时开启。两者通过 `@deepseek-ai/dsh-util-action-block` 读取同一种回答块格式,两个包互不依赖。各自每轮最多从自己的块出一张卡。多个独立需求时,指引要求一条回答只写一个块:用户明确的优先级优先,否则现成插件优先于新建页面;其余需求在回答正文中说明。一条回答若仍同时带两种块,两张卡都会显示。
本实验可以单独使用,也可以和「个性化页面」同时开启,两个包互不依赖。两个 bundle 插入相同的卡片服务行,Loader 对每个行 id 只保留一个条目。两种卡片合计每轮最多一张:本轮第一个被接受的 `propose_action` 调用成为卡片。多个独立需求时,指引优先遵循用户明确的优先级,否则现成插件优先于新建页面;其余需求在回答正文中说明。
### 结果与恢复
带块的回答所在的轮次结束后(无论以何种原因结束)卡片才会出现。启用需要修改 profile 的权限。Host 修改前会重新检查目录资格。已保存的选择可能需要重启,或被其他配置覆盖;失败和取消的变更可在同一张卡片上重试。已在别处开启的插件会直接记为已启用,不再改动。之后的新提案会替换尚未处理的卡片,旧卡片显示「已替换」;排队消息达到 `queuedMessageLimit` 时写下的提案,以及同一轮里本实验已出卡之后的提案,都不会展示。响应丢失时结果暂不确定,Session 投影重连后会恢复;重复点击卡片是幂等的。
被接受的调用所在的轮次结束后卡片才会出现。启用需要修改 profile 的权限。用户操作时 Host 会重新检查插件是否在可推荐目录里,所以卡片上的名字不在目录里时会说明原因,不提供启用。已保存的选择可能需要重启,或被其他配置覆盖;失败和取消的变更可在同一张卡片上重试。已在别处开启的插件会直接记为已启用,不再改动。之后的新提案会替换尚未处理的卡片,旧卡片显示「已替换」;排队消息达到 `queuedMessageLimit` 时写下的提案不会展示。响应丢失时结果暂不确定,Session 投影重连后会恢复;重复点击卡片是幂等的。
-----
@@ -47,11 +41,11 @@ kind: "package-bundle"
<details>
<summary>实现细节——点击展开</summary>
Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的 `pluginRecommendations` Remote service。该 service 负责功能状态、effect 生命周期和类型化按钮调用;它不是可单独配置或替换的 YAML 条目,也不会向 `dsh-base` 添加 service。关闭功能会一并释放内部 service、skill provider 和监听器,并等待其拥有的异步工作结束。这沿用了现有 `command-feedback` 组装 `sessionFeedback` 的做法。
Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的 `pluginRecommendations` Remote service;该 service 为卡片读取目录文本,并为本卡片种类启用插件。它不是可单独配置或替换的 YAML 条目,也不会向 `dsh-base` 添加 service。关闭功能会移除它的 skill、卡片种类和 Remote,并等待它拥有的启用操作结束。
本插件不添加模型工具,也不添加 system prompt 段。它注册 `official-plugins` skill,加载时追加来自 `pluginManager` 的当前可推荐目录。提案是最终回答末尾的一个 `dsh-action` 围栏 JSON 对象,含 `action: "enable_official_plugin"`、`name` 和 `reason`。Session 投影用共享的块解析器取回答中最后一个合法的官方插件块,以消息 id 标识卡片,并使用相同的每轮预留、排队抑制和替换规则。fork 出的 Session 继承的事件不产生提案或回执,因此 fork 后没有卡片。投影的客户端视图在卡片状态旁带上已接纳的提案及其所在轮次。卡片向 Host 读取插件当前的标题、描述和启用状态,因此不在可推荐目录里的名字不会显示操作按钮。只有已随安装提供、可选、官方、可读取、可写且无错误的插件可以推荐。
本插件不添加模型工具,也不添加 system prompt 段。它注册 `official-plugins` skill,加载时追加来自 `pluginManager` 的当前可推荐目录。它向共享的[操作卡片服务](../action-cards/README.zh.md)注册 `enable_official_plugin` 种类:skill 为 `official-plugins`,`details` 带插件的 `name`,动作「启用」需要全局 Remote 权限。`propose_action` 工具、卡片投影、「暂不需要」、Remote 操作和状态通知都由共享服务负责。本插件通过 Plugin Manager 启用指定插件,记录 `enabled`、`already-enabled` 或 `failed`;保存的选择需要重启、被覆盖或被取消时,决定数据带 `application`,变更失败时带插件管理的 `errorCode`。未处理和失败的卡片可以记为「暂不需要」。只有已随安装提供、可选、官方、可读取、可写且无错误的插件可以推荐;名字在用户操作时检查,而不是在模型提议时。Remote `describe(sessionId, callId)` 返回卡片对应插件当前的标题、描述和启用状态,或拒绝原因。
同一张卡上的并发操作共用一次执行;卸载时停止本地后续处理,不等待排在自身 HMR 卸载之后的 profile 变更。已接纳的变更由 Plugin Manager 负责完成;结果未确认的卡片再次点击时会重新检查 profile。安装状态由 profile 文件负责,实验性 Session 记录只保存历史决定。无法读取的回执会阻止操作并显示恢复提示,但不会卡住其他 Session 投影。每个决定都会通过现有的请求准备钩子排入下一次模型请求的状态通知;通知写入日志后才确认送达。
同一张卡上的并发操作共用一次执行;卸载时停止本地后续处理,不等待排在自身 HMR 卸载之后的 profile 变更。已接纳的变更由 Plugin Manager 负责完成;结果未确认的卡片再次点击时会重新检查 profile。安装状态由 profile 文件负责,卡片决定只记录历史结果。
- [源码](src/index.ts) — 注册与服务入口。
- [测试](tests/) — 操作、恢复和生命周期验证。
@@ -64,9 +58,9 @@ Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的
## 进一步阅读
- [插件管理器](../../boot/plugin-manager/README.zh.md) — 配置档案变更。
- [Session 投影](../../session/session-projection/README.zh.md) — 持久化派生状态。
- [推荐卡片](../client-ui-plugin-recommendations/README.zh.md) — 回答末尾的推荐入口。
- [个性化页面](../creation-recommendations/README.zh.md) — 另一种卡片动作,使用同一种块格式。
- [操作卡片](../action-cards/README.zh.md) — 卡片工具、卡片状态和用户操作。
- [推荐卡片](../client-ui-plugin-recommendations/README.zh.md) — 卡片正文。
- [个性化页面](../creation-recommendations/README.zh.md) — 另一种卡片。
<a id="model-experience"></a>
## 模型体验
@@ -75,15 +69,15 @@ Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的
#### 模型可见内容
用户问 DSH 里某件事怎么做或怎么用、DSH 能不能做某件事,或者说缺少某种能力、希望它换一种工作方式时,例如工具、输入方式、思考内容或标题的显示、搜索会话、终端、工作树、多智能体协作、桌面操作、自动化、访问权限、授权确认、输出徽章或签名、插件及插件源,现有的 `skill` 工具会加载 `official-plugins`。目录条目告诉 Agent 这类问题不是要开发 DSH,并要求在读取文件或源码之前先加载它。加载结果包含匹配规则、块格式、六个简短示例,以及当前目录中的名称、本地化标题、描述和启用状态。已启用的插件只说明用法、不出卡;只覆盖部分需求时说明限制。普通任务、引用中的愿望、只讨论和明确不要推荐的请求不出卡。写下块即提交;用户点击启用前什么都不会改变。点击后的下一次请求会带一条状态通知:它是事实,不是继续工作的要求,启用一个插件也不授权该插件之外的任何事。
用户问 DSH 里某件事怎么做或怎么用、DSH 能不能做某件事,或者说缺少某种能力、希望它换一种工作方式时,例如工具、输入方式、思考内容或标题的显示、搜索会话、终端、工作树、多智能体协作、桌面操作、自动化、访问权限、授权确认、输出徽章或签名、插件及插件源,现有的 `skill` 工具会加载 `official-plugins`。目录条目告诉 Agent 这类问题不是要开发 DSH,并要求在读取文件或源码之前先加载它。加载结果包含匹配规则、提交方式、六个简短示例,以及当前目录中的名称、本地化标题、描述和启用状态。已启用的插件只说明用法、不出卡;只覆盖部分需求时说明限制。普通任务、引用中的愿望、只讨论和明确不要推荐的请求不出卡。提案是在加载 skill 的同一轮里调用一次 [`propose_action`](../action-cards/README.zh.md#model-experience):`action: "enable_official_plugin"`、插件在目录中的 `title`、一句用户语言的 `brief`,以及带目录 `name` 的 `details`;调用本身就是提交,用户点击启用前什么都不会改变。点击后的下一次请求会带共享的状态通知和本种类的说明:它是事实,不是继续工作的要求,启用一个插件也不授权该插件之外的任何事。
#### Token 影响
启用期间,每次请求只多一条 495 字符的目录条目(在 skill 目录 500 字符的上限内),不增加工具 schema 或 prompt 段。一次 skill 加载追加约 1 万字符:3.3K 字符的规则,加上随可推荐插件数量增长的目录。一次推荐只需加载一次 skill,不需要额外的模型往返:块就在最终回答里。状态变化的卡片会在下一次请求追加一条简短通知。
启用期间,每次请求只多一条 495 字符的目录条目(在 skill 目录 500 字符的上限内);共享的卡片工具只在加载过卡片 skill 之后才声明。一次 skill 加载追加约 1 万字符:3.2K 字符的规则,加上随可推荐插件数量增长的目录。一次推荐需要加载一次 skill,并在最终回答前调用一次 `propose_action`。状态变化的卡片会在下一次请求追加一条简短通知。
#### KV Cache 影响
Skill 内容和提案块追加到对话记录;公共 system prompt 和工具列表不变,所以开关本实验只改变它的目录条目。启用被推荐的插件可能改变之后的工具声明及其缓存复用。
Skill 内容和提案调用追加到对话记录;公共 system prompt 不变,卡片工具只在加载过卡片 skill 之后才加入声明,见[操作卡片](../action-cards/README.zh.md#model-experience)。启用被推荐的插件可能改变之后的工具声明及其缓存复用。
## 已知限制与后续工作
@@ -93,8 +87,7 @@ Skill 内容和提案块追加到对话记录;公共 system prompt 和工具
- 只推荐已随安装提供、可选的官方插件;缺失的包和第三方注册源不在范围内。
- 推荐需要在线的根会话;不会主动寻找推荐机会。
- 块解析器通过 `@deepseek-ai/dsh-util-action-block` 共享;每轮规则、状态通知和卡片尾部组件仍是「个性化页面」代码的副本,之后由共享动作卡包统一负责。
- 历史回执不会持续跟踪之后在插件页做的修改。
- 历史决定不会持续跟踪之后在插件页做的修改。
<a id="dev-note"></a>
### 开发备注
@@ -1,4 +1,8 @@
- insert:
- id: action-cards
name: '@deepseek-ai/dsh-experimental-action-cards'
- id: client-ui-action-cards
name: '@deepseek-ai/dsh-experimental-client-ui-action-cards'
- id: plugin-recommendations
name: '@deepseek-ai/dsh-experimental-plugin-recommendations'
- id: client-ui-plugin-recommendations
@@ -121,6 +121,6 @@
{
"id": "two_existing_plugins",
"prompt": "我看展开的英文思考很费劲,也希望每个 PR 都有 DSH 标识。两个现成插件都给推荐入口,先不要启用。",
"expected": "Select one eligible plugin before calling suggest_plugin, explain the other need, no rejected second call or claim that both cards exist."
"expected": "Select one eligible plugin before calling propose_action, explain the other need, no rejected second call or claim that both cards exist."
}
]
@@ -41,7 +41,6 @@
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-skill": "workspace:*"
},
"devDependencies": {
@@ -50,7 +49,6 @@
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
@@ -58,8 +56,9 @@
},
"dependencies": {
"zod": "4.4.3",
"@deepseek-ai/dsh-util-action-block": "workspace:*",
"@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations": "workspace:*"
"@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-action-cards": "workspace:*",
"@deepseek-ai/dsh-experimental-client-ui-action-cards": "workspace:*"
},
"files": [
"icon.svg",
@@ -1,6 +1,6 @@
# Official plugins
Use this workflow in the main conversation when the user wants DSH itself to work differently or gain an ability. The catalog at the end lists DSH's built-in optional plugins and whether each is enabled. A `dsh-action` block at the end of your final answer shows an Enable card; nothing changes until the user clicks Enable. Match from the catalog descriptions alone: do not read files, configuration or source, call plugin management tools, or change settings yourself.
Use this workflow in the main conversation when the user wants DSH itself to work differently or gain an ability. The catalog at the end lists DSH's built-in optional plugins and whether each is enabled. A `propose_action` call shows an Enable card below your answer; nothing changes until the user clicks Enable. Match from the catalog descriptions alone: do not read files, configuration or source, call plugin management tools, or change settings yourself.
## When to offer
@@ -20,11 +20,7 @@ A turn shows at most one card across every card skill. When the user asks for se
## Offer without enabling
To offer, end your final answer with exactly one fenced code block tagged `dsh-action` whose content is one JSON object: `action` is `"enable_official_plugin"`, `name` is the exact catalog name, and `reason` is one sentence in the user's language that ties the plugin to their need. The block is the submission: the Host shows the card after your answer, an invalid or missing block shows nothing, and nothing follows the block. Before the block, answer any direct question and add at most one short invitation such as “点击下方启用即可。”; never write the invitation without the block. Do not narrate the block, repeat the catalog description, add setup steps the catalog does not state, claim the plugin is enabled, or ask the user to report back. The card opens the plugin's details; enabling applies to every conversation and can be undone there. The user's choice reaches you later as a status notice; enabling a plugin authorizes nothing beyond it.
```dsh-action
{"action":"enable_official_plugin","name":"@deepseek-ai/dsh-experimental-voice-input-bundle","reason":"说话即可转成输入框里的文字,适合口述较长的需求。"}
```
To offer, call `propose_action` once, in the same turn as loading this skill, with `action: "enable_official_plugin"`, `title` set to the plugin's catalog title in the user's language, `brief` set to one sentence in the user's language that ties the plugin to their need, and `details` holding `name`, the exact catalog name. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer: answer any direct question and add at most one short invitation such as “点击下方启用即可。”; never write the invitation without an accepted call. Do not narrate the call, repeat the catalog description, add setup steps the catalog does not state, claim the plugin is enabled, or ask the user to report back. The card opens the plugin's details; enabling applies to every conversation and can be undone there. The user's choice reaches you later as a status notice; enabling a plugin authorizes nothing beyond it.
## Examples
@@ -1,175 +1,133 @@
/** Explicit-need official plugin recommendations: an answer's dsh-action block proposes, and only a human card action enables. */
/** Explicit-need official plugin recommendations: an `enable_official_plugin` card offers one plugin, and only a human click enables it. */
import { z } from 'zod'
import { FiberState, Service, type Context } from '@deepseek-ai/cordis'
import { Service, type Context } from '@deepseek-ai/cordis'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm'
import { appendPluginRecord, SessionSeq } from '@deepseek-ai/dsh-session'
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { ManagementError } from '@deepseek-ai/dsh-plugin-manager/types'
import { isPluginStopping } from '@deepseek-ai/dsh-experimental-action-cards'
import type { ActionCardDecision, ActionCardProposalOf, ActionCardResult } from '@deepseek-ai/dsh-experimental-action-cards'
import type {} from '@deepseek-ai/dsh-plugin-manager'
import type {} from '@deepseek-ai/dsh-api-remote-access'
import type {} from '@deepseek-ai/dsh-skill'
import { eligible, registerOfficialPluginsSkill } from './skill.ts'
import { recommendationProjection, type RecommendationState } from './projection.ts'
import type { RecommendationActionResult, RecommendationDetails, RecommendationOutcome } from './types.ts'
import type { PluginData, PluginDetails, PluginPhase, RecommendationDetails } from './types.ts'
export type * from './types.ts'
export const name = 'plugin-recommendations'
/** Deployment-tunable limits for offering cards. */
export const Config = z.object({
/** Queued next-turn messages at or above which a proposal written in the current turn is never offered. */
queuedMessageLimit: z.number().int().min(1).default(2),
}).prefault({})
/** Validated card limits after schema defaults. */
export type Config = z.infer<typeof Config>
declare module '@deepseek-ai/cordis' {
interface Context { pluginRecommendations: PluginRecommendations }
}
/** Card action this plugin registers. */
const ACTION = 'enable_official_plugin'
const detailsSchema = z.object({ name: z.string().trim().min(1) }) satisfies z.ZodType<PluginDetails>
type PluginProposal = ActionCardProposalOf<PluginDetails>
/** Own entries only: browser-supplied ids such as `toString` never reach inherited object members. */
function ownEntry<T>(record: Readonly<Record<string, T>>, key: string): T | undefined {
return Object.hasOwn(record, key) ? record[key] : undefined
}
/** Internal Remote and effect owner assembled by the plugin-recommendation feature. */
function settled(decision: ActionCardDecision): ActionCardResult {
return { status: 'settled', decision }
}
function refused(reason: string): ActionCardResult {
return { status: 'refused', reason }
}
/** Catalog lookups for card text and the enabling behind the `enable_official_plugin` card. */
export class PluginRecommendations extends TypertRemoteService {
static inject = ['pluginManager', 'agents', 'sessionProjections', 'remoteAccess', 'skills']
private projection!: ProjectionHandle<RecommendationState>
private readonly operations = new Map<string, Promise<RecommendationActionResult>>()
static inject = ['pluginManager', 'agents', 'skills', 'actionCards']
private readonly operations = new Set<Promise<ActionCardResult>>()
private readonly stopped = Promise.withResolvers<undefined>()
private closing = false
private readonly owner: Context['fiber']
constructor(ctx: Context, private readonly config: Config) {
constructor(ctx: Context) {
super(ctx, 'pluginRecommendations')
this.owner = ctx.fiber
}
protected async [Service.init](): Promise<void> {
this.projection = await this.ctx.sessionProjections.register(recommendationProjection(this.config))
protected [Service.init](): void {
registerOfficialPluginsSkill(this.ctx)
// Same as personalized pages until the shared action-card layer owns status notices.
/* jscpd:ignore-start */
this.ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted || this.isStopping()) return decision
const state = this.projection.stateOf(agent.session)
const pending = Object.entries(state.pendingStatus)
if (pending.length === 0) return decision
const updates = pending.map(([callId]) => ({
plugin: ownEntry(state.view.proposals, callId)?.name, outcome: ownEntry(state.view.decisions, callId),
}))
return { ...decision, messages: [...decision.messages, createUserMessage({
source: { kind: 'plugin-recommendations', through: SessionSeq(Math.max(...pending.map(([, seq]) => seq))),
form: 'notice', summary: 'Official plugin status updated' },
content: [{ type: 'text', text: 'Latest official plugin card status from the user’s clicks. These are status facts, not a request for more work. '
+ 'applied means the plugin is enabled now; restart-required means it is saved and takes effect after DSH restarts; overridden means another configuration keeps it off; '
+ 'failed or cancelled means it is not enabled and can be retried from its card; already-enabled means it was on already; dismissed means the user declined it, so do not offer it again in this conversation. '
+ 'Enabling a plugin authorizes nothing beyond it. Continue with the user’s current request.\n' + JSON.stringify(updates) }],
})] }
}, { prepend: true })
/* jscpd:ignore-end */
this.ctx.actionCards.register({
action: ACTION, skill: 'official-plugins', details: detailsSchema,
verbs: { enable: { global: true } },
openPhases: ['failed'],
statusGuidance: 'Plugin cards: enabled means the plugin is on now, or turns on after a DSH restart when application is restart-required, '
+ 'while application overridden means another configuration keeps it off; already-enabled means it was on before; failed can be retried from its card; '
+ 'dismissed means the user declined it, so do not offer it again in this conversation; enabling authorizes nothing beyond that plugin.',
act: (agent, proposal) => {
const operation = this.enable(agent, proposal)
this.operations.add(operation)
return operation.finally(() => { this.operations.delete(operation) })
},
})
this.ctx.effect(() => async () => {
this.closing = true
this.stopped.resolve(undefined)
await Promise.allSettled([...this.operations.values()])
}, 'plugin-recommendations: drain card actions')
await Promise.allSettled([...this.operations])
}, 'plugin-recommendations: drain enabling')
}
/**
* Read the current catalog text for a logged proposal's card.
* Read the current catalog text for a logged card.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the block names no available official plugin.
* @param callId - The card's `propose_action` call id, never a package supplied by the browser.
* @returns the plugin's title, description and selection, or a refusal when the card names no available official plugin.
*/
@Remote
async describe(agent: Agent, callId: ToolCallId): Promise<RecommendationDetails> {
if (this.isStopping()) return { status: 'refused', reason: 'closing' }
if (!this.ctx.agents.roots().includes(agent)) return { status: 'refused', reason: 'not-live-root' }
const proposal = ownEntry(this.projection.stateOf(agent.session).view.proposals, callId)
if (proposal === undefined) return { status: 'refused', reason: 'unknown' }
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === proposal.name)
const proposal = ownEntry(this.ctx.actionCards.view(agent.session).proposals, callId)
const details = proposal?.action === ACTION ? detailsSchema.safeParse(proposal.details) : undefined
if (details?.success !== true) return { status: 'refused', reason: 'unknown' }
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === details.data.name)
if (bundle === undefined || !eligible(bundle)) return { status: 'refused', reason: 'unavailable' }
return { status: 'available', title: bundle.meta?.title ?? bundle.name,
description: bundle.meta?.description ?? bundle.description ?? '', enabled: bundle.enabled }
}
/**
* Decide one logged recommendation. Concurrent actions on the same card share
* one operation; a settled success or dismissal never enables twice. Failed
* activation remains retryable. Profile selection survives session disposal.
* @param agent - Exact live root resolved by the Gateway from the Session id.
* @param callId - Proposal id from the answer block, never a package supplied by the browser.
* @param action - Human choice.
* @returns An observed outcome, a refusal before mutation, or an unconfirmed result if reload interrupts the wait.
*/
@Remote
act(agent: Agent, callId: ToolCallId, action: 'enable' | 'dismiss'): Promise<RecommendationActionResult> {
// The Agent lookup covers the Session. Enabling also touches the whole profile.
if (action === 'enable' && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve({ status: 'refused', reason: 'access-denied' })
if (this.isStopping()) return Promise.resolve({ status: 'refused', reason: 'closing' })
if (!this.ctx.agents.roots().includes(agent)) return Promise.resolve({ status: 'refused', reason: 'not-live-root' })
const key = JSON.stringify([agent.id, callId])
const running = this.operations.get(key)
if (running !== undefined) return running
const operation = this.ctx.agents.withInitiator(agent, () => this.decide(agent, callId, action))
.finally(() => { this.operations.delete(key) })
this.operations.set(key, operation)
return operation
}
/** Parent disposal starts before its child effects drain. */
// Each optional bundle owns its lifetime check until the shared action-card layer owns card actions.
/* jscpd:ignore-start */
private isStopping(): boolean {
let owner = this.owner
while (true) {
if (owner.uid === null || owner.state === FiberState.UNLOADING) return true
const parent = owner.parent.fiber
if (parent === owner) return this.closing
owner = parent
}
return isPluginStopping(this.owner, this.closing)
}
/* jscpd:ignore-end */
private async decide(agent: Agent, callId: ToolCallId, action: 'enable' | 'dismiss'): Promise<RecommendationActionResult> {
const state = this.projection.stateOf(agent.session)
if (state.view.invalid) return { status: 'refused', reason: 'invalid-history' }
const proposal = ownEntry(state.view.proposals, callId)
if (proposal === undefined) return { status: 'refused', reason: 'unknown' }
const previous = ownEntry(state.view.decisions, callId)
if (previous !== undefined && previous !== 'failed' && previous !== 'cancelled') return { status: 'settled', outcome: previous }
let outcome: RecommendationOutcome = 'dismissed'
let errorCode: ManagementError['code'] | undefined
if (action === 'enable') {
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === proposal.name)
if (bundle === undefined || !eligible(bundle)) return { status: 'refused', reason: 'unavailable' }
if (this.isStopping()) return { status: 'refused', reason: 'closing' }
if (!this.ctx.agents.roots().includes(agent)) return { status: 'refused', reason: 'not-live-root' }
if (bundle.enabled) outcome = 'already-enabled'
else {
// PluginManager owns the accepted profile mutation. HMR may unload this
// consumer before it finishes, so only our receipt wait is cancelled.
const result = await Promise.race([this.ctx.pluginManager.setBundleEnabled(bundle.name, true), this.stopped.promise])
if (result === undefined || this.isStopping()) return { status: 'unconfirmed', reason: 'closing' }
outcome = result.application
errorCode = result.error?.code
}
}
// Profile files own the configuration; this receipt owns the card's decision and the model's status notice.
appendPluginRecord(agent.session, 'plugin:plugin-recommendations/decision', { callId, outcome })
return { status: 'settled', outcome, ...errorCode === undefined ? {} : { errorCode } }
private record(agent: Agent, callId: ToolCallId, phase: PluginPhase, data: PluginData = {}): ActionCardResult {
const fields = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined))
const decision: ActionCardDecision = { callId, action: ACTION, phase, ...Object.keys(fields).length === 0 ? {} : { data: fields } }
this.ctx.actionCards.record(agent, decision)
return settled(decision)
}
/** Enable the card's plugin once; an enabled card settles unchanged and a failed one can be retried. */
private async enable(agent: Agent, proposal: PluginProposal): Promise<ActionCardResult> {
const previous = ownEntry(this.ctx.actionCards.view(agent.session).decisions, proposal.callId)
if (previous !== undefined && previous.phase !== 'failed') return settled(previous)
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === proposal.details.name)
if (bundle === undefined || !eligible(bundle)) return refused('unavailable')
if (this.isStopping()) return refused('closing')
if (!this.ctx.agents.roots().includes(agent)) return refused('not-live-root')
if (bundle.enabled) return this.record(agent, proposal.callId, 'already-enabled')
// PluginManager owns the accepted profile mutation. HMR may unload this
// consumer before it finishes, so only our wait for its result is cancelled.
const result = await Promise.race([this.ctx.pluginManager.setBundleEnabled(bundle.name, true), this.stopped.promise])
if (result === undefined || this.isStopping()) return { status: 'unconfirmed', reason: 'closing' }
const { application } = result
return this.record(agent, proposal.callId, application === 'failed' || application === 'cancelled' ? 'failed' : 'enabled', {
application: application === 'applied' || application === 'failed' ? undefined : application, errorCode: result.error?.code,
})
}
}
/**
* Assemble the feature-owned Remote; disposing the feature also disposes its effects.
* @param ctx - Owning feature context.
* @param config - Validated card limits; the Loader supplies them, and direct composition gets the schema defaults.
* Register the official-plugins skill, the `enable_official_plugin` card kind and the catalog-text Remote;
* disposing the plugin stops local follow-up of pending enabling and removes all three.
* @param ctx - Owning plugin context.
*/
export async function apply(ctx: Context, config: Config = Config.parse({})): Promise<void> {
await ctx.plugin(PluginRecommendations, config)
export async function apply(ctx: Context): Promise<void> {
await ctx.plugin(PluginRecommendations)
}
@@ -1,125 +0,0 @@
/** Validate answer blocks and fold card decisions without scanning session history. */
import { z } from 'zod'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { pluginRecordOf, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { actionBlocksOf } from '@deepseek-ai/dsh-util-action-block'
import type { PluginRecommendation, RecommendationOutcome } from './types.ts'
const proposalSchema = z.object({
callId: z.string().min(1).transform(ToolCallId),
name: z.string().min(1), reason: z.string().min(1),
turn: z.number().int(),
}).strict()
const blockSchema = z.object({
action: z.literal('enable_official_plugin'), name: z.string().trim().min(1), reason: z.string().trim().min(1),
}).strict()
/** Read a proposal from a logged answer: its last valid official-plugin block. Blocks of other actions are ignored.
* @param event - logged session event.
* @returns the proposal, when the event is an answer carrying a valid block.
*/
export function recommendationOf(event: SessionEvent): PluginRecommendation | undefined {
if (event.type !== 'assistant/message') return undefined
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
const last = actionBlocksOf(text).flatMap((value) => {
const parsed = blockSchema.safeParse(value)
return parsed.success ? [parsed.data] : []
}).at(-1)
return last === undefined ? undefined : { callId: ToolCallId(`block:${event.data.message.id}`), name: last.name, reason: last.reason }
}
const outcomeSchema: z.ZodType<RecommendationOutcome> = z.enum([
'dismissed', 'applied', 'already-enabled', 'restart-required', 'overridden', 'failed', 'cancelled',
])
/** Card states and admitted proposals; the client reads both, and the Host validates actions against the proposals. */
const viewSchema = z.object({
decisions: z.record(z.string(), z.union([outcomeSchema, z.enum(['replaced', 'suppressed'])])),
proposals: z.record(z.string(), proposalSchema),
invalid: z.literal(true).optional(),
})
// Same fold state as personalized pages until the shared action-card layer owns card folds.
/* jscpd:ignore-start */
/** Cache validation for proposals and experimental decision records. */
export const stateSchema = z.object({
/** Fork-inherited prefix length; inherited events create no proposals or receipts. */
inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset),
view: viewSchema,
turn: z.number(), firstCall: z.string().nullable(),
/** Decided cards whose status the model has not received, with the receipt sequence. */
pendingStatus: z.record(z.string(), z.number()),
/** User messages still queued for later turns, folded from inbox splices. */
queued: z.number(),
})
/* jscpd:ignore-end */
/** Validated proposals, card states and undelivered status receipts. */
export type RecommendationState = z.infer<typeof stateSchema>
/** Card limits the fold needs; the owning service supplies its validated configuration. */
export interface RecommendationLimits { readonly queuedMessageLimit: number }
type Definition = ProjectionDefinition<'pluginRecommendations', RecommendationState>
/** Definition returned by {@link recommendationProjection}: the fold plus its client view wire. */
export type RecommendationProjectionDefinition = Omit<Definition, 'wire'> & {
readonly wire: NonNullable<Definition['wire']>
}
/** Whole-log fold; late decisions survive card pagination and reconnect, and a later proposal replaces an undecided card.
* Events inherited by a forked Session create no proposals or receipts.
* @param limits - validated card limits.
* @returns the projection definition for this configuration.
*/
export function recommendationProjection(limits: RecommendationLimits): RecommendationProjectionDefinition {
return {
key: 'pluginRecommendations', stateVersion: 6, stateSchema,
init: (_header, inheritedEventCount) => ({
inheritedEventCount, view: { decisions: {}, proposals: {} }, turn: 0, firstCall: null, pendingStatus: {}, queued: 0,
}),
apply: (state, event) => {
// Same as personalized pages until the shared action-card layer owns card folds.
/* jscpd:ignore-start */
if (event.seq < state.inheritedEventCount) return state
if (event.type === 'turn/start') return { ...state, turn: event.data.turn, firstCall: null }
if (event.type === 'agent/inbox/spliced') {
if (event.data.target !== 'next-turn') return state
return { ...state, queued: Math.max(0, state.queued - (event.data.removedCount ?? 0) + event.data.inserted.length) }
}
if (event.type === 'user/message' && event.data.source.kind === 'plugin-recommendations') {
const through = event.data.source.through
return { ...state, pendingStatus: Object.fromEntries(Object.entries(state.pendingStatus).filter(([, seq]) => seq > through)) }
}
/* jscpd:ignore-end */
const record = pluginRecordOf(event)
if (record?.type === 'plugin:plugin-recommendations/decision') {
const parsed = z.object({ callId: z.string().min(1), outcome: outcomeSchema }).safeParse(record.data)
if (!parsed.success) return { ...state, view: { ...state.view, invalid: true } }
const { callId, outcome } = parsed.data
return { ...state, view: { ...state.view, decisions: { ...state.view.decisions, [callId]: outcome } },
pendingStatus: { ...state.pendingStatus, [callId]: event.seq } }
}
const proposal = recommendationOf(event)
if (proposal === undefined) return state
const firstCall = state.firstCall ?? proposal.callId
// One card per turn: this experiment's later block in the same turn creates nothing.
// Blocks of other actions never displace a plugin card.
if (firstCall !== proposal.callId) return state
const decisions = { ...state.view.decisions }
const proposals = { ...state.view.proposals, [proposal.callId]: { ...proposal, turn: state.turn } }
// Queued user messages mean the user has moved on; the proposal is logged but never offered.
if (state.queued >= limits.queuedMessageLimit) {
decisions[proposal.callId] = 'suppressed'
return { ...state, firstCall, view: { ...state.view, decisions, proposals } }
}
// A newer proposal supersedes every undecided card; the model owns that choice, so no status notice is queued.
for (const earlier of Object.values(state.view.proposals)) {
if (earlier.callId !== proposal.callId && decisions[earlier.callId] === undefined) decisions[earlier.callId] = 'replaced'
}
return { ...state, firstCall, view: { ...state.view, decisions, proposals } }
},
wire: { summary: false, viewSchema, view: (state: RecommendationState) => state.view },
}
}
@@ -13,6 +13,14 @@ export function eligible(bundle: BundleInfo): boolean {
&& bundle.readOnlyReason === undefined && bundle.error === undefined
}
/** The eligible plugins as the skill lists them: name, title, description and selection. */
function catalogOf(bundles: readonly BundleInfo[]): string {
return JSON.stringify(bundles.filter(eligible).map(bundle => ({
name: bundle.name, title: bundle.meta?.title ?? bundle.name,
description: bundle.meta?.description ?? bundle.description ?? '', enabled: bundle.enabled,
})))
}
/** Register the experiment's skill without adding a model tool or a system-prompt section.
* @param ctx - Owning plugin context with skill and plugin registries.
*/
@@ -20,30 +28,18 @@ export function registerOfficialPluginsSkill(ctx: Context): void {
const candidate: SkillCandidate = {
name: 'official-plugins',
description: 'Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers.',
provider: 'dsh-official-plugins', source: 'bundled', rank: BUNDLED_SKILL_RANK, locator: null,
invocation: { modelInvocable: true, userInvocable: false },
provider: 'dsh-official-plugins', source: 'bundled', rank: BUNDLED_SKILL_RANK, locator: null,
}
// Same as personalized pages until the shared action-card layer owns catalog skills.
/* jscpd:ignore-start */
ctx.skills.registerProvider(() => ({
name: candidate.provider,
list: () => Promise.resolve([candidate]),
async get(_candidate, options) {
const instructions = await readFile(new URL('../skills/official-plugins.md', import.meta.url), {
encoding: 'utf8', signal: options.signal,
})
const bundles = await ctx.pluginManager.listBundles()
options.signal?.throwIfAborted()
const { rank: _rank, locator: _locator, ...summary } = candidate
return {
...summary,
content: instructions + '\n## Official plugins\n\n'
+ JSON.stringify(bundles.filter(eligible).map(bundle => ({
name: bundle.name, title: bundle.meta?.title ?? bundle.name,
description: bundle.meta?.description ?? bundle.description ?? '', enabled: bundle.enabled,
}))),
}
async get(_candidate, { signal }) {
const policy = await readFile(new URL('../skills/official-plugins.md', import.meta.url), { encoding: 'utf8', signal })
const catalog = catalogOf(await ctx.pluginManager.listBundles())
signal?.throwIfAborted()
const { rank: _rank, locator: _locator, ...entry } = candidate
return { ...entry, content: `${policy}\n## Official plugins\n\n${catalog}` }
},
}))
/* jscpd:ignore-end */
}
@@ -1,73 +1,25 @@
/** Durable facts for user-requested official plugin recommendations. */
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types'
import type { ManagementError } from '@deepseek-ai/dsh-plugin-manager/types'
/** Fields, recorded data and catalog text of `enable_official_plugin` cards. */
import type { ChangeResult, ManagementError } from '@deepseek-ai/dsh-plugin-manager/types'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
/** A `dsh-action` block naming one official plugin; the card reads the plugin's catalog text from the Host. */
export interface PluginRecommendation {
readonly callId: ToolCallId
/** Package name as written in the block; enabling checks it against the current catalog. */
/** Card fields the skill asks the model to write in `details`. */
export interface PluginDetails {
/** Package name as written in the catalog; enabling checks it against the current catalog. */
readonly name: string
/** Model explanation of how this plugin helps with the user's need. */
readonly reason: string
}
/** Selection and runtime application are deliberately separate. */
export type RecommendationOutcome = 'dismissed' | 'applied' | 'already-enabled' | 'restart-required' | 'overridden' | 'failed' | 'cancelled'
/** Phases a plugin card records besides the shared `dismissed`. */
export type PluginPhase = 'enabled' | 'already-enabled' | 'failed'
/** Replacement and suppression are derived from the log, never written as decisions: a later proposal replaces an undecided card;
* a proposal written while user messages are queued, or after this experiment's card in the same turn, is never offered. */
export type RecommendationCardState = RecommendationOutcome | 'replaced' | 'suppressed'
/** A completed user action. A failure remains retryable from the card. */
export interface RecommendationDecision {
readonly callId: ToolCallId
readonly outcome: RecommendationOutcome
/** Data each plugin decision records; profile files own the actual configuration. */
export interface PluginData {
/** Present when the saved selection did not apply at once (`restart-required`, `overridden`) or the change was `cancelled`. */
readonly application?: Exclude<ChangeResult['application'], 'applied' | 'failed'> | undefined
/** Plugin manager error code of a failed change; its diagnostic text is not recorded. */
readonly errorCode?: ManagementError['code'] | undefined
}
/** Full-session card states and admitted proposals. */
export interface RecommendationView {
readonly decisions: Readonly<Record<string, RecommendationCardState>>
/** Every admitted proposal by call id, with the turn whose answer carried it. */
readonly proposals: Readonly<Record<string, PluginRecommendation & { readonly turn: number }>>
/** An unreadable receipt blocks actions without breaking other Session projections. */
readonly invalid?: true | undefined
}
/** Catalog text for a card, read when it is displayed; a name outside the catalog gets no card text. */
/** Catalog text for a card, read when it is displayed; a name outside the eligible catalog gets no card text. */
export type RecommendationDetails =
| { readonly status: 'available'; readonly title: LocalizedText; readonly description: LocalizedText; readonly enabled: boolean }
| { readonly status: 'refused'; readonly reason: 'unknown' | 'unavailable' | 'not-live-root' | 'closing' }
/** Explicit refusals never change the profile. */
export type RecommendationActionResult =
| { readonly status: 'settled'; readonly outcome: RecommendationCardState; readonly errorCode?: ManagementError['code'] }
| { readonly status: 'refused'; readonly reason: 'unknown' | 'unavailable' | 'access-denied' | 'not-live-root' | 'closing' | 'invalid-history' }
| { readonly status: 'unconfirmed'; readonly reason: 'closing' }
declare module '@deepseek-ai/dsh-session/types' {
interface PluginRecordMap {
/** Informational receipt of a profile change; profile files own the actual configuration. */
'plugin:plugin-recommendations/decision': RecommendationDecision
}
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Open-session card states, omitted from session listing summaries. */
pluginRecommendations: SessionProjectionSummaryExcluded<RecommendationView>
}
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** Status receipt attribution; other readers preserve the recorded text without this plugin.
* The producer uses through only to acknowledge delivered updates.
* @persistenceAttribution
*/
'plugin-recommendations': { kind: 'plugin-recommendations'; through: SessionSeq } & ContextFormed
}
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`official recommendations > offers the live catalog through a skill, adds no tools, and withdraws it on unload > skill discovery 1`] = `
exports[`official recommendations > offers the live catalog through a skill, adds no tools of its own, and withdraws the skill and card kind on unload > skill discovery 1`] = `
[
{
"description": "Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers.",
@@ -15,11 +15,11 @@ exports[`official recommendations > offers the live catalog through a skill, add
]
`;
exports[`official recommendations > offers the live catalog through a skill, adds no tools, and withdraws it on unload > skill policy and current catalog 1`] = `
exports[`official recommendations > offers the live catalog through a skill, adds no tools of its own, and withdraws the skill and card kind on unload > skill policy and current catalog 1`] = `
{
"content": "# Official plugins
Use this workflow in the main conversation when the user wants DSH itself to work differently or gain an ability. The catalog at the end lists DSH's built-in optional plugins and whether each is enabled. A \`dsh-action\` block at the end of your final answer shows an Enable card; nothing changes until the user clicks Enable. Match from the catalog descriptions alone: do not read files, configuration or source, call plugin management tools, or change settings yourself.
Use this workflow in the main conversation when the user wants DSH itself to work differently or gain an ability. The catalog at the end lists DSH's built-in optional plugins and whether each is enabled. A \`propose_action\` call shows an Enable card below your answer; nothing changes until the user clicks Enable. Match from the catalog descriptions alone: do not read files, configuration or source, call plugin management tools, or change settings yourself.
## When to offer
@@ -39,11 +39,7 @@ A turn shows at most one card across every card skill. When the user asks for se
## Offer without enabling
To offer, end your final answer with exactly one fenced code block tagged \`dsh-action\` whose content is one JSON object: \`action\` is \`"enable_official_plugin"\`, \`name\` is the exact catalog name, and \`reason\` is one sentence in the user's language that ties the plugin to their need. The block is the submission: the Host shows the card after your answer, an invalid or missing block shows nothing, and nothing follows the block. Before the block, answer any direct question and add at most one short invitation such as “点击下方启用即可。”; never write the invitation without the block. Do not narrate the block, repeat the catalog description, add setup steps the catalog does not state, claim the plugin is enabled, or ask the user to report back. The card opens the plugin's details; enabling applies to every conversation and can be undone there. The user's choice reaches you later as a status notice; enabling a plugin authorizes nothing beyond it.
\`\`\`dsh-action
{"action":"enable_official_plugin","name":"@deepseek-ai/dsh-experimental-voice-input-bundle","reason":"说话即可转成输入框里的文字,适合口述较长的需求。"}
\`\`\`
To offer, call \`propose_action\` once, in the same turn as loading this skill, with \`action: "enable_official_plugin"\`, \`title\` set to the plugin's catalog title in the user's language, \`brief\` set to one sentence in the user's language that ties the plugin to their need, and \`details\` holding \`name\`, the exact catalog name. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer: answer any direct question and add at most one short invitation such as “点击下方启用即可。”; never write the invitation without an accepted call. Do not narrate the call, repeat the catalog description, add setup steps the catalog does not state, claim the plugin is enabled, or ask the user to report back. The card opens the plugin's details; enabling applies to every conversation and can be undone there. The user's choice reaches you later as a status notice; enabling a plugin authorizes nothing beyond it.
## Examples
@@ -1,10 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`reports the card outcome on the next request without waking the conversation > applied plugin status 1`] = `
exports[`reports the card outcome with the plugin guidance on the next request without waking the conversation > restart-required plugin status 1`] = `
[
{
"text": "Latest official plugin card status from the user’s clicks. These are status facts, not a request for more work. applied means the plugin is enabled now; restart-required means it is saved and takes effect after DSH restarts; overridden means another configuration keeps it off; failed or cancelled means it is not enabled and can be retried from its card; already-enabled means it was on already; dismissed means the user declined it, so do not offer it again in this conversation. Enabling a plugin authorizes nothing beyond it. Continue with the user’s current request.
[{"plugin":"official-search","outcome":"applied"}]",
"text": "Latest status of action cards from user actions and background work. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Plugin cards: enabled means the plugin is on now, or turns on after a DSH restart when application is restart-required, while application overridden means another configuration keeps it off; already-enabled means it was on before; failed can be retried from its card; dismissed means the user declined it, so do not offer it again in this conversation; enabling authorizes nothing beyond that plugin. Continue with the user’s current request.
[{"action":"enable_official_plugin","callId":"call-plugin-1","title":"Search","phase":"enabled","data":{"application":"restart-required"}}]",
"type": "text",
},
]
@@ -1,38 +1,45 @@
/** Real Agent, skills and projections with a controlled profile manager. */
/** Real Agent, Tools, skills, card service and projections with a controlled profile manager. */
import { Context } from '@deepseek-ai/cordis'
import { onTestFinished, vi } from 'vitest'
import { MessageId, ToolCallId, type AssistantMessage } from '@deepseek-ai/dsh-llm'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import Skills from '@deepseek-ai/dsh-skill'
import * as SkillTool from '@deepseek-ai/dsh-tool-skill'
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
import ActionCards from '@deepseek-ai/dsh-experimental-action-cards'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager/types'
import * as PluginRecommendations from '../src/index.ts'
import { recommendationProjection } from '../src/projection.ts'
export const ACTION = 'enable_official_plugin'
export const bundle: BundleInfo = {
name: 'official-search', official: true, optional: true, availability: 'installation',
enabled: false, installed: false, removable: false, updatable: false, rows: [], overrides: [],
meta: { title: { en: 'Search', zh: '搜索' }, description: 'Search past conversations.' },
}
export const callId = ToolCallId('block:answer-1')
export const callId = ToolCallId('call-plugin-1')
export const proposal = { title: 'Search', brief: 'Search your conversation history.', details: { name: bundle.name } }
/** One assistant answer carrying the given dsh-action objects, as the model writes it.
* @param id - message id; the card id is `block:<id>`.
* @param blocks - objects written into separate fences, in order.
* @param turn - turn recorded on the message.
* @returns the logged message event payload.
/**
* Log a successful `propose_action` call, as the card tool would after the skill load.
* @param ctx - context with the agent registry.
* @param agent - agent whose Session receives the call.
* @param id - tool call id of the card.
* @param args - call arguments besides `action`.
* @param action - card action.
* @returns the card's call id.
*/
export function answer(id: string, blocks: readonly object[], turn = 1): SessionEvent<'assistant/message'>['data'] {
const text = ['下方可以直接启用。', ...blocks.map(block => '```dsh-action\n' + JSON.stringify(block) + '\n```')].join('\n\n')
const message: AssistantMessage = { role: 'assistant', id: MessageId(id), content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'fixture', model: 'fixture' } }
return { turn, step: 1, message, stream: [] }
export function logProposal(ctx: Context, agent: Agent, id = 'call-plugin-1', args: object = proposal, action = ACTION): ToolCallId {
const turn = Math.max(1, ctx.agents.turnBoundaryOf(agent.session).lastTurn)
const toolCallId = ToolCallId(id)
agent.session.append('tool/call', { turn, step: 1, callId: toolCallId, name: 'propose_action', arguments: JSON.stringify({ action, ...args }) })
agent.session.append('tool/result', { turn, step: 1, message: createToolResultMessage({ callId: toolCallId,
content: [{ type: 'text', text: 'Card shown below your answer.' }], isError: false }) }, { surfaceOp: 'append' })
return toolCallId
}
export const block = { action: 'enable_official_plugin', name: bundle.name, reason: 'Search your conversation history.' }
export async function fixture() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
@@ -41,6 +48,7 @@ export async function fixture() {
await ctx.plugin(RemoteAccess)
await ctx.plugin(Skills)
await ctx.plugin(SkillTool)
await ctx.plugin(ActionCards)
const manager = {
listBundles: vi.fn(async (): Promise<BundleInfo[]> => [bundle]),
setBundleEnabled: vi.fn(async (): Promise<ChangeResult> => ({ application: 'applied', changed: true, stage: 'enable', target: bundle.name, enabled: true })),
@@ -51,12 +59,10 @@ export async function fixture() {
const agent = handle.agent
const events: SessionEvent[] = []
ctx.on('session/event', (session, event) => { if (session === agent.session) events.push(event) })
const projection = recommendationProjection(PluginRecommendations.Config.parse({}))
const state = () => events.reduce(projection.apply, projection.init(agent.session.header, agent.session.inheritedEventCount))
/** Log an answer whose last block names the official plugin. */
const propose = (id = 'answer-1', blocks: readonly object[] = [block], caller = agent) => {
caller.session.append('assistant/message', answer(id, blocks, Math.max(1, state().turn)), { surfaceOp: 'append' })
return ToolCallId('block:' + id)
const card = (id: ToolCallId = callId, caller: Agent = agent) => {
const decisions = ctx.actionCards.view(caller.session).decisions
return Object.hasOwn(decisions, id) ? decisions[id] : undefined
}
return { ctx, agent, handle, manager, fiber, events, state, propose }
const propose = (id?: string, args?: object, caller: Agent = agent) => logProposal(ctx, caller, id, args)
return { ctx, agent, handle, manager, fiber, events, card, propose }
}
@@ -23,5 +23,7 @@
name: cordis:access
- id: manager
name: cordis:manager
- id: action-cards
name: cordis:actionCards
- id: recommendations
name: cordis:recommendations
@@ -1,4 +1,4 @@
/** Profile files, Loader, HMR, AgentLoop and skills are real; only model output is scripted. */
/** Profile files, Loader, HMR, AgentLoop, skills and the card service are real; only model output is scripted. */
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -12,14 +12,15 @@ import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
import Projections from '@deepseek-ai/dsh-session-projection'
import Agents from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import Llm, { LlmAdapter, ToolCallId, createUserMessage, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import Llm, { LlmAdapter, createUserMessage, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import Tools from '@deepseek-ai/dsh-tools'
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import Skills from '@deepseek-ai/dsh-skill'
import Access from '@deepseek-ai/dsh-api-remote-access'
import * as ActionCards from '@deepseek-ai/dsh-experimental-action-cards'
import * as Recommendations from '../src/index.ts'
import { answer } from './fixture.ts'
import { ACTION, logProposal } from './fixture.ts'
class Model extends LlmAdapter {
readonly requests: GenerateOptions[] = []
@@ -32,7 +33,7 @@ class Model extends LlmAdapter {
}
}
it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader/HMR queue: %s', async (scenario) => {
it.each(['normal', 'consumer-unload', 'shared-rows'] as const)('enables through the real Loader/HMR queue: %s', async (scenario) => {
const home = await mkdtemp(join(tmpdir(), 'dsh-recommendations-'))
let owner: Context | undefined
onTestFinished(async () => { await owner?.fiber.dispose(); await rm(home, { recursive: true, force: true }) })
@@ -40,7 +41,8 @@ it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader
const anchor = join(home, 'package.json')
const name = '@deepseek-ai/dsh-experimental-session-search'
await writeFile(anchor, JSON.stringify({ name: 'installation', dependencies: { [name]: '1.0.0' } }))
initProfile(dir, ['fixture-base'])
// The Personalized pages bundle inserts the same card-service row; the Loader keeps one entry for the id.
initProfile(dir, scenario === 'shared-rows' ? ['fixture-base', 'fixture-pages'] : ['fixture-base'])
async function bundle(root: string, name: string, patch: string) {
const path = join(root, 'node_modules', name)
await mkdir(path, { recursive: true })
@@ -52,6 +54,7 @@ it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader
return path
}
await bundle(dir, 'fixture-base', await readFile(new URL('./fixtures/cordis.yml', import.meta.url), 'utf8'))
await bundle(dir, 'fixture-pages', JSON.stringify([{ insert: [{ id: 'action-cards', name: 'cordis:actionCards' }] }]))
const plugin = await bundle(home, name, JSON.stringify([{ insert: [{ id: 'recommendation-probe', name: './plugin.mjs' }] }]))
await writeFile(join(plugin, 'plugin.mjs'), 'export function apply(ctx) { ctx.provide("recommendationProbe", true) }\n')
await writeFile(join(dir, 'cordis.yml'), '[]\n')
@@ -66,18 +69,18 @@ it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader
ctx.provide('appReady', { onReady: (listener) => { listener(); return () => {} } })
Object.assign(ctx.loader.builtins, {
sessions: Sessions, projections: Projections, llm: Llm, prompt: SystemPrompt, tools: Tools,
executor: NativeToolExecutor, skills: Skills,
agents: Agents, loop: AgentLoop, access: Access, manager: PluginManager, recommendations: Recommendations,
executor: NativeToolExecutor, skills: Skills, agents: Agents, loop: AgentLoop, access: Access, manager: PluginManager,
actionCards: ActionCards, recommendations: Recommendations,
})
})
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.hmr.runExclusive(async () => {})
expect([...ctx.loader.entries()].filter(entry => entry.options.id === 'action-cards')).toHaveLength(1)
const model = new Model()
ctx.llm.registerAdapter(['mock'], model)
const agent = await ctx.agentLoop.create(SessionId('recommendation-live'), { provider: 'mock', model: 'test' })
const callId = ToolCallId('block:recommend-live')
agent.session.append('assistant/message', answer('recommend-live', [{ action: 'enable_official_plugin', name, reason: 'Search saved conversations' }]), { surfaceOp: 'append' })
const callId = logProposal(ctx, agent, 'call-live', { title: 'Session search', brief: 'Search saved conversations', details: { name } })
expect((await ctx.skills.list()).map(skill => skill.name)).toContain('official-plugins')
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain(name)
if (scenario === 'consumer-unload') {
@@ -93,7 +96,7 @@ it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader
})
await entered.promise
const exclusive = vi.spyOn(ctx.hmr, 'runExclusive')
const action = ctx.pluginRecommendations.act(agent, callId, 'enable')
const action = ctx.actionCards.act(agent, callId, 'enable')
await vi.waitFor(() => { expect(exclusive).toHaveBeenCalledOnce() })
release.resolve(undefined)
await reload
@@ -106,18 +109,17 @@ it.each(['normal', 'consumer-unload'] as const)('enables through the real Loader
expect(model.requests).toHaveLength(0)
return
}
expect(await ctx.pluginRecommendations.act(agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'applied' })
const enabled = { callId, action: ACTION, phase: 'enabled' }
expect(await ctx.actionCards.act(agent, callId, 'enable')).toEqual({ status: 'settled', decision: enabled })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain(name)
expect([...ctx.loader.entries()].find(entry => entry.options.id === 'recommendation-probe')?.fiber).toBeDefined()
expect((await ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(true)
// The outcome reaches the model with the next request; enabling starts no turn by itself.
expect(agent.status).toBe('idle')
expect(model.requests).toHaveLength(0)
expect(ctx.sessionProjections.snapshot(agent.session).values.pluginRecommendations).toEqual({
decisions: { [callId]: 'applied' }, proposals: { [callId]: { callId, name, reason: 'Search saved conversations', turn: 0 } },
})
expect(ctx.actionCards.view(agent.session).decisions[callId]).toEqual(enabled)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks.' }], source: { kind: 'user' } }))
await agent.whenIdle()
expect(model.requests).toHaveLength(1)
expect(JSON.stringify(model.requests[0]?.messages)).toContain('Latest official plugin card status')
expect(JSON.stringify(model.requests[0]?.messages)).toContain('enabled means the plugin is on now')
})
@@ -1,108 +0,0 @@
import { expect, it } from 'vitest'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { recommendationOf, recommendationProjection, type RecommendationState } from '../src/projection.ts'
import { answer, block } from './fixture.ts'
const projection = recommendationProjection({ queuedMessageLimit: 2 })
const page = { action: 'create_personalized_page', title: 'Counter', brief: 'Count tasks.', slot: 'page' }
const id = (message: string) => ToolCallId('block:' + message)
let seq = 0
const event = <K extends 'assistant/message' | 'turn/start' | 'agent/inbox/spliced' | 'user/message'>(type: K, data: SessionEvent<K>['data']) =>
({ type, seq: SessionSeq(++seq), time: 0, data }) as SessionEvent<K>
const apply = (state: RecommendationState, ...events: SessionEvent[]) => events.reduce(projection.apply, state)
function init(inherited = 0): RecommendationState {
const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: SessionId('recommendations'), createdAt: 0, isSeeded: inherited > 0 }
return projection.init(header, SessionLogOffset(inherited))
}
it('reads the last valid official-plugin block of an answer and ignores malformed blocks', () => {
const empty = init()
expect(apply(empty, event('assistant/message', answer('m1', [{ ...block, name: 'first' }, block])))
.view.proposals[id('m1')]).toEqual({ callId: id('m1'), name: block.name, reason: block.reason, turn: 0 })
expect(apply(empty, event('assistant/message', answer('m2', [{ ...block, name: ' trimmed ', reason: ' Reason. ' }]))).view.proposals[id('m2')])
.toEqual({ callId: id('m2'), name: 'trimmed', reason: 'Reason.', turn: 0 })
for (const invalid of [{ ...block, name: ' ' }, { ...block, reason: '' }, { ...block, extra: 1 }, { ...block, action: 'other' }, { name: block.name }]) {
expect(apply(empty, event('assistant/message', answer('m3', [invalid])))).toBe(empty)
}
const text = { role: 'assistant', id: 'm4', content: [{ type: 'text', text: '```dsh-action\n{broken\n```' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }
expect(apply(empty, event('assistant/message', { turn: 1, step: 1, message: text, stream: [] } as never))).toBe(empty)
expect(apply(empty, event('turn/start', { turn: 1 }))).toEqual({ ...empty, turn: 1 })
})
it('publishes the admitted proposals with their turns beside the card states as the client view', () => {
const state = apply(init(), event('turn/start', { turn: 3 }), event('assistant/message', answer('m1', [block], 3)))
const view = projection.wire.view(state)
expect(view).toBe(state.view)
expect(view).toEqual({ decisions: {}, proposals: { [id('m1')]: { callId: id('m1'), name: block.name, reason: block.reason, turn: 3 } } })
expect(projection.wire.viewSchema.parse(view)).toEqual(view)
expect(projection.wire.view(apply(state, event('turn/start', { turn: 4 })))).toBe(view)
})
it('never yields to another action and keeps one card per turn of its own', () => {
const empty = init()
// Page blocks in the same answer, before or after, do not change this experiment's card.
for (const blocks of [[block, page], [page, block]]) {
const state = apply(empty, event('assistant/message', answer('m1', blocks)))
expect(state.view.decisions).toEqual({})
expect(state.view.proposals[id('m1')]).toBeDefined()
}
// A page answer earlier in the turn neither holds the turn nor hides a later plugin block.
const pageFirst = apply(empty, event('assistant/message', answer('m1', [page])))
expect(pageFirst).toBe(empty)
expect(apply(pageFirst, event('assistant/message', answer('m2', [block]))).view.proposals[id('m2')]).toBeDefined()
// This experiment's own later block in the same turn creates nothing.
const own = apply(empty, event('assistant/message', answer('m1', [block])))
const later = apply(own, event('assistant/message', answer('m2', [{ ...block, name: 'later' }])))
expect(later).toBe(own)
expect(apply(own, event('assistant/message', answer('m1', [block])))).toEqual(own)
expect(apply(own, event('turn/start', { turn: 2 })).firstCall).toBeNull()
})
it('ignores the events a forked Session inherits from its parent', () => {
const parent = [event('turn/start', { turn: 1 }), event('assistant/message', answer('m1', [block]))]
const inherited = init(seq + 1)
expect(parent.reduce(projection.apply, inherited)).toBe(inherited)
const own = apply(inherited, event('turn/start', { turn: 2 }), event('assistant/message', answer('m2', [block], 2)))
expect(Object.keys(own.view.proposals)).toEqual([id('m2')])
expect(own.view.decisions).toEqual({})
})
it('reads only the text parts of an answer', () => {
const data = answer('m1', [block])
const message = { ...data.message, content: [{ type: 'tool-call' as const, id: ToolCallId('call'), name: 'skill', arguments: '{}' }, ...data.message.content] }
expect(recommendationOf(event('assistant/message', { ...data, message }))).toEqual({ callId: id('m1'), name: block.name, reason: block.reason })
})
it('lets a later proposal replace only undecided cards', () => {
const first = apply(init(), event('assistant/message', answer('m1', [block])))
const second = apply(first, event('turn/start', { turn: 2 }), event('assistant/message', answer('m2', [block], 2)))
expect(second.view.decisions).toEqual({ [id('m1')]: 'replaced' })
const dismissed = { ...first, view: { ...first.view, decisions: { [id('m1')]: 'dismissed' as const } } }
expect(apply(dismissed, event('turn/start', { turn: 2 }), event('assistant/message', answer('m2', [block], 2))).view.decisions)
.toEqual({ [id('m1')]: 'dismissed' })
})
it('suppresses proposals written while user messages are queued and folds inbox splices', () => {
const message = createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
const empty = init()
const queued = apply(empty, event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message, message] }))
expect(queued.queued).toBe(2)
expect(apply(queued, event('agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [message] }))).toBe(queued)
expect(apply(queued, event('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 3, inserted: [] })).queued).toBe(0)
const suppressed = apply(queued, event('assistant/message', answer('m1', [block])))
expect(suppressed.view.decisions[id('m1')]).toBe('suppressed')
expect(suppressed.view.proposals[id('m1')]).toBeDefined()
const one = apply(queued, event('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }))
expect(apply(one, event('assistant/message', answer('m1', [block]))).view.decisions).toEqual({})
})
it('acknowledges delivered status only through the notice sequence', () => {
const state = { ...init(), pendingStatus: { a: 3, b: 7 } }
const notice = createUserMessage({ content: [{ type: 'text', text: 'status' }],
source: { kind: 'plugin-recommendations', through: SessionSeq(5), form: 'notice', summary: 'Official plugin status updated' } })
expect(apply(state, event('user/message', notice)).pendingStatus).toEqual({ b: 7 })
const human = createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
expect(apply(state, event('user/message', human))).toBe(state)
})
@@ -1,14 +1,11 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager/types'
import * as PluginRecommendations from '../src/index.ts'
import type { RecommendationDecision } from '../src/types.ts'
import { bundle, callId, fixture } from './fixture.ts'
import { ACTION, bundle, callId, fixture, logProposal, proposal } from './fixture.ts'
describe('official recommendations', () => {
it('offers the live catalog through a skill, adds no tools, and withdraws it on unload', async () => {
it('offers the live catalog through a skill, adds no tools of its own, and withdraws the skill and card kind on unload', async () => {
const f = await fixture()
expect(f.ctx.tools.get('list_official_plugins')).toBeUndefined()
expect(f.ctx.tools.get('suggest_plugin')).toBeUndefined()
@@ -33,13 +30,15 @@ describe('official recommendations', () => {
const controller = new AbortController()
f.manager.listBundles.mockImplementationOnce(async () => { controller.abort(); return [bundle] })
await expect(f.ctx.skills.get('official-plugins', { signal: controller.signal })).rejects.toThrow()
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
f.propose()
await f.fiber.dispose()
expect(f.ctx.get('pluginRecommendations')).toBeUndefined()
expect((await f.ctx.skills.list()).some(skill => skill.name === 'official-plugins')).toBe(false)
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('describes a logged proposal from the current catalog and refuses names outside it', async () => {
it('describes a logged card from the current catalog and refuses other cards and names outside it', async () => {
const f = await fixture()
expect(await f.ctx.pluginRecommendations.describe(f.agent, callId)).toEqual({ status: 'refused', reason: 'unknown' })
f.propose()
@@ -58,6 +57,15 @@ describe('official recommendations', () => {
f.manager.listBundles.mockResolvedValueOnce(listed)
expect(await f.ctx.pluginRecommendations.describe(f.agent, callId)).toEqual({ status: 'refused', reason: 'unavailable' })
}
// A card of another kind, and one whose details name no plugin, describe nothing.
f.agent.session.append('turn/start', { turn: 2 })
const page = logProposal(f.ctx, f.agent, 'call-page', { title: 'Counter', brief: 'Count.', details: { slot: 'page' } }, 'create_personalized_page')
expect(await f.ctx.pluginRecommendations.describe(f.agent, page)).toEqual({ status: 'refused', reason: 'unknown' })
f.agent.session.append('turn/start', { turn: 3 })
const unnamed = f.propose('call-unnamed', { ...proposal, details: {} })
expect(await f.ctx.pluginRecommendations.describe(f.agent, unnamed)).toEqual({ status: 'refused', reason: 'unknown' })
expect(await f.ctx.actionCards.act(f.agent, unnamed, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(await f.ctx.pluginRecommendations.describe(f.agent, ToolCallId('toString'))).toEqual({ status: 'refused', reason: 'unknown' })
vi.spyOn(f.ctx.agents, 'roots').mockReturnValueOnce([])
expect(await f.ctx.pluginRecommendations.describe(f.agent, callId)).toEqual({ status: 'refused', reason: 'not-live-root' })
const service = f.ctx.pluginRecommendations
@@ -65,69 +73,63 @@ describe('official recommendations', () => {
expect(await service.describe(f.agent, callId)).toEqual({ status: 'refused', reason: 'closing' })
})
it('records the application result once, never wakes the conversation, and restores decisions by replay', async () => {
it('records the enabled card once, never wakes the conversation, and keeps the decision in the Session', async () => {
const f = await fixture()
const steer = vi.spyOn(f.agent, 'steer')
const followup = vi.spyOn(f.agent, 'followup')
f.propose()
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'applied' })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'applied' })
const enabled = { callId, action: ACTION, phase: 'enabled' }
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', decision: enabled })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', decision: enabled })
expect(f.manager.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(bundle.name, true)
expect(steer).not.toHaveBeenCalled()
expect(followup).not.toHaveBeenCalled()
expect(f.state().view.decisions[callId]).toBe('applied')
expect(f.ctx.sessionProjections.summaryKeys().has('pluginRecommendations')).toBe(false)
expect(f.card()).toEqual(enabled)
})
it.each(['restart-required', 'overridden', 'failed', 'cancelled'] as const)('preserves the exact %s outcome and retries only failures', async (application) => {
it.each([
['restart-required', { phase: 'enabled', data: { application: 'restart-required' } }, false],
['overridden', { phase: 'enabled', data: { application: 'overridden' } }, false],
['failed', { phase: 'failed' }, true],
['cancelled', { phase: 'failed', data: { application: 'cancelled' } }, true],
] as const)('records the %s outcome and retries only failures', async (application, recorded, retried) => {
const f = await fixture()
f.propose()
f.manager.setBundleEnabled.mockResolvedValue({ application, changed: false, stage: 'enable', target: bundle.name })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: application })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', decision: { callId, action: ACTION, ...recorded } })
f.manager.setBundleEnabled.mockResolvedValue({ application: 'applied', changed: true, stage: 'enable', target: bundle.name })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({
status: 'settled', outcome: application === 'failed' || application === 'cancelled' ? 'applied' : application,
})
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled',
decision: retried ? { callId, action: ACTION, phase: 'enabled' } : { callId, action: ACTION, ...recorded } })
expect(f.manager.setBundleEnabled).toHaveBeenCalledTimes(retried ? 2 : 1)
})
it('settles an already-enabled plugin without another profile change', async () => {
const f = await fixture()
f.propose()
f.manager.listBundles.mockResolvedValue([{ ...bundle, enabled: true }])
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'already-enabled' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled',
decision: { callId, action: ACTION, phase: 'already-enabled' } })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('preserves the management failure code without persisting external diagnostics', async () => {
it('records the management error code without the diagnostic text', async () => {
const f = await fixture()
f.propose()
f.manager.setBundleEnabled.mockResolvedValue({ application: 'failed', changed: false, stage: 'enable', target: bundle.name,
error: { code: 'incompatible-version', diagnostic: '/private/profile/path' } })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'failed', errorCode: 'incompatible-version' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled',
decision: { callId, action: ACTION, phase: 'failed', data: { errorCode: 'incompatible-version' } } })
expect(JSON.stringify(f.events)).not.toContain('/private/profile/path')
})
it('contains an unreadable receipt and refuses actions without stalling later events', async () => {
const f = await fixture()
f.propose()
// Persisted data can originate in a newer build, outside the current writer's types.
const malformed = JSON.parse('{"callId":"block:answer-1","outcome":"future-outcome"}') as RecommendationDecision
appendPluginRecord(f.agent.session, 'plugin:plugin-recommendations/decision', malformed)
f.agent.session.append('turn/start', { turn: 2 })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'invalid-history' })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
expect(f.state().turn).toBe(2)
expect(f.state().view.invalid).toBe(true)
})
it.each(['plugin', 'plugin-reload', 'agent'] as const)('does not start activation after the %s leaves during catalog lookup', async (target) => {
it.each(['plugin', 'plugin-reload', 'agent'] as const)('does not start enabling after the %s leaves during catalog lookup', async (target) => {
const f = await fixture()
f.propose()
const entered = Promise.withResolvers<undefined>()
const gate = Promise.withResolvers<BundleInfo[]>()
onTestFinished(() => { gate.resolve([bundle]) })
f.manager.listBundles.mockImplementationOnce(() => { entered.resolve(undefined); return gate.promise })
const action = f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')
const action = f.ctx.actionCards.act(f.agent, callId, 'enable')
await entered.promise
const disposal = target === 'agent' ? f.handle.dispose()
: target === 'plugin' ? f.fiber.dispose() : f.fiber.restart()
@@ -138,61 +140,40 @@ describe('official recommendations', () => {
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('treats inherited object member names as unknown proposals and logs nothing for them', async () => {
it('dismisses durably, rejects unknown cards, and rechecks catalog eligibility before enabling', async () => {
const f = await fixture()
f.propose()
const logged = f.events.length
expect(await f.ctx.pluginRecommendations.describe(f.agent, ToolCallId('toString'))).toEqual({ status: 'refused', reason: 'unknown' })
expect(await f.ctx.pluginRecommendations.act(f.agent, ToolCallId('toString'), 'dismiss')).toEqual({ status: 'refused', reason: 'unknown' })
expect(await f.ctx.pluginRecommendations.act(f.agent, ToolCallId('constructor'), 'enable')).toEqual({ status: 'refused', reason: 'unknown' })
expect(f.events).toHaveLength(logged)
expect(f.manager.listBundles).not.toHaveBeenCalled()
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('dismisses durably, rejects unknown ids, and rechecks catalog eligibility', async () => {
const f = await fixture()
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unknown' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unknown' })
f.propose()
f.manager.listBundles.mockResolvedValue([])
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
f.manager.listBundles.mockResolvedValue([{ ...bundle, optional: false }])
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')).toEqual({ status: 'settled', outcome: 'dismissed' })
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'dismissed' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
const dismissed = { callId, action: ACTION, phase: 'dismissed' }
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'settled', decision: dismissed })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', decision: dismissed })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('settles cards that were never offered or were replaced without changing the profile', async () => {
it('leaves a replaced card and a card proposed while messages were queued unenabled', async () => {
const f = await fixture()
const first = f.propose('call-first')
f.agent.session.append('turn/start', { turn: 2 })
f.propose('call-second')
expect(await f.ctx.actionCards.act(f.agent, first, 'enable')).toEqual({ status: 'settled', decision: { callId: first, action: ACTION, phase: 'replaced' } })
const message = createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
f.agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message, message] })
const suppressed = f.propose('answer-1')
expect(await f.ctx.pluginRecommendations.act(f.agent, suppressed, 'enable')).toEqual({ status: 'settled', outcome: 'suppressed' })
f.agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 2, inserted: [] })
f.agent.session.append('turn/start', { turn: 2 })
const first = f.propose('answer-2')
f.agent.session.append('turn/start', { turn: 3 })
f.propose('answer-3')
expect(await f.ctx.pluginRecommendations.act(f.agent, first, 'enable')).toEqual({ status: 'settled', outcome: 'replaced' })
const queued = f.propose('call-queued')
expect(await f.ctx.actionCards.act(f.agent, queued, 'enable')).toEqual({ status: 'settled', decision: { callId: queued, action: ACTION, phase: 'suppressed' } })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('allows a session-authorized dismissal without global profile access', async () => {
it('allows dismissal without global profile access but refuses enabling', async () => {
const f = await fixture()
f.propose()
vi.spyOn(f.ctx.remoteAccess, 'allows').mockReturnValue(false)
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')).toEqual({ status: 'settled', outcome: 'dismissed' })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('refuses non-root callers and profile writes without global access', async () => {
const f = await fixture()
f.propose()
vi.spyOn(f.ctx.agents, 'roots').mockReturnValue([])
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'not-live-root' })
vi.spyOn(f.ctx.remoteAccess, 'allows').mockReturnValue(false)
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'access-denied' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'access-denied' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toMatchObject({ status: 'settled', decision: { phase: 'dismissed' } })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
@@ -201,21 +182,20 @@ describe('official recommendations', () => {
f.propose()
const roots = f.ctx.agents.roots()
vi.spyOn(f.ctx.agents, 'roots').mockReturnValueOnce(roots).mockReturnValue([])
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'not-live-root' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'not-live-root' })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('coalesces actions and releases local work when HMR must unload the consumer before applying', async () => {
it('coalesces actions and releases local work when HMR must unload this plugin before applying', async () => {
const f = await fixture()
f.propose()
const gate = Promise.withResolvers<ChangeResult>()
onTestFinished(() => { gate.resolve({ application: 'applied', changed: true, stage: 'enable', target: bundle.name }) })
const entered = Promise.withResolvers<undefined>()
f.manager.setBundleEnabled.mockImplementation(() => { entered.resolve(undefined); return gate.promise })
const first = f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')
const first = f.ctx.actionCards.act(f.agent, callId, 'enable')
await entered.promise
const second = f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')
const service = f.ctx.pluginRecommendations
const second = f.ctx.actionCards.act(f.agent, callId, 'dismiss')
await f.fiber.dispose()
expect(await first).toEqual({ status: 'unconfirmed', reason: 'closing' })
expect(await second).toEqual(await first)
@@ -224,17 +204,6 @@ describe('official recommendations', () => {
await gate.promise
expect(f.events).toHaveLength(before)
expect(f.manager.setBundleEnabled).toHaveBeenCalledOnce()
expect(await service.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'closing' })
})
it('applies validated configuration and the schema default for direct composition', async () => {
expect(PluginRecommendations.Config.parse(undefined)).toEqual({ queuedMessageLimit: 2 })
expect(() => PluginRecommendations.Config.parse({ queuedMessageLimit: 0 })).toThrow()
const f = await fixture()
await f.fiber.dispose()
await f.ctx.plugin(PluginRecommendations, PluginRecommendations.Config.parse({ queuedMessageLimit: 1 }))
f.agent.inbox.append('next-turn', createUserMessage({ content: [{ type: 'text', text: 'Later.' }], source: { kind: 'user' } }))
f.propose()
expect(await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')).toEqual({ status: 'settled', outcome: 'suppressed' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'enable')).toEqual({ status: 'refused', reason: 'unavailable' })
})
})
@@ -1,18 +1,14 @@
import { expect, it, vi } from 'vitest'
import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
import * as PluginRecommendations from '../src/index.ts'
import { callId, fixture } from './fixture.ts'
async function prepare(
f: Awaited<ReturnType<typeof fixture>>, agent: Agent = f.agent,
signal = new AbortController().signal, admitted?: () => void | Promise<void>,
) {
const decision = await agentEvents(f.ctx, agent).waterfall('agent/pre-step', { messages: [], turn: 2, step: 1, signal, modelSelection: { provider: 'mock', model: 'mock' } },
async (): Promise<PreStepDecision> => { await admitted?.(); return { kind: 'enter', messages: [] } })
async function prepare(f: Awaited<ReturnType<typeof fixture>>, agent: Agent = f.agent) {
const decision = await agentEvents(f.ctx, agent).waterfall('agent/pre-step', { messages: [], turn: 2, step: 1,
signal: new AbortController().signal, modelSelection: { provider: 'mock', model: 'mock' } },
async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }))
return decision.kind === 'reject' ? decision : {
...decision, messages: decision.messages.filter(message => message.source.kind === 'plugin-recommendations'),
...decision, messages: decision.messages.filter(message => message.source.kind === 'action-cards'),
}
}
@@ -21,96 +17,31 @@ function text(decision: PreStepDecision): string {
return decision.messages.flatMap(message => message.content.flatMap(part => part.type === 'text' ? [part.text] : [])).join('\n')
}
function enter(agent: Agent, decision: PreStepDecision) {
if (decision.kind === 'enter') for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
it('reports the card outcome on the next request without waking the conversation', async () => {
it('reports the card outcome with the plugin guidance on the next request without waking the conversation', async () => {
const f = await fixture()
const followup = vi.spyOn(f.agent, 'followup')
const steer = vi.spyOn(f.agent, 'steer')
const inject = vi.spyOn(f.agent, 'inject')
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
f.propose()
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')
f.manager.setBundleEnabled.mockResolvedValueOnce({ application: 'restart-required', changed: true, stage: 'enable', target: 'official-search' })
await f.ctx.actionCards.act(f.agent, callId, 'enable')
expect(f.agent.status).toBe('idle')
const decision = await prepare(f)
if (decision.kind !== 'enter') throw new Error('request was rejected')
expect(decision.messages).toHaveLength(1)
expect(decision.messages[0]!.content).toMatchSnapshot('applied plugin status')
enter(f.agent, decision)
expect(decision.messages[0]!.content).toMatchSnapshot('restart-required plugin status')
for (const message of decision.messages) f.agent.session.append('user/message', message, { surfaceOp: 'append' })
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
expect(followup).not.toHaveBeenCalled()
expect(steer).not.toHaveBeenCalled()
expect(inject).not.toHaveBeenCalled()
})
it('retains undelivered status through cancellation, rejection, plugin reload and Session reopen', async () => {
it('reports a recorded decision after this plugin reloads', async () => {
const f = await fixture()
f.propose()
await f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')
const cancelled = new AbortController()
await expect(prepare(f, f.agent, cancelled.signal, () => { cancelled.abort() })).rejects.toThrow()
const rejected = f.ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))
expect((await prepare(f)).kind).toBe('reject')
rejected()
// A prepared message is not acknowledged until it enters the durable log.
expect((await prepare(f)).kind).toBe('enter')
await f.ctx.actionCards.act(f.agent, callId, 'dismiss')
await f.fiber.dispose()
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
expect(text(await prepare(f))).toContain('"phase":"dismissed"')
await f.ctx.plugin(PluginRecommendations)
await f.handle.dispose()
const reopened = await f.ctx.agents.create({ sessionId: f.agent.id, seed: [...f.events] })
const decision = await prepare(f, reopened.agent)
if (decision.kind !== 'enter') throw new Error('request was rejected')
expect(JSON.stringify(decision.messages[0]!.content)).toContain('dismissed')
enter(reopened.agent, decision)
expect(await prepare(f, reopened.agent)).toEqual({ kind: 'enter', messages: [] })
})
it('keeps a later outcome pending when an earlier status message enters the log', async () => {
const f = await fixture()
f.propose()
f.manager.setBundleEnabled.mockResolvedValueOnce({ application: 'failed', changed: false, stage: 'enable', target: 'official-search' })
await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')
const failed = await prepare(f)
await f.ctx.pluginRecommendations.act(f.agent, callId, 'enable')
enter(f.agent, failed)
const applied = await prepare(f)
if (applied.kind !== 'enter') throw new Error('request was rejected')
expect(applied.messages).toHaveLength(1)
expect(text(applied)).toContain('[{"plugin":"official-search","outcome":"applied"}]')
enter(f.agent, applied)
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
})
it('reports a receipt without a logged proposal by outcome only', async () => {
const f = await fixture()
appendPluginRecord(f.agent.session, 'plugin:plugin-recommendations/decision', { callId: ToolCallId('older-card'), outcome: 'dismissed' })
expect(text(await prepare(f))).toContain('\n[{"outcome":"dismissed"}]')
})
it('does not supply status when cancellation arrives after downstream request preparation', async () => {
const f = await fixture()
f.propose()
await f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')
await f.fiber.dispose()
const controller = new AbortController()
f.ctx.on('agent/pre-step', async (_event, next) => {
const decision = await next()
controller.abort()
return decision
}, { prepend: true })
await f.ctx.plugin(PluginRecommendations)
expect(await prepare(f, f.agent, controller.signal)).toEqual({ kind: 'enter', messages: [] })
})
it('does not add context if the plugin unloads during request preparation', async () => {
const f = await fixture()
f.propose()
await f.ctx.pluginRecommendations.act(f.agent, callId, 'dismiss')
expect(await prepare(f, f.agent, undefined, async () => { await f.fiber.dispose() })).toEqual({ kind: 'enter', messages: [] })
expect(text(await prepare(f))).toContain('dismissed means the user declined it')
})
@@ -20,9 +20,6 @@
{
"path": "../../core/session"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../boot/plugin-manager"
},
@@ -36,13 +33,12 @@
"path": "../../skill/skill"
},
{
"path": "../../util/action-block"
"path": "../action-cards"
}
],
"files": [
"src/index.ts",
"src/types.ts",
"src/projection.ts",
"src/skill.ts"
]
}
@@ -1984,20 +1984,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'pluginRecommendations',
summary: 'Internal Remote and effect owner assembled by the plugin-recommendation feature.',
description: 'Internal Remote and effect owner assembled by the plugin-recommendation feature.',
summary: 'Catalog lookups for card text and the enabling behind the `enable_official_plugin` card.',
description: 'Catalog lookups for card text and the enabling behind the `enable_official_plugin` card.',
methods: [
{
signature: '@Remote async describe(agent: Agent, callId: ToolCallId): Promise<RecommendationDetails>',
description: 'Read the current catalog text for a logged proposal\'s card.',
parameters: [{ name: 'agent', description: 'Exact live root resolved by the Gateway from the Session id.' }, { name: 'callId', description: 'Proposal id from the answer block, never a package supplied by the browser.' }],
returns: 'the plugin\'s title, description and selection, or a refusal when the block names no available official plugin.',
},
{
signature: '@Remote act(agent: Agent, callId: ToolCallId, action: \'enable\' | \'dismiss\'): Promise<RecommendationActionResult>',
description: 'Decide one logged recommendation. Concurrent actions on the same card share one operation; a settled success or dismissal never enables twice. Failed activation remains retryable. Profile selection survives session disposal.',
parameters: [{ name: 'agent', description: 'Exact live root resolved by the Gateway from the Session id.' }, { name: 'callId', description: 'Proposal id from the answer block, never a package supplied by the browser.' }, { name: 'action', description: 'Human choice.' }],
returns: 'An observed outcome, a refusal before mutation, or an unconfirmed result if reload interrupts the wait.',
description: 'Read the current catalog text for a logged card.',
parameters: [{ name: 'agent', description: 'Exact live root resolved by the Gateway from the Session id.' }, { name: 'callId', description: 'The card\'s `propose_action` call id, never a package supplied by the browser.' }],
returns: 'the plugin\'s title, description and selection, or a refusal when the card names no available official plugin.',
},
],
},
@@ -7021,22 +7015,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningEffortId',
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
},
{
name: 'RecommendationActionResult',
declaration: 'export type RecommendationActionResult = {\n readonly status: \'settled\';\n readonly outcome: RecommendationCardState;\n readonly errorCode?: ManagementError[\'code\'];\n} | {\n readonly status: \'refused\';\n readonly reason: \'unknown\' | \'unavailable\' | \'access-denied\' | \'not-live-root\' | \'closing\' | \'invalid-history\';\n} | {\n readonly status: \'unconfirmed\';\n readonly reason: \'closing\';\n};',
},
{
name: 'RecommendationCardState',
declaration: 'export type RecommendationCardState = RecommendationOutcome | \'replaced\' | \'suppressed\';',
},
{
name: 'RecommendationDetails',
declaration: 'export type RecommendationDetails = {\n readonly status: \'available\';\n readonly title: LocalizedText;\n readonly description: LocalizedText;\n readonly enabled: boolean;\n} | {\n readonly status: \'refused\';\n readonly reason: \'unknown\' | \'unavailable\' | \'not-live-root\' | \'closing\';\n};',
},
{
name: 'RecommendationOutcome',
declaration: 'export type RecommendationOutcome = \'dismissed\' | \'applied\' | \'already-enabled\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';',
},
{
name: 'RecurringScheduleRecord',
declaration: 'export type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord;',
@@ -203,12 +203,14 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | no | Web band above the prompt for Claude Code mods: draws each session's mod tree and sends button presses back to the bridge |
| `@deepseek-ai/dsh-experimental-client-ui-cot-translation` | yes | Optional machine translation of expanded reasoning, preserving original Session text |
| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | no | Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore |
| `@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations` | no | Official plugin card body with catalog text and enabling |
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | no | Record speech and insert editable text into the conversation draft |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp` | yes | Experimental computer use through an installed Cua Driver MCP executable |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | no | Create persistent removable interface plugins from explicit conversation needs |
| `@deepseek-ai/dsh-experimental-cua-macos` | yes | macOS native provider for shared Computer Use application and window objects |
| `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection |
| `@deepseek-ai/dsh-experimental-plugin-recommendations` | no | Official plugin suggestions with explicit user activation and persistent conversation decisions |
| `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam |
| `@deepseek-ai/dsh-experimental-session-inspector` | no | Experimental virtualized Session log and live Chat group/node inspectors |
| `@deepseek-ai/dsh-experimental-speech-to-text` | yes | Experimental speech recognition with independently selectable providers |
+17 -20
View File
@@ -67,9 +67,6 @@ importers:
'@deepseek-ai/dsh-experimental-cua-macos':
specifier: workspace:*
version: link:packages/experimental/cua-macos
'@deepseek-ai/dsh-experimental-plugin-recommendations':
specifier: workspace:*
version: link:packages/experimental/plugin-recommendations
'@deepseek-ai/dsh-experimental-subagent-oar':
specifier: workspace:*
version: link:packages/experimental/subagent-oar
@@ -8975,27 +8972,24 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:*
version: link:../../client/store
'@deepseek-ai/dsh-client-ui-chat':
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:*
version: link:../../client/ui-chat
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:*
version: link:../../client/ui-conversation
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-plugin-manager':
specifier: workspace:*
version: link:../../client/ui-plugin-manager
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../../client/ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:*
version: link:../../client/ui-renderer
'@deepseek-ai/dsh-client-ui-session':
specifier: workspace:*
version: link:../../client/ui-session
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:*
version: link:../../client/ui-slots
'@deepseek-ai/dsh-experimental-action-cards':
specifier: workspace:*
version: link:../action-cards
'@deepseek-ai/dsh-experimental-client-ui-action-cards':
specifier: workspace:*
version: link:../client-ui-action-cards
'@deepseek-ai/dsh-experimental-plugin-recommendations':
specifier: workspace:*
version: link:../plugin-recommendations
@@ -9014,6 +9008,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.3.1
zod:
specifier: 4.4.3
version: 4.4.3
packages/experimental/client-ui-voice-input:
devDependencies:
@@ -9831,12 +9828,15 @@ importers:
packages/experimental/plugin-recommendations:
dependencies:
'@deepseek-ai/dsh-experimental-action-cards':
specifier: workspace:*
version: link:../action-cards
'@deepseek-ai/dsh-experimental-client-ui-action-cards':
specifier: workspace:*
version: link:../client-ui-action-cards
'@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations':
specifier: workspace:*
version: link:../client-ui-plugin-recommendations
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../../util/action-block
zod:
specifier: 4.4.3
version: 4.4.3
@@ -9862,9 +9862,6 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
'@deepseek-ai/dsh-skill':
specifier: workspace:*
version: link:../../skill/skill
+1 -1
View File
@@ -121,7 +121,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Explicit-need plugin recommendations',
mode: 'service',
consumers: ['experimental-client-ui-plugin-recommendations'],
note: 'Offers official catalog entries and records explicit user activation through the profile plugin manager.',
note: 'Reads the current catalog text of an official plugin card and enables the plugin through the profile plugin manager after a click.',
},
{
key: 'headlessStartup',
+1 -1
View File
@@ -252,7 +252,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(RemoteAccess)
await ctx.plugin(ActionCards)
},
note: 'Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages bundle supplies the only shipped kind.',
note: 'Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages and Plugin recommendations bundles supply the shipped kinds.',
},
{
pkg: '@deepseek-ai/dsh-tool-ask-user',