mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(experimental): add shared action cards behind a propose_action tool
action-cards owns the propose_action tool, the actionCards projection of proposals and decisions, the act Remote and the card status notice; card kinds register an action name, skill, details schema, verbs and handler. The tool is admitted per Agent only after a registered kind's skill loads. client-ui-action-cards draws the card frame below the completed answer, lets kinds register a body per action and adds the propose_action process row.
This commit is contained in:
1 parent
a07485bdc7
commit
9980af2d48
60 files changed
+2874
-10
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: 6cc2cd9ced70bb09
|
||||
zh: 1c0e6cb4ba2ed637
|
||||
/tool-schema-catalog/tool-package-map:
|
||||
en: 10fe940a3d17cde7
|
||||
zh: ef1e8863c28e3c05
|
||||
en: 3b44fba80c4b6baf
|
||||
zh: 45f8962c849cdc20
|
||||
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
|
||||
en: e0143916bb634762
|
||||
zh: e0143916bb634762
|
||||
@@ -54,8 +54,14 @@
|
||||
en: 4910ab327694ee97
|
||||
zh: 17e79e4366cb636d
|
||||
/tool-schema-catalog/deepseek-ai-dsh-experimental-browser-use-stagehand-native/stagehand-tabs:
|
||||
en: 095b18f9376e073d
|
||||
zh: abd8785f8acaa555
|
||||
en: c65d58eac812b03c
|
||||
zh: d4259095ce06e457
|
||||
/tool-schema-catalog/deepseek-ai-dsh-experimental-action-cards:
|
||||
en: 94f08f350e306557
|
||||
zh: 94f08f350e306557
|
||||
/tool-schema-catalog/deepseek-ai-dsh-experimental-action-cards/propose-action:
|
||||
en: b2afbd46036c0eb1
|
||||
zh: 0983902a9034482b
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user:
|
||||
en: e713a4a11a15d13c
|
||||
zh: e713a4a11a15d13c
|
||||
|
||||
@@ -19,6 +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-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. |
|
||||
@@ -497,6 +498,49 @@ List, create, select, or close a Stagehand browser tab.
|
||||
|
||||
Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-action-cards"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-action-cards`
|
||||
|
||||
### `propose_action`
|
||||
|
||||
Show the user one optional action card under this answer. Call only as the skill that names the action instructs.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action name from the skill."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Card title."
|
||||
},
|
||||
"brief": {
|
||||
"type": "string",
|
||||
"description": "What the action does, in the user's language."
|
||||
},
|
||||
"details": {
|
||||
"type": "object",
|
||||
"description": "Fields the skill lists for this action.",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"title",
|
||||
"brief",
|
||||
"details"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<a id="deepseek-aidsh-tool-ask-user"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ask-user`
|
||||
|
||||
@@ -23,6 +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-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 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
|
||||
@@ -501,6 +502,49 @@
|
||||
|
||||
来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-action-cards"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-action-cards`
|
||||
|
||||
### `propose_action`
|
||||
|
||||
在本次回答下方向用户展示一张可选操作卡片。只按指定该操作的 Skill 的要求调用。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action name from the skill."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Card title."
|
||||
},
|
||||
"brief": {
|
||||
"type": "string",
|
||||
"description": "What the action does, in the user's language."
|
||||
},
|
||||
"details": {
|
||||
"type": "object",
|
||||
"description": "Fields the skill lists for this action.",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"title",
|
||||
"brief",
|
||||
"details"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/experimental/action-cards/src/index.ts`](../packages/experimental/action-cards/src/index.ts)
|
||||
|
||||
只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,目前只有个性化页面 Bundle 提供种类。
|
||||
|
||||
<a id="deepseek-aidsh-tool-ask-user"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ask-user`
|
||||
|
||||
+2
-1
@@ -301,6 +301,7 @@
|
||||
"@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-cua-macos": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*"
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-action-cards": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
'create_goal', 'create_worktree', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep',
|
||||
'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
|
||||
'list_agents', 'list_agents', 'list_mcp_resource_templates', 'list_mcp_resources',
|
||||
'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'plugin_market', 'present', 'pwsh', 'pwsh', 'ralph',
|
||||
'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'plugin_market', 'present', 'propose_action', 'pwsh', 'pwsh', 'ralph',
|
||||
'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete',
|
||||
'schedule_list', 'schedule_update', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
|
||||
'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: b55aeb927737b90d
|
||||
zh: 0b215fcb5c2c7908
|
||||
/packages-experimental/packages:
|
||||
en: ae99f2efe9cd1c89
|
||||
zh: 0a49d7c31560df36
|
||||
en: e61934c9013fab1c
|
||||
zh: 959fac1a1cc47c5c
|
||||
/packages-experimental/related-documentation:
|
||||
en: 1cb9d3448d44e48d
|
||||
zh: cd326d70a746b896
|
||||
|
||||
@@ -36,6 +36,8 @@ Promotion requires a named product role, documented public behavior and limitati
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`action-cards`](action-cards/README.md) | Optional action cards below answers: the proposal tool, card state, user actions and status notices | `ctx.actionCards` |
|
||||
| [`client-ui-action-cards`](client-ui-action-cards/README.md) | Card frame below completed answers, kind bodies and the proposal process row | — |
|
||||
| [`creation-recommendations`](creation-recommendations/README.md) | Explicit-need creation of persistent removable interface plugins | `ctx.creationRecommendations` |
|
||||
| [`client-ui-creation-recommendations`](client-ui-creation-recommendations/README.md) | Completed-answer creation cards with location selection and undo | — |
|
||||
| [`tool-session-query`](tool-session-query/README.md) | Workspace-authorized model session search, trace, and event reads | — |
|
||||
|
||||
@@ -36,6 +36,8 @@ kind: "package-group"
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`action-cards`](action-cards/README.zh.md) | 回答下方的可选操作卡片:提议工具、卡片状态、用户操作与状态通知 | `ctx.actionCards` |
|
||||
| [`client-ui-action-cards`](client-ui-action-cards/README.zh.md) | 已完成回答下方的卡片框架、种类正文与提议过程行 | — |
|
||||
| [`creation-recommendations`](creation-recommendations/README.zh.md) | 针对明确需求创建持续可用、可移除的界面插件 | `ctx.creationRecommendations` |
|
||||
| [`client-ui-creation-recommendations`](client-ui-creation-recommendations/README.zh.md) | 回答末尾的创建卡片、位置选择和撤销 | — |
|
||||
| [`tool-session-query`](tool-session-query/README.zh.md) | 按工作区授权的模型会话搜索、追踪与事件读取 | — |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Bilingual-pair consistency record for README.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 packages/experimental/action-cards/README.md
|
||||
/:
|
||||
en: 1a68a7a733a2676d
|
||||
zh: 01f72eb61a0626b7
|
||||
/deepseek-ai-dsh-experimental-action-cards:
|
||||
en: e57ce09e58e6fabc
|
||||
zh: 8a727dbd6f925363
|
||||
/deepseek-ai-dsh-experimental-action-cards/summary:
|
||||
en: 7621088773d073f4
|
||||
zh: 57674e6dcee15997
|
||||
/deepseek-ai-dsh-experimental-action-cards/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: b00dddf4c88f0a45
|
||||
/deepseek-ai-dsh-experimental-action-cards/use-this-package:
|
||||
en: e64653a62247236d
|
||||
zh: d035f7cf50071696
|
||||
/deepseek-ai-dsh-experimental-action-cards/use-this-package/register-a-card-kind:
|
||||
en: a1414c3cfa8e204f
|
||||
zh: 90eb7a7010972743
|
||||
/deepseek-ai-dsh-experimental-action-cards/use-this-package/card-rules:
|
||||
en: d37be4e928e6fa00
|
||||
zh: 5d3eb834c2509707
|
||||
/deepseek-ai-dsh-experimental-action-cards/understand-the-implementation:
|
||||
en: 5640f0436038aefc
|
||||
zh: 5ebb181d81ad2c7e
|
||||
/deepseek-ai-dsh-experimental-action-cards/further-exploration:
|
||||
en: db407bb756ee5641
|
||||
zh: af5d25f4f2351ee6
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool:
|
||||
en: 4925bc5f7153825a
|
||||
zh: 3a7bfb2210d9c6aa
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/what-the-model-sees:
|
||||
en: 04e06f15a53041b0
|
||||
zh: b17c3c707c96c086
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/token-effect:
|
||||
en: f9c78daaf64cf38e
|
||||
zh: 2908740f9bc3bc9c
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/kv-cache-effect:
|
||||
en: d4192e35d47b5fa3
|
||||
zh: c786828e123de657
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice:
|
||||
en: 407ebca5eb94992e
|
||||
zh: 779819ea83974103
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/what-the-model-sees:
|
||||
en: a14013ca3ddb5ee3
|
||||
zh: 18cd7ffc62fd6590
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/what-the-model-sees/notice-preamble:
|
||||
en: 04dd9469bfaff052
|
||||
zh: 35110b231b8e3928
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/token-effect:
|
||||
en: 4e73a2cff60b5e88
|
||||
zh: 4887212815e7425d
|
||||
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/kv-cache-effect:
|
||||
en: 58785543e2428dba
|
||||
zh: 4b082f77b3b2f482
|
||||
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work:
|
||||
en: a8b122edac907461
|
||||
zh: d1003e2830e410c1
|
||||
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: bd8a8936229e035e
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
description: "Shared Host service of optional action cards: the propose_action tool, card state, user actions and status notices that experiment packages register card kinds into."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-action-cards
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
An experiment can offer one optional action below an answer, such as creating a page, without adding a tool of its own. The model loads the experiment's skill, then calls `propose_action`; the card appears after the turn ends, and nothing runs until the user acts on it. Each choice is recorded and reported to the model on its next request. The tool costs nothing until a card skill loads in the conversation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<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:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `queuedMessageLimit` | `2` | Queued next-turn user messages at or above which a card proposed in the current turn is never offered. |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-action-cards) is the exhaustive source for accepted fields.
|
||||
|
||||
### Register a card kind
|
||||
|
||||
A kind package injects `actionCards` and calls `ctx.actionCards.register(kind)` from its own plugin; unloading that plugin removes the kind, and actions on its cards are then refused as unavailable. The kind names its action, the skill that proposes it, a zod schema for the card's `details`, the user actions it accepts and the shared checks each needs (`global` Remote access, `writes` refused in a read-only Session), the recorded phases that still offer dismissal, optional status guidance, the decision data fields the status notice reports, and the handler for every action except `dismiss`. The handler records each new decision with `ctx.actionCards.record()` and returns `accepted`, `settled` with the decision in effect, `refused` or `unconfirmed`.
|
||||
|
||||
### Card rules
|
||||
|
||||
- The first accepted `propose_action` call of a turn becomes the card. The tool refuses a second call in the same turn, an unregistered action, a call whose kind skill was not loaded in the current turn, a blank title or brief, details that fail the kind schema, and calls dispatched from code.
|
||||
- A newer card replaces every unstarted earlier card. A card proposed while `queuedMessageLimit` or more user messages wait for later turns is logged but never offered. Both states are derived from the log and never recorded.
|
||||
- Events a forked Session inherits create no cards.
|
||||
- Not now is recorded for an unstarted card and for the phases the kind lists; dismissed, replaced and suppressed cards accept no further action.
|
||||
- Concurrent actions on one card share one operation. Actions require the card's live root Agent.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The package default-exports the `actionCards` Remote service. It registers `propose_action` once, globally, with a `when` condition: an Agent becomes eligible when a `skill` tool result loads the skill of a registered kind, and the flag lives in memory until the Agent is disposed. The call's `execute` checks the current turn's skill loads through the turn boundary and keeps an in-memory mark of the turn's accepted call; the logged `tool/call` and its successful `tool/result` are the durable proposal.
|
||||
|
||||
The `actionCards` Session projection (state version 1) pairs each `propose_action` call with its successful result, records decisions from `plugin:action-cards/decision`, folds queued next-turn messages from inbox splices, and keeps unreported decisions until a status notice whose `through` covers them enters the log. Its Client view `{ proposals, decisions }` is excluded from Session summaries.
|
||||
|
||||
The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live root, the card, the kind and verb, and Remote access before it shares one operation per card. A dismissed, replaced or suppressed card then settles unchanged, Not now is recorded, a writing verb is refused in a read-only Session, and every other verb goes to the kind handler under the Agent's initiator. An `agent/pre-step` listener adds the status notice after later listeners prepared the step.
|
||||
|
||||
| Source | Contents |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Service, tool, kind registry, Remote action and status notice |
|
||||
| [`src/projection.ts`](src/projection.ts) | Session projection |
|
||||
| [`src/types.ts`](src/types.ts) | Proposal, decision, view and result types |
|
||||
| [`tests/`](tests/) | Activation, validation, projection, action and notice tests |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
- [Tools](../../core/tools/README.md) — conditional registration and step admission.
|
||||
- [Session projection](../../session/session-projection/README.md) — the card state the browser reads.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
### Card tool
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The [`propose_action` declaration](../../../docs/tool-catalog.md#deepseek-aidsh-experimental-action-cards) appears only to an Agent that loaded the skill of a registered kind in this Session, from the next step on, for that Agent's lifetime. A kind's skill names the action and the fields of `details`. An accepted call returns `Card shown below your answer. Do not restate it; end with one short invitation.`. A refused call returns its reason, for example `Load skill <skill> first, then call propose_action.`, `One card per turn; the earlier card stands.`, `Unknown action "<action>". Registered actions: <actions>.` or the failing `details` fields.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Before a card skill loads, the tool adds no tokens. Afterwards each request of that Agent declares it: 574 characters of JSON, about 144 tokens at the token meter's four characters per token. A card costs one extra model round trip, the call before the final answer, and its short tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Activation is append-only on routes that declare tool updates, such as DeepSeek-V4.1-Flash: when the condition turns true the loop logs a header change and a `tool-addition` developer message, and those routes receive the definition as a deferred declaration referenced at that position, so the cached prefix is kept. A route without tool updates receives the tool in its declaration list from that step on, which changes the request prefix once. Disposing the Agent drops the activation; a reopened Session declares the tool again only after a card skill loads again, logging a removal and a later addition.
|
||||
|
||||
### Card status notice
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a decision is recorded, the next request carries one `action-cards` notice: the preamble below, the guidance sentences of each involved kind, `Continue with the user’s current request.`, and a JSON array of the changed cards with `action`, `callId`, `title`, `phase` and the data fields their kind reports.
|
||||
|
||||
##### Notice preamble
|
||||
|
||||
```markdown
|
||||
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.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
One notice per request that follows new decisions, sized by the changed cards; unchanged cards add nothing, and several decisions of one card collapse to its latest.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The notice is appended after the request's other new context and never rewrites earlier messages. The notice neither starts a turn nor wakes an idle conversation.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Activation is per live Agent and kept in memory, so after a Session reopens the tool appears again only after a card skill loads again.
|
||||
- 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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
description: "可选操作卡片的共享 Host 服务:propose_action 工具、卡片状态、用户操作和状态通知,各实验包把自己的卡片种类注册进来。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-action-cards
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
实验可以在回答下方提供一个可选操作,例如创建页面,而不必新增自己的工具。模型先加载该实验的 Skill,再调用 `propose_action`;本轮结束后卡片出现在回答下方,用户操作之前不会执行任何事。用户的每次选择都会被记录,并在模型的下一次请求中告知模型。对话中加载卡片 Skill 之前,这个工具不占用任何 token。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [实现原理](#understand-the-implementation)
|
||||
- [延伸阅读](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
[个性化页面 Bundle](../creation-recommendations/README.zh.md) 会插入本服务及其[浏览器端](../client-ui-action-cards/README.zh.md);启用该 Bundle 即同时启用两者。本服务只有一个配置字段:
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `queuedMessageLimit` | `2` | 排队到后续轮次的用户消息达到该数量时,本轮提议的卡片不再展示。 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-action-cards)是可接受字段的完整来源。
|
||||
|
||||
### 注册卡片种类
|
||||
|
||||
种类所在的包注入 `actionCards`,并在自己的插件中调用 `ctx.actionCards.register(kind)`;该插件卸载时种类随之移除,此后对其卡片的操作会以 unavailable 拒绝。种类需要给出:动作名、提议该动作的 Skill、卡片 `details` 的 zod 校验、接受的用户操作及各自需要的共享检查(`global` 表示需要全局 Remote 权限,`writes` 表示只读会话中拒绝)、仍可忽略卡片的已记录阶段、可选的状态说明、状态通知要报告的决定数据字段,以及处理 `dismiss` 以外所有操作的处理器。处理器用 `ctx.actionCards.record()` 记录每个新决定,并返回 `accepted`、带当前决定的 `settled`、`refused` 或 `unconfirmed`。
|
||||
|
||||
### 卡片规则
|
||||
|
||||
- 一轮中第一个被接受的 `propose_action` 调用成为卡片。工具会拒绝同一轮的第二次调用、未注册的动作、本轮没有加载对应种类 Skill 的调用、空白的标题或说明、未通过种类校验的 details,以及从代码中发起的调用。
|
||||
- 新卡片会替换此前所有尚未开始的卡片。用户排队到后续轮次的消息达到 `queuedMessageLimit` 条时提议的卡片只记录、不展示。这两种状态都从日志推导,从不单独记录。
|
||||
- fork 出的会话从父会话继承的事件不会产生卡片。
|
||||
- 对尚未开始的卡片,以及处于种类列出阶段的卡片,“暂不需要”会被记录;已忽略、已替换、未展示的卡片不再接受任何操作。
|
||||
- 同一张卡片上的并发操作共用一次处理。操作要求卡片所在会话的根 Agent 仍然在线。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 实现原理
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
本包默认导出 `actionCards` Remote 服务。它在全局注册一次 `propose_action`,并附带 `when` 条件:某个 `skill` 工具结果加载了已注册种类的 Skill 后,该 Agent 才满足条件;这个标记保存在内存中,直到 Agent 被释放。调用的 `execute` 通过轮次边界检查本轮加载过的 Skill,并在内存中标记本轮已接受的调用;日志中的 `tool/call` 及其成功的 `tool/result` 就是持久的提议。
|
||||
|
||||
`actionCards` 会话投影(状态版本 1)把每个 `propose_action` 调用与其成功结果配对,从 `plugin:action-cards/decision` 读取决定,从收件箱拼接事件累计排队到下一轮的消息,并保留尚未报告的决定,直到 `through` 覆盖它们的状态通知写入日志。投影的客户端视图 `{ proposals, decisions }` 不进入会话摘要。
|
||||
|
||||
Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在线根 Agent、卡片、种类与操作,以及 Remote 权限,然后让同一张卡片共用一次处理。之后,已忽略、已替换或未展示的卡片原样返回,“暂不需要”被记录,写入类操作在只读会话中被拒绝,其余操作在该 Agent 的发起方作用域内交给种类处理器。一个 `agent/pre-step` 监听器在后续监听器准备好本步之后追加状态通知。
|
||||
|
||||
| 源码 | 内容 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 服务、工具、种类注册表、Remote 操作与状态通知 |
|
||||
| [`src/projection.ts`](src/projection.ts) | 会话投影 |
|
||||
| [`src/types.ts`](src/types.ts) | 提议、决定、视图与结果类型 |
|
||||
| [`tests/`](tests/) | 激活、校验、投影、操作与通知测试 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
- [操作卡片显示](../client-ui-action-cards/README.zh.md) — 浏览器端卡片框架、种类正文与过程行。
|
||||
- [个性化页面](../creation-recommendations/README.zh.md) — 随产品提供的卡片种类。
|
||||
- [工具](../../core/tools/README.zh.md) — 条件注册与每步准入。
|
||||
- [会话投影](../../session/session-projection/README.zh.md) — 浏览器读取的卡片状态。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
### 卡片工具
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
[`propose_action` 声明](../../../docs/tool-catalog.zh.md#deepseek-aidsh-experimental-action-cards)只对在本会话中加载过已注册种类 Skill 的 Agent 出现,从下一步开始,持续到该 Agent 被释放。种类的 Skill 给出动作名和 `details` 的字段。被接受的调用返回 `Card shown below your answer. Do not restate it; end with one short invitation.`。被拒绝的调用返回原因,例如 `Load skill <skill> first, then call propose_action.`、`One card per turn; the earlier card stands.`、`Unknown action "<action>". Registered actions: <actions>.`,或未通过校验的 `details` 字段。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
加载卡片 Skill 之前,工具不增加任何 token。加载之后,该 Agent 的每次请求都会声明它:574 个字符的 JSON,按 token 计量器每 4 个字符计 1 个 token 约 144 个 token。出一张卡片多一次模型往返(最终回答前的这次调用)以及一条简短的工具结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在声明支持工具更新的路由上(例如 DeepSeek-V4.1-Flash),激活只追加内容:条件变为真时,循环记录一次请求头变化和一条 `tool-addition` 开发者消息,这些路由以延迟加载声明的形式在该位置引用新定义,因此已缓存的前缀保持不变。不支持工具更新的路由从这一步起在声明列表中收到该工具,请求前缀因此改变一次。Agent 被释放后激活随之失效;重新打开的会话要再次加载卡片 Skill 才会再次声明该工具,并依次记录一次移除和一次添加。
|
||||
|
||||
### 卡片状态通知
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
记录决定之后,下一次请求会带一条 `action-cards` 通知:下方的开头段落、所涉及各种类的说明句、`Continue with the user’s current request.`,以及一个 JSON 数组,列出发生变化的卡片的 `action`、`callId`、`title`、`phase` 和种类要报告的数据字段。
|
||||
|
||||
##### 通知开头
|
||||
|
||||
```markdown
|
||||
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.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有新决定之后的每次请求带一条通知,大小取决于发生变化的卡片;没有变化的卡片不增加内容,同一张卡片的多次决定只报告最新一次。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
通知追加在本次请求其他新上下文之后,从不改写之前的消息。通知既不会开始新轮次,也不会唤醒空闲的对话。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 激活按在线 Agent 记在内存中,因此会话重新打开后,要再次加载卡片 Skill 工具才会再次出现。
|
||||
- 从代码中发起的调用(例如 PTC 预设中)会被拒绝:日志把它们记为程序派发而不是工具调用,因此不可能有卡片跟随。
|
||||
- 所有种类合计每轮只展示一张卡片,每个对话只保留一张尚未开始的卡片。
|
||||
- 投影读取 `queuedMessageLimit`,状态版本却不随它变化。修改后的上限只作用于重新从日志折叠的会话;已缓存的投影行保留按旧上限得出的“未展示”结果。
|
||||
- 本服务及其浏览器端的行由个性化页面 Bundle 插入。第二个种类的 Bundle 若插入相同的行会与其冲突;多个 Bundle 共享这些行的方式留到第二个种类发布时再定。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作背景——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Action cards",
|
||||
"description": "Show optional action cards below answers for enabled experiments, and report the user's choices back to the conversation."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "アクションカード",
|
||||
"description": "有効な実験のために回答の下に任意のアクションカードを表示し、ユーザーの選択を会話に伝えます。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "操作卡片",
|
||||
"description": "为已启用的实验在回答下方显示可选操作卡片,并把用户的选择告知对话。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-action-cards",
|
||||
"description": "Optional action cards below answers: one shared proposal tool, card state, user actions and status notices",
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/action-cards"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/** Optional action cards below an answer: the `propose_action` tool, card state, user actions and status notices for registered kinds. */
|
||||
import { FiberState, Service, type Context } from '@deepseek-ai/cordis'
|
||||
import { z, type ZodError, type ZodType } from 'zod'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createUserMessage, HarnessError, type ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { appendPluginRecord, SessionSeq, type Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { actionCardProjection, PROPOSE_ACTION, type ActionCardState } from './projection.ts'
|
||||
import type { ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, ActionCardView } from './types.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Deployment-tunable limits for offering cards. */
|
||||
export const Config = z.object({
|
||||
/** Queued next-turn messages at or above which a card proposed 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 { actionCards: ActionCards }
|
||||
}
|
||||
|
||||
/** The user action every card offers; the shared service records it. */
|
||||
const DISMISS = 'dismiss'
|
||||
/** Phases after which no user action changes the card. */
|
||||
const FINAL_PHASES: readonly string[] = ['dismissed', 'replaced', 'suppressed']
|
||||
/** Tool whose successful loads activate `propose_action`; its `name` argument names the skill. */
|
||||
const SKILL_TOOL = 'skill'
|
||||
const skillArguments = z.object({ name: z.string() })
|
||||
|
||||
const STATUS_PREAMBLE = '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.'
|
||||
const STATUS_CLOSING = 'Continue with the user’s current request.'
|
||||
|
||||
/** The fiber fields the stopping check reads. */
|
||||
export interface PluginFiber {
|
||||
readonly uid: number | null
|
||||
readonly state: FiberState
|
||||
readonly parent: { readonly fiber: PluginFiber }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a plugin must refuse new work: its fiber or an ancestor is disposed or unloading, since a parent's disposal
|
||||
* begins before the plugin's own effects run, or the plugin's own drain has begun.
|
||||
* @param owner - the plugin's fiber.
|
||||
* @param draining - whether the plugin's drain effect has started.
|
||||
* @returns whether the plugin is stopping.
|
||||
*/
|
||||
export function isPluginStopping(owner: PluginFiber, draining: boolean): boolean {
|
||||
for (let fiber = owner; ; fiber = fiber.parent.fiber) {
|
||||
if (fiber.uid === null || fiber.state === FiberState.UNLOADING) return true
|
||||
if (fiber.parent.fiber === fiber) return draining
|
||||
}
|
||||
}
|
||||
|
||||
/** A refused `propose_action` call; the message tells the model what to do instead. */
|
||||
export class ActionCardError extends HarnessError {}
|
||||
|
||||
/** Shared checks one user action of a kind requires before the kind handles it. */
|
||||
export interface ActionCardVerb {
|
||||
/** The connection must hold global Remote access, as for profile changes. */
|
||||
readonly global?: true
|
||||
/** Refused while the Session sandbox mode is read-only. */
|
||||
readonly writes?: true
|
||||
}
|
||||
|
||||
/** A proposal whose details passed its kind's schema. */
|
||||
export type ActionCardProposalOf<Details> = Omit<ActionCardProposal, 'details'> & { readonly details: Details }
|
||||
|
||||
/** Host half of one action kind: the skill that proposes it, its fields and its user actions. */
|
||||
export interface ActionCardKind<Details> {
|
||||
/** Value of the `action` argument. */
|
||||
readonly action: string
|
||||
/** Skill whose instructions propose this action; a load in the current turn admits the call. */
|
||||
readonly skill: string
|
||||
/** Validates `details` when the model proposes and before each user action. */
|
||||
readonly details: ZodType<Details>
|
||||
/** User actions besides `dismiss`, with the shared checks each requires; other verbs are refused as unavailable. */
|
||||
readonly verbs: Readonly<Record<string, ActionCardVerb>>
|
||||
/** Recorded phases that still offer the action; dismissal is recorded for these and for an unstarted card. */
|
||||
readonly openPhases?: readonly string[]
|
||||
/** Sentences the status notice adds when it reports this kind's decisions. */
|
||||
readonly statusGuidance?: string
|
||||
/** Decision data fields the status notice reports; omitted reports every field. */
|
||||
readonly statusFields?: readonly string[]
|
||||
/**
|
||||
* Fields recorded with a dismissal.
|
||||
* @param proposal - the dismissed card.
|
||||
* @param payload - fields the user's client sent.
|
||||
* @returns decision data, or undefined to record none.
|
||||
*/
|
||||
dismissal?(proposal: ActionCardProposalOf<Details>, payload: ActionCardFields | undefined): ActionCardFields | undefined
|
||||
/**
|
||||
* Handle one user action other than `dismiss` after the shared checks passed. The handler records each new
|
||||
* decision with {@link ActionCards.record}.
|
||||
* @param agent - live root of the card's Session.
|
||||
* @param proposal - the card with validated details.
|
||||
* @param verb - one of {@link verbs}.
|
||||
* @param payload - fields the user's client sent; the kind validates them.
|
||||
* @returns the action's result.
|
||||
*/
|
||||
act(agent: Agent, proposal: ActionCardProposalOf<Details>, verb: string, payload: ActionCardFields | undefined): Promise<ActionCardResult>
|
||||
}
|
||||
|
||||
/** A registered kind with its details type erased behind validating closures. */
|
||||
interface RegisteredKind {
|
||||
readonly skill: string
|
||||
readonly verbs: Readonly<Record<string, ActionCardVerb>>
|
||||
readonly openPhases: readonly string[]
|
||||
readonly statusGuidance: string | undefined
|
||||
readonly statusFields: readonly string[] | undefined
|
||||
/** Describe why details fail the kind's schema; undefined when they pass. */
|
||||
readonly detailsIssues: (details: ActionCardFields) => string | undefined
|
||||
readonly dismissal: (proposal: ActionCardProposal, payload: ActionCardFields | undefined) => ActionCardFields | undefined
|
||||
readonly act: (
|
||||
agent: Agent, proposal: ActionCardProposal, verb: string, payload: ActionCardFields | undefined,
|
||||
) => Promise<ActionCardResult>
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
function refused(reason: string): ActionCardResult {
|
||||
return { status: 'refused', reason }
|
||||
}
|
||||
|
||||
function issuesOf(error: ZodError): string {
|
||||
return 'Invalid details: ' + error.issues.map(issue => `${['details', ...issue.path.map(String)].join('.')}: ${issue.message}`).join('; ')
|
||||
}
|
||||
|
||||
/** Card tool, kind registry, Session projection, user actions and status notices. */
|
||||
export class ActionCards extends TypertRemoteService {
|
||||
static inject = ['tools', 'agents', 'sessionProjections', 'remoteAccess']
|
||||
static Config = Config
|
||||
private projection!: ProjectionHandle<ActionCardState>
|
||||
private readonly kinds = new Map<string, RegisteredKind>()
|
||||
/** Skills of registered kinds each live agent has loaded; `propose_action` is declared while one is still registered. */
|
||||
private readonly loaded = new Map<Agent, Set<string>>()
|
||||
/** Kind skills each agent loaded in its latest turn. */
|
||||
private readonly turnLoads = new Map<Agent, { readonly turn: number; readonly skills: Set<string> }>()
|
||||
/** The call accepted in each agent's latest turn, until its result fails. */
|
||||
private readonly accepted = new Map<Agent, { readonly turn: number; readonly callId: ToolCallId }>()
|
||||
private readonly actions = new Map<string, Promise<ActionCardResult>>()
|
||||
private closing = false
|
||||
private readonly owner: Context['fiber']
|
||||
|
||||
/**
|
||||
* @param ctx - Owning plugin context.
|
||||
* @param config - Validated card limits; direct composition without a config gets the schema defaults.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly config: Config = Config.parse({})) {
|
||||
super(ctx, 'actionCards')
|
||||
this.owner = ctx.fiber
|
||||
}
|
||||
|
||||
protected async [Service.init](): Promise<void> {
|
||||
this.projection = await this.ctx.sessionProjections.register(actionCardProjection(this.config))
|
||||
this.ctx.tools.register(defineTool({
|
||||
name: PROPOSE_ACTION,
|
||||
description: 'Show the user one optional action card under this answer. Call only as the skill that names the action instructs.',
|
||||
parameters: {
|
||||
action: { type: 'string', required: true, description: 'Action name from the skill.' },
|
||||
title: { type: 'string', required: true, description: 'Card title.' },
|
||||
brief: { type: 'string', required: true, description: 'What the action does, in the user\'s language.' },
|
||||
details: { type: 'object', additionalProperties: true, required: true, description: 'Fields the skill lists for this action.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object', additionalProperties: false,
|
||||
properties: { action: { type: 'string', required: true }, title: { type: 'string', required: true } },
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Card shown below your answer. Do not restate it; end with one short invitation.' }],
|
||||
},
|
||||
execute: (args, exec) => Promise.resolve(this.propose(args, exec)),
|
||||
}), { when: ({ agent }) => this.declares(agent) })
|
||||
this.ctx.on('tools/result', (exec, result) => {
|
||||
const agent = exec.agent
|
||||
if (agent === undefined) return
|
||||
if (exec.name === PROPOSE_ACTION) {
|
||||
if (result.isError && this.accepted.get(agent)?.callId === exec.callId) this.accepted.delete(agent)
|
||||
return
|
||||
}
|
||||
const skill = exec.name === SKILL_TOOL && !result.isError ? skillArguments.safeParse(exec.arguments) : undefined
|
||||
if (skill?.success !== true || ![...this.kinds.values()].some(kind => kind.skill === skill.data.name)) return
|
||||
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn
|
||||
const loads = this.turnLoads.get(agent)
|
||||
if (loads?.turn === turn) loads.skills.add(skill.data.name)
|
||||
else this.turnLoads.set(agent, { turn, skills: new Set([skill.data.name]) })
|
||||
this.loaded.set(agent, new Set([...this.loaded.get(agent) ?? [], skill.data.name]))
|
||||
})
|
||||
this.ctx.on('agent/disposed', ({ agent }) => {
|
||||
this.loaded.delete(agent)
|
||||
this.turnLoads.delete(agent)
|
||||
this.accepted.delete(agent)
|
||||
})
|
||||
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.values(state.pendingStatus)
|
||||
if (pending.length === 0) return decision
|
||||
const receipts = Object.values(state.view.decisions).filter(receipt => Object.hasOwn(state.pendingStatus, receipt.callId))
|
||||
const guidance = new Set(receipts.flatMap(receipt => this.kinds.get(receipt.action)?.statusGuidance ?? []))
|
||||
const updates = receipts.map((receipt) => {
|
||||
const fields = this.kinds.get(receipt.action)?.statusFields
|
||||
const data = receipt.data === undefined || fields === undefined ? receipt.data
|
||||
: Object.fromEntries(Object.entries(receipt.data).filter(([key]) => fields.includes(key)))
|
||||
return { action: receipt.action, callId: receipt.callId, title: ownEntry(state.view.proposals, receipt.callId)?.title,
|
||||
phase: receipt.phase, ...data === undefined ? {} : { data } }
|
||||
})
|
||||
return { ...decision, messages: [...decision.messages, createUserMessage({
|
||||
source: { kind: 'action-cards', through: SessionSeq(Math.max(...pending)), form: 'notice', summary: 'Action card status updated' },
|
||||
content: [{ type: 'text', text: [STATUS_PREAMBLE, ...guidance, STATUS_CLOSING].join(' ') + '\n' + JSON.stringify(updates) }],
|
||||
})] }
|
||||
}, { prepend: true })
|
||||
this.ctx.effect(() => async () => {
|
||||
this.closing = true
|
||||
await Promise.allSettled([...this.actions.values()])
|
||||
}, 'action-cards: drain user actions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one action kind for the caller's lifetime.
|
||||
* @param kind - the kind's skill, details schema, verbs and handler.
|
||||
* @returns disposer removing the kind; actions on its cards are then unavailable.
|
||||
* @throws when another kind already registered the same action.
|
||||
*/
|
||||
register<Details>(kind: ActionCardKind<Details>): () => void {
|
||||
const typed = (proposal: ActionCardProposal): ActionCardProposalOf<Details> | undefined => {
|
||||
const details = kind.details.safeParse(proposal.details)
|
||||
return details.success ? { ...proposal, details: details.data } : undefined
|
||||
}
|
||||
const entry: RegisteredKind = {
|
||||
skill: kind.skill, verbs: kind.verbs, openPhases: kind.openPhases ?? [],
|
||||
statusGuidance: kind.statusGuidance, statusFields: kind.statusFields,
|
||||
detailsIssues: (details) => {
|
||||
const parsed = kind.details.safeParse(details)
|
||||
return parsed.success ? undefined : issuesOf(parsed.error)
|
||||
},
|
||||
dismissal: (proposal, payload) => {
|
||||
const card = typed(proposal)
|
||||
return card === undefined ? undefined : kind.dismissal?.(card, payload)
|
||||
},
|
||||
act: (agent, proposal, verb, payload) => {
|
||||
const card = typed(proposal)
|
||||
return card === undefined ? Promise.resolve(refused('unavailable')) : kind.act(agent, card, verb, payload)
|
||||
},
|
||||
}
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.kinds.has(kind.action)) throw new Error(`action-cards: action "${kind.action}" is already registered`)
|
||||
this.kinds.set(kind.action, entry)
|
||||
return () => { this.kinds.delete(kind.action) }
|
||||
}, `action-cards: kind ${kind.action}`)
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a card decision in the Session log; the next model request reports it.
|
||||
* @param agent - agent whose Session owns the card.
|
||||
* @param decision - the card's new state.
|
||||
*/
|
||||
record(agent: Agent, decision: ActionCardDecision): void {
|
||||
appendPluginRecord(agent.session, 'plugin:action-cards/decision', decision)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cards of one Session.
|
||||
* @param session - a Session whose projections are prepared.
|
||||
* @returns proposals and decisions by call id.
|
||||
*/
|
||||
view(session: Session): ActionCardView {
|
||||
return this.projection.stateOf(session).view
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a user action to a logged card. Shared checks run first; `dismiss` is recorded here and every other
|
||||
* verb goes to the card's kind. Concurrent actions on one card share one operation.
|
||||
* @param agent - live root resolved from the displayed Session.
|
||||
* @param callId - the card's `propose_action` call id.
|
||||
* @param verb - `dismiss` or one of the kind's verbs.
|
||||
* @param payload - kind-defined fields such as a chosen location.
|
||||
* @returns acceptance of background work, the decision in effect, or a refusal.
|
||||
*/
|
||||
@Remote
|
||||
act(agent: Agent, callId: ToolCallId, verb: string, payload?: Record<string, JsonValue>): Promise<ActionCardResult> {
|
||||
if (this.isStopping()) return Promise.resolve(refused('closing'))
|
||||
if (!this.ctx.agents.roots().includes(agent)) return Promise.resolve(refused('not-live-root'))
|
||||
const proposal = ownEntry(this.projection.stateOf(agent.session).view.proposals, callId)
|
||||
if (proposal === undefined) return Promise.resolve(refused('unknown'))
|
||||
const kind = this.kinds.get(proposal.action)
|
||||
const policy = kind === undefined || verb === DISMISS ? undefined : ownEntry(kind.verbs, verb)
|
||||
if (kind === undefined || verb !== DISMISS && policy === undefined) return Promise.resolve(refused('unavailable'))
|
||||
if (policy?.global === true && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve(refused('access-denied'))
|
||||
const key = JSON.stringify([agent.id, callId])
|
||||
const existing = this.actions.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const operation = this.ctx.agents.withInitiator(agent, () => this.decide(agent, kind, proposal, verb, policy, payload))
|
||||
.finally(() => { this.actions.delete(key) })
|
||||
this.actions.set(key, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async decide(
|
||||
agent: Agent, kind: RegisteredKind, proposal: ActionCardProposal, verb: string, policy: ActionCardVerb | undefined,
|
||||
payload: ActionCardFields | undefined,
|
||||
): Promise<ActionCardResult> {
|
||||
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId)
|
||||
if (decision !== undefined && FINAL_PHASES.includes(decision.phase)) return { status: 'settled', decision }
|
||||
if (verb === DISMISS) {
|
||||
if (decision !== undefined && !kind.openPhases.includes(decision.phase)) return { status: 'settled', decision }
|
||||
const data = kind.dismissal(proposal, payload)
|
||||
const dismissed: ActionCardDecision = { callId: proposal.callId, action: proposal.action, phase: 'dismissed', ...data === undefined ? {} : { data } }
|
||||
this.record(agent, dismissed)
|
||||
return { status: 'settled', decision: dismissed }
|
||||
}
|
||||
if (policy?.writes === true && this.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }).mode === 'read-only') return refused('read-only')
|
||||
return await kind.act(agent, proposal, verb, payload)
|
||||
}
|
||||
|
||||
/** Whether `propose_action` is declared for this agent's next step. */
|
||||
private declares(agent: Agent): boolean {
|
||||
const skills = this.loaded.get(agent)
|
||||
return skills !== undefined && [...this.kinds.values()].some(kind => skills.has(kind.skill))
|
||||
}
|
||||
|
||||
private propose(
|
||||
args: { readonly action: string; readonly title: string; readonly brief: string; readonly details: ActionCardFields },
|
||||
exec: ToolRunContext,
|
||||
): { action: string; title: string } {
|
||||
const agent = exec.agent
|
||||
// Step admission declares the tool only to an agent; calls dispatched from code are not logged as tool calls,
|
||||
// so no card could follow them.
|
||||
if (agent === undefined || exec.parent !== undefined) throw new ActionCardError('Call propose_action directly, not from code.', 'ACTION_CARD_NESTED')
|
||||
const kind = this.kinds.get(args.action)
|
||||
// The tool is declared only while a kind is registered, so the list is never empty.
|
||||
if (kind === undefined) throw new ActionCardError(`Unknown action "${args.action}". Registered actions: ${[...this.kinds.keys()].join(', ')}.`, 'ACTION_CARD_UNKNOWN')
|
||||
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn
|
||||
const loads = this.turnLoads.get(agent)
|
||||
if (loads?.turn !== turn || !loads.skills.has(kind.skill)) {
|
||||
throw new ActionCardError(`Load skill ${kind.skill} first, then call propose_action.`, 'ACTION_CARD_SKILL')
|
||||
}
|
||||
if (args.title.trim() === '' || args.brief.trim() === '') throw new ActionCardError('Write a title and a brief.', 'ACTION_CARD_BLANK')
|
||||
const issues = kind.detailsIssues(args.details)
|
||||
if (issues !== undefined) throw new ActionCardError(issues, 'ACTION_CARD_DETAILS')
|
||||
if (this.accepted.get(agent)?.turn === turn || Object.values(this.view(agent.session).proposals).some(card => card.turn === turn)) {
|
||||
throw new ActionCardError('One card per turn; the earlier card stands.', 'ACTION_CARD_LIMIT')
|
||||
}
|
||||
this.accepted.set(agent, { turn, callId: exec.callId })
|
||||
return { action: args.action, title: args.title.trim() }
|
||||
}
|
||||
|
||||
private isStopping(): boolean {
|
||||
return isPluginStopping(this.owner, this.closing)
|
||||
}
|
||||
}
|
||||
|
||||
export default ActionCards
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Recover card proposals from logged `propose_action` calls and decisions from plugin records. */
|
||||
import { z } from 'zod'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { pluginRecordOf, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
/** Model-facing name of the card tool. */
|
||||
export const PROPOSE_ACTION = 'propose_action'
|
||||
|
||||
/** Kind-defined JSON object of a proposal or decision. */
|
||||
export const fieldsSchema = z.record(z.string(), z.json())
|
||||
|
||||
/** Arguments of a `propose_action` call; blank titles and briefs create no card. */
|
||||
export const argumentsSchema = z.object({
|
||||
action: z.string().min(1),
|
||||
title: z.string().trim().min(1),
|
||||
brief: z.string().trim().min(1),
|
||||
details: fieldsSchema,
|
||||
})
|
||||
|
||||
const proposalSchema = z.object({
|
||||
callId: z.string().min(1).transform(ToolCallId),
|
||||
turn: z.number().int().nonnegative(),
|
||||
action: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
brief: z.string().min(1),
|
||||
details: fieldsSchema,
|
||||
}).strict()
|
||||
|
||||
/** Recorded decision of one card. */
|
||||
export const decisionSchema = z.object({
|
||||
callId: z.string().min(1).transform(ToolCallId),
|
||||
action: z.string().min(1),
|
||||
phase: z.string().min(1),
|
||||
data: fieldsSchema.optional(),
|
||||
}).strict()
|
||||
|
||||
const viewSchema = z.object({
|
||||
proposals: z.record(z.string(), proposalSchema),
|
||||
decisions: z.record(z.string(), decisionSchema),
|
||||
})
|
||||
|
||||
const stateSchema = z.object({
|
||||
/** Fork-inherited prefix length; inherited events create no proposals or decisions. */
|
||||
inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset),
|
||||
view: viewSchema,
|
||||
/** `propose_action` calls of the open turn still waiting for their result. */
|
||||
calls: z.record(z.string(), proposalSchema.omit({ callId: true })),
|
||||
/** Turn of the latest admitted proposal; later calls of that turn create no card. */
|
||||
admittedTurn: z.number().int().nullable(),
|
||||
/** Decisions not yet reported to the model, by call id, with the seq of their record. */
|
||||
pendingStatus: z.record(z.string(), z.number()),
|
||||
/** User messages still queued for later turns, folded from inbox splices. */
|
||||
queued: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
/** Fold state; `view` is also the Client wire value. */
|
||||
export type ActionCardState = z.infer<typeof stateSchema>
|
||||
|
||||
/** Card limits the fold needs; the owning service supplies its validated configuration. */
|
||||
export interface ActionCardLimits { readonly queuedMessageLimit: number }
|
||||
|
||||
/** Definition returned by {@link actionCardProjection}: the fold plus its Client view wire. */
|
||||
export type ActionCardProjectionDefinition = Omit<ProjectionDefinition<'actionCards', ActionCardState>, 'wire'> & {
|
||||
readonly wire: NonNullable<ProjectionDefinition<'actionCards', ActionCardState>['wire']>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-Session fold. A successful `propose_action` result admits the call as a card, at most one per turn;
|
||||
* the first admitted call of a turn wins. A newer card replaces every unstarted earlier card, and a card
|
||||
* written while `queuedMessageLimit` or more user messages are queued for later turns is suppressed.
|
||||
* Events inherited by a forked Session create no proposals or decisions.
|
||||
* @param limits - validated card limits.
|
||||
* @returns the projection definition for this configuration.
|
||||
*/
|
||||
export function actionCardProjection(limits: ActionCardLimits): ActionCardProjectionDefinition {
|
||||
return {
|
||||
key: 'actionCards', stateVersion: 1, stateSchema,
|
||||
init: (_header, inheritedEventCount) => ({
|
||||
inheritedEventCount, view: { proposals: {}, decisions: {} }, calls: {}, admittedTurn: null, pendingStatus: {}, queued: 0,
|
||||
}),
|
||||
apply: (state, event) => {
|
||||
if (event.seq < state.inheritedEventCount) return state
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
// A turn's calls settle inside it; a call left without a result never becomes a card.
|
||||
return Object.keys(state.calls).length === 0 ? state : { ...state, calls: {} }
|
||||
case '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) }
|
||||
case 'user/message': {
|
||||
if (event.data.source.kind !== 'action-cards') return state
|
||||
const through = event.data.source.through
|
||||
return { ...state, pendingStatus: Object.fromEntries(Object.entries(state.pendingStatus).filter(([, seq]) => seq > through)) }
|
||||
}
|
||||
case 'tool/call': {
|
||||
if (event.data.name !== PROPOSE_ACTION) return state
|
||||
let raw: unknown
|
||||
try { raw = JSON.parse(event.data.arguments) }
|
||||
catch { return state } // Malformed model JSON failed in the tool too and creates no card.
|
||||
const parsed = argumentsSchema.safeParse(raw)
|
||||
if (!parsed.success) return state
|
||||
return { ...state, calls: { ...state.calls, [event.data.callId]: { turn: event.data.turn, ...parsed.data } } }
|
||||
}
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.toolCallId
|
||||
const call = Object.hasOwn(state.calls, callId) ? state.calls[callId] : undefined
|
||||
if (call === undefined) return state
|
||||
const calls = Object.fromEntries(Object.entries(state.calls).filter(([id]) => id !== callId))
|
||||
if (event.data.message.isError === true || state.admittedTurn === call.turn) return { ...state, calls }
|
||||
const decisions = { ...state.view.decisions }
|
||||
if (state.queued >= limits.queuedMessageLimit) {
|
||||
// The user has moved on; the card is logged but never offered.
|
||||
decisions[callId] = { callId, action: call.action, phase: 'suppressed' }
|
||||
} else {
|
||||
// A newer card supersedes every unstarted card; the model made that choice, so no status is reported.
|
||||
for (const earlier of Object.values(state.view.proposals)) {
|
||||
if (decisions[earlier.callId] === undefined) decisions[earlier.callId] = { callId: earlier.callId, action: earlier.action, phase: 'replaced' }
|
||||
}
|
||||
}
|
||||
return { ...state, calls, admittedTurn: call.turn,
|
||||
view: { proposals: { ...state.view.proposals, [callId]: { callId, ...call } }, decisions } }
|
||||
}
|
||||
default: {
|
||||
const record = pluginRecordOf(event)
|
||||
if (record?.type !== 'plugin:action-cards/decision') return state
|
||||
// A malformed or newer record is skipped; throwing here would fail every later read of this Session.
|
||||
const decision = decisionSchema.safeParse(record.data)
|
||||
if (!decision.success) return state
|
||||
return { ...state, view: { ...state.view, decisions: { ...state.view.decisions, [decision.data.callId]: decision.data } },
|
||||
pendingStatus: { ...state.pendingStatus, [decision.data.callId]: event.seq } }
|
||||
}
|
||||
}
|
||||
},
|
||||
wire: { summary: false, viewSchema, view: (state: ActionCardState) => state.view },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Card proposals, recorded decisions and user-action results shared by every action kind. */
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/** Kind-defined JSON fields of a proposal or decision; each kind validates its own fields. */
|
||||
export type ActionCardFields = Readonly<Record<string, JsonValue>>
|
||||
|
||||
/** One card admitted from a successful `propose_action` call. */
|
||||
export interface ActionCardProposal {
|
||||
/** Id of the logged tool call. */
|
||||
readonly callId: ToolCallId
|
||||
/** Turn whose answer the card follows. */
|
||||
readonly turn: number
|
||||
/** Registered action kind named by the call. */
|
||||
readonly action: string
|
||||
readonly title: string
|
||||
/** What the action does, in the user's language. */
|
||||
readonly brief: string
|
||||
/** Fields the kind's skill asks for. */
|
||||
readonly details: ActionCardFields
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest state of one card. Kinds define their own phases; `dismissed` is shared.
|
||||
* `replaced` (a later proposal superseded an unstarted card) and `suppressed`
|
||||
* (the proposal was written while the user had queued messages) are derived
|
||||
* from the log and never recorded.
|
||||
*/
|
||||
export interface ActionCardDecision {
|
||||
readonly callId: ToolCallId
|
||||
readonly action: string
|
||||
readonly phase: string
|
||||
/** Kind-defined fields such as a chosen location or a created package. */
|
||||
readonly data?: ActionCardFields | undefined
|
||||
}
|
||||
|
||||
/** Open-Session proposals and decisions by call id, excluded from Session summaries. */
|
||||
export interface ActionCardView {
|
||||
readonly proposals: Readonly<Record<string, ActionCardProposal>>
|
||||
readonly decisions: Readonly<Record<string, ActionCardDecision>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a user action. `accepted` starts background work that records its
|
||||
* own progress; `settled` returns the decision now in effect; `refused` changed
|
||||
* nothing; `unconfirmed` lost its outcome to an unloading owner, which the
|
||||
* kind reconciles when the Session reopens.
|
||||
*/
|
||||
export type ActionCardResult =
|
||||
| { readonly status: 'accepted' }
|
||||
| { readonly status: 'settled'; readonly decision: ActionCardDecision }
|
||||
| { readonly status: 'refused'; readonly reason: string }
|
||||
| { readonly status: 'unconfirmed'; readonly reason: string }
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/** Card status notice; other readers preserve the recorded text without this plugin.
|
||||
* The producer uses through only to acknowledge delivered decisions.
|
||||
* @persistenceAttribution
|
||||
*/
|
||||
'action-cards': { kind: 'action-cards'; through: SessionSeq } & ContextFormed
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface PluginRecordMap {
|
||||
/** Dismissals and kind-defined progress of action cards. */
|
||||
'plugin:action-cards/decision': ActionCardDecision
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Action card proposals and decisions for the currently open Session. */
|
||||
actionCards: SessionProjectionSummaryExcluded<ActionCardView>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`reports recorded decisions once on the next request, with each kind guidance once, without waking the conversation > status notice 1`] = `
|
||||
[
|
||||
{
|
||||
"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. Failed runs can be retried from their card. Continue with the user’s current request.
|
||||
[{"action":"test_action","callId":"call-card-1","title":"Counter","phase":"failed","data":{"reason":"timeout"}},{"action":"test_action","callId":"call-card-2","title":"Second","phase":"dismissed"},{"action":"retired_action","callId":"call-orphan","phase":"done"}]",
|
||||
"type": "text",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,36 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`declares propose_action from the step after a card skill loads, and logs the addition > tool declaration 1`] = `
|
||||
{
|
||||
"description": "Show the user one optional action card under this answer. Call only as the skill that names the action instructs.",
|
||||
"name": "propose_action",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"description": "Action name from the skill.",
|
||||
"type": "string",
|
||||
},
|
||||
"brief": {
|
||||
"description": "What the action does, in the user's language.",
|
||||
"type": "string",
|
||||
},
|
||||
"details": {
|
||||
"additionalProperties": true,
|
||||
"description": "Fields the skill lists for this action.",
|
||||
"type": "object",
|
||||
},
|
||||
"title": {
|
||||
"description": "Card title.",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"title",
|
||||
"brief",
|
||||
"details",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ActionCardResult } from '../src/index.ts'
|
||||
import { ACTION, callId, fixture, proposal } from './fixture.ts'
|
||||
|
||||
const dismissed = (data?: object) => ({ status: 'settled', decision: { callId, action: ACTION, phase: 'dismissed', ...data === undefined ? {} : { data } } })
|
||||
|
||||
it('refuses unknown cards, closed owners, removed kinds and undeclared verbs before any handler runs', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'unknown' })
|
||||
f.propose()
|
||||
for (const id of ['toString', 'constructor']) {
|
||||
expect(await f.ctx.actionCards.act(f.agent, ToolCallId(id), 'dismiss')).toEqual({ status: 'refused', reason: 'unknown' })
|
||||
}
|
||||
for (const verb of ['toString', 'missing']) {
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, verb)).toEqual({ status: 'refused', reason: 'unavailable' })
|
||||
}
|
||||
const roots = vi.spyOn(f.ctx.agents, 'roots').mockReturnValue([])
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'not-live-root' })
|
||||
roots.mockRestore()
|
||||
await f.kindFiber.dispose()
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'unavailable' })
|
||||
const service = f.ctx.actionCards
|
||||
await f.cards.dispose()
|
||||
expect(await service.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'closing' })
|
||||
expect(f.act).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the shared access and read-only checks the kind declares, then hands typed details to the kind', async () => {
|
||||
const f = await fixture()
|
||||
f.propose()
|
||||
const access = vi.spyOn(f.ctx.remoteAccess, 'allows').mockReturnValue(false)
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'publish')).toEqual({ status: 'refused', reason: 'access-denied' })
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'start', { note: 'x' })).toEqual({ status: 'settled', decision: { callId, action: ACTION, phase: 'start' } })
|
||||
access.mockRestore()
|
||||
expect(f.act).toHaveBeenCalledWith(f.agent, { callId, turn: 1, ...proposal }, 'start', { note: 'x' })
|
||||
const resolve = vi.fn(() => ({ mode: 'read-only', workspaceRoot: '/' }))
|
||||
f.ctx.provide('sandboxPolicy', { resolve })
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'write')).toEqual({ status: 'refused', reason: 'read-only' })
|
||||
expect(resolve).toHaveBeenCalledWith({ session: f.agent.session })
|
||||
expect((await f.ctx.actionCards.act(f.agent, callId, 'publish')).status).toBe('settled')
|
||||
resolve.mockReturnValue({ mode: 'workspace-write', workspaceRoot: '/' })
|
||||
expect((await f.ctx.actionCards.act(f.agent, callId, 'write')).status).toBe('settled')
|
||||
f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2)
|
||||
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'start')).toEqual({ status: 'refused', reason: 'unavailable' })
|
||||
expect(f.act).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('records dismissal of an unstarted or open card with the kind data, and settles started or final cards', async () => {
|
||||
const f = await fixture({
|
||||
openPhases: ['failed'],
|
||||
dismissal: (card, payload) => ({ value: card.details.value, ...z.object({ choice: z.string() }).safeParse(payload).data }),
|
||||
})
|
||||
f.propose()
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed({ value: 'a', choice: 'later' }))
|
||||
expect(f.view().decisions[callId]?.phase).toBe('dismissed')
|
||||
for (const verb of ['dismiss', 'start']) expect(await f.ctx.actionCards.act(f.agent, callId, verb)).toEqual(dismissed({ value: 'a', choice: 'later' }))
|
||||
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'failed' })
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual(dismissed({ value: 'a' }))
|
||||
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'running', data: { step: 1 } })
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'settled',
|
||||
decision: { callId, action: ACTION, phase: 'running', data: { step: 1 } } })
|
||||
f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2)
|
||||
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'dismiss')).toEqual({ status: 'settled',
|
||||
decision: { callId: 'call-invalid', action: ACTION, phase: 'dismissed' } })
|
||||
expect(f.act).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses without data when the kind records none', async () => {
|
||||
const f = await fixture()
|
||||
f.propose()
|
||||
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed())
|
||||
})
|
||||
|
||||
it('shares one operation between concurrent actions on a card and drains it on unload', async () => {
|
||||
const pending = Promise.withResolvers<ActionCardResult>()
|
||||
onTestFinished(() => { pending.resolve({ status: 'accepted' }) })
|
||||
const f = await fixture({ act: vi.fn(() => pending.promise) })
|
||||
f.propose()
|
||||
const first = f.ctx.actionCards.act(f.agent, callId, 'start')
|
||||
expect(f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toBe(first)
|
||||
const disposing = f.cards.dispose()
|
||||
pending.resolve({ status: 'accepted' })
|
||||
expect(await first).toEqual({ status: 'accepted' })
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('rejects a second kind for the same action and releases a kind through its disposer', async () => {
|
||||
const f = await fixture()
|
||||
const kind = (action: string) => ({ action, skill: 'other-skill', details: z.object({}), verbs: { start: {} },
|
||||
act: () => Promise.resolve<ActionCardResult>({ status: 'accepted' }) })
|
||||
expect(() => f.ctx.actionCards.register(kind(ACTION))).toThrow('action-cards: action "test_action" is already registered')
|
||||
const unregister = f.ctx.actionCards.register(kind('other_action'))
|
||||
f.propose(ToolCallId('call-other'), { action: 'other_action', title: 'Other', brief: 'Other card.', details: {} })
|
||||
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-other'), 'start')).toEqual({ status: 'accepted' })
|
||||
unregister()
|
||||
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-other'), 'start')).toEqual({ status: 'refused', reason: 'unavailable' })
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Real Agent, Tools, skills and projections with the card service and one scripted test kind. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { onTestFinished, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
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, { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillTool from '@deepseek-ai/dsh-tool-skill'
|
||||
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ActionCards from '../src/index.ts'
|
||||
import type { ActionCardFields, ActionCardKind, ActionCardProposalOf, ActionCardResult } from '../src/index.ts'
|
||||
|
||||
export const ACTION = 'test_action'
|
||||
export const SKILL = 'test-skill'
|
||||
export const callId = ToolCallId('call-card-1')
|
||||
export const proposal = { action: ACTION, title: 'Counter', brief: 'Keep a counter visible.', details: { value: 'a' } }
|
||||
|
||||
/** Details of the test kind. */
|
||||
export interface TestDetails { readonly value: string }
|
||||
|
||||
/** Provide one model-invocable skill per name. */
|
||||
export function provideSkills(ctx: Context, ...names: string[]): void {
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'card-skills',
|
||||
list: () => Promise.resolve(names.map(name => ({
|
||||
name, description: `Use ${name}.`, provider: 'card-skills', source: 'bundled' as const, rank: BUNDLED_SKILL_RANK, locator: null,
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
}))),
|
||||
get: candidate => Promise.resolve({ name: candidate.name, description: candidate.description, provider: 'card-skills', source: 'bundled' as const,
|
||||
invocation: candidate.invocation, content: `Call propose_action for ${candidate.name}.` }),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Mount the card service with one registered kind whose handler is a spy. */
|
||||
export async function fixture(kind: Partial<ActionCardKind<TestDetails>> = {}) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
await mountAgentLoopTestDependencies(ctx, { workingDirectory: true })
|
||||
const harness = await mountAgentLoopTestHarness(ctx)
|
||||
await ctx.plugin(RemoteAccess)
|
||||
await ctx.plugin(Skills)
|
||||
await ctx.plugin(SkillTool)
|
||||
provideSkills(ctx, SKILL, 'other-skill')
|
||||
const cards = await ctx.plugin(ActionCards)
|
||||
type Handler = (
|
||||
agent: Agent, card: ActionCardProposalOf<TestDetails>, verb: string, payload: ActionCardFields | undefined,
|
||||
) => Promise<ActionCardResult>
|
||||
const act = vi.fn<Handler>(async (_agent, card, verb) =>
|
||||
({ status: 'settled', decision: { callId: card.callId, action: ACTION, phase: verb } }))
|
||||
const kindFiber = await ctx.plugin({ name: 'test-kind', inject: ['actionCards'], apply: (inner: Context) => {
|
||||
inner.actionCards.register({
|
||||
action: ACTION, skill: SKILL, details: z.object({ value: z.string() }),
|
||||
verbs: { start: {}, publish: { global: true }, write: { writes: true } },
|
||||
act, ...kind,
|
||||
})
|
||||
} })
|
||||
const agentHandle = await ctx.agents.create({ sessionId: SessionId('cards-root') })
|
||||
const agent = agentHandle.agent
|
||||
const events: SessionEvent[] = []
|
||||
ctx.on('session/event', (session, event) => { if (session === agent.session) events.push(event) })
|
||||
let step = 0
|
||||
/** Log a successful `propose_action` call, as the loop does after the tool accepts it. */
|
||||
const propose = (id: ToolCallId = callId, args: object = proposal, caller = agent, turn = 1) => {
|
||||
caller.session.append('tool/call', { turn, step: ++step, callId: id, name: 'propose_action', arguments: JSON.stringify(args) })
|
||||
caller.session.append('tool/result', { turn, step, message: createToolResultMessage({ callId: id, content: [], isError: false }) }, { surfaceOp: 'append' })
|
||||
return id
|
||||
}
|
||||
const view = (caller = agent) => ctx.actionCards.view(caller.session)
|
||||
return { ctx, harness, cards, kindFiber, agent, agentHandle, events, act, propose, view }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
appendPluginRecord, Session, SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { actionCardProjection, type ActionCardState } from '../src/projection.ts'
|
||||
import type { ActionCardDecision } from '../src/types.ts'
|
||||
|
||||
const projection = actionCardProjection({ queuedMessageLimit: 2 })
|
||||
const args = { action: 'test_action', title: 'Counter', brief: 'Keep count visible', details: { value: 'a' } }
|
||||
let seq = 0
|
||||
|
||||
function init(inherited = 0): ActionCardState {
|
||||
const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: SessionId('cards'), createdAt: 0, isSeeded: inherited > 0 }
|
||||
return projection.init(header, SessionLogOffset(inherited))
|
||||
}
|
||||
|
||||
function turn(value: number): SessionEvent<'turn/start'> {
|
||||
return { type: 'turn/start', seq: SessionSeq(seq++), time: 0, data: { turn: value } }
|
||||
}
|
||||
|
||||
function call(callId: string, value: object | string = args, turnNumber = 1, name = 'propose_action'): SessionEvent<'tool/call'> {
|
||||
return { type: 'tool/call', seq: SessionSeq(seq++), time: 0,
|
||||
data: { turn: turnNumber, step: 1, callId: ToolCallId(callId), name, arguments: typeof value === 'string' ? value : JSON.stringify(value) } }
|
||||
}
|
||||
|
||||
function result(callId: string, isError = false, turnNumber = 1): SessionEvent<'tool/result'> {
|
||||
return { type: 'tool/result', seq: SessionSeq(seq++), time: 0, surfaceOp: 'append',
|
||||
data: { turn: turnNumber, step: 1, message: createToolResultMessage({ callId: ToolCallId(callId), content: [], isError }) } }
|
||||
}
|
||||
|
||||
function spliced(inserted: number, removedCount?: number, target: 'next-turn' | 'next-step' = 'next-turn'): SessionEvent<'agent/inbox/spliced'> {
|
||||
return { type: 'agent/inbox/spliced', seq: SessionSeq(seq++), time: 0, data: { target, start: 0, ...removedCount === undefined ? {} : { removedCount },
|
||||
inserted: Array.from({ length: inserted }, () => createUserMessage({ content: [{ type: 'text', text: 'next' }], source: { kind: 'user' } })) } }
|
||||
}
|
||||
|
||||
function records(...data: ActionCardDecision[]): readonly SessionEvent[] {
|
||||
const session = Session.create(SessionId('records'))
|
||||
for (const value of data) appendPluginRecord(session, 'plugin:action-cards/decision', value)
|
||||
return session.snapshotEvents()
|
||||
}
|
||||
|
||||
const fold = (events: readonly SessionEvent[], state = init()) => events.reduce(projection.apply, state)
|
||||
|
||||
it('admits a successful propose_action call as a card in its turn and ignores failed, malformed and other calls', () => {
|
||||
const state = fold([turn(1), call('c1'), result('c1')])
|
||||
expect(state.view.proposals).toEqual({ c1: { callId: 'c1', turn: 1, ...args } })
|
||||
expect(state.calls).toEqual({})
|
||||
for (const events of [
|
||||
[call('c2'), result('c2', true)],
|
||||
[call('c3', '{broken')],
|
||||
[call('c4', { ...args, title: ' ' })],
|
||||
[call('c5', { ...args, details: 'text' })],
|
||||
[call('c6', args, 1, 'other_tool'), result('c6')],
|
||||
[result('unrelated')],
|
||||
]) {
|
||||
expect(fold(events).view.proposals).toEqual({})
|
||||
}
|
||||
const trimmed = fold([call('c7', { ...args, title: ' Counter ' }), result('c7')])
|
||||
expect(trimmed.view.proposals.c7?.title).toBe('Counter')
|
||||
const pending = fold([call('c8')])
|
||||
expect(Object.keys(pending.calls)).toEqual(['c8'])
|
||||
expect(projection.apply(pending, turn(2)).calls).toEqual({})
|
||||
const idle = fold([turn(3)])
|
||||
expect(projection.apply(idle, turn(4))).toBe(idle)
|
||||
})
|
||||
|
||||
it('keeps the first admitted card of a turn and replaces only unstarted earlier cards', () => {
|
||||
const first = fold([turn(1), call('a'), result('a')])
|
||||
const late = fold([call('b'), result('b')], first)
|
||||
expect(Object.keys(late.view.proposals)).toEqual(['a'])
|
||||
const next = fold([turn(2), call('c', args, 2), result('c', false, 2)], first)
|
||||
expect(next.view.decisions).toEqual({ a: { callId: 'a', action: 'test_action', phase: 'replaced' } })
|
||||
const started = fold(records({ callId: ToolCallId('a'), action: 'test_action', phase: 'creating' }), first)
|
||||
const afterStart = fold([turn(2), call('c', args, 2), result('c', false, 2)], started)
|
||||
expect(afterStart.view.decisions.a?.phase).toBe('creating')
|
||||
expect(afterStart.view.decisions.c).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses a card proposed while user messages are queued for later turns', () => {
|
||||
const queued = fold([turn(1), spliced(1, undefined, 'next-step'), spliced(2), call('a'), result('a')])
|
||||
expect(queued.queued).toBe(2)
|
||||
expect(queued.view.decisions.a).toEqual({ callId: 'a', action: 'test_action', phase: 'suppressed' })
|
||||
const drained = fold([spliced(0, 2), turn(2), call('b', args, 2), result('b', false, 2)], queued)
|
||||
expect(drained.queued).toBe(0)
|
||||
expect(drained.view.decisions.b).toBeUndefined()
|
||||
expect(drained.view.decisions.a?.phase).toBe('suppressed')
|
||||
expect(fold([spliced(0, 5)]).queued).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores the events a forked Session inherits from its parent', () => {
|
||||
const parent = [turn(1), call('a'), result('a')]
|
||||
// Every event created so far precedes the fork point.
|
||||
const inherited = init(seq)
|
||||
expect(fold(parent, inherited)).toBe(inherited)
|
||||
const own = fold([turn(2), call('b', args, 2), result('b', false, 2)], inherited)
|
||||
expect(Object.keys(own.view.proposals)).toEqual(['b'])
|
||||
expect(own.view.decisions).toEqual({})
|
||||
})
|
||||
|
||||
it('records valid decisions as pending status until a status notice acknowledges them', () => {
|
||||
const [created, malformed, dismissed] = records(
|
||||
{ callId: ToolCallId('a'), action: 'test_action', phase: 'creating', data: { value: 1 } },
|
||||
{ callId: ToolCallId('a'), action: 'test_action', phase: '' },
|
||||
{ callId: ToolCallId('b'), action: 'test_action', phase: 'dismissed' },
|
||||
)
|
||||
const state = fold([created!, malformed!, dismissed!])
|
||||
expect(state.view.decisions).toEqual({
|
||||
a: { callId: 'a', action: 'test_action', phase: 'creating', data: { value: 1 } },
|
||||
b: { callId: 'b', action: 'test_action', phase: 'dismissed' },
|
||||
})
|
||||
expect(state.pendingStatus).toEqual({ a: created!.seq, b: dismissed!.seq })
|
||||
const notice = (through: number): SessionEvent<'user/message'> => ({ type: 'user/message', seq: SessionSeq(99), time: 0, surfaceOp: 'append',
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'status' }],
|
||||
source: { kind: 'action-cards', through: SessionSeq(through), form: 'notice', summary: 'status' } }) })
|
||||
expect(projection.apply(state, notice(created!.seq)).pendingStatus).toEqual({ b: dismissed!.seq })
|
||||
const user: SessionEvent<'user/message'> = { type: 'user/message', seq: SessionSeq(100), time: 0, surfaceOp: 'append',
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) }
|
||||
expect(projection.apply(state, user)).toBe(state)
|
||||
})
|
||||
|
||||
it('publishes proposals and decisions as one view reused across internal state changes', () => {
|
||||
const state = fold([turn(1), call('a'), result('a')])
|
||||
const view = projection.wire.view(state)
|
||||
expect(view).toBe(state.view)
|
||||
expect(projection.wire.viewSchema.parse(view)).toEqual(view)
|
||||
expect(projection.wire.view(fold([spliced(1), call('pending')], state))).toBe(view)
|
||||
expect(projection.stateSchema.parse(state)).toEqual(state)
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
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 ActionCards from '../src/index.ts'
|
||||
import { ACTION, callId, fixture } from './fixture.ts'
|
||||
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>
|
||||
|
||||
async function prepare(f: 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: [] } })
|
||||
return decision.kind === 'reject' ? decision : { ...decision, messages: decision.messages.filter(message => message.source.kind === 'action-cards') }
|
||||
}
|
||||
|
||||
function enter(agent: Agent, decision: PreStepDecision): void {
|
||||
if (decision.kind === 'enter') for (const message of decision.messages) agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
it('reports recorded decisions once on the next request, with each kind guidance once, without waking the conversation', async () => {
|
||||
const f = await fixture({ statusGuidance: 'Failed runs can be retried from their card.', statusFields: ['reason'] })
|
||||
const followup = vi.spyOn(f.agent, 'followup')
|
||||
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
|
||||
f.propose()
|
||||
f.propose(ToolCallId('call-card-2'), { action: ACTION, title: 'Second', brief: 'Another card.', details: { value: 'b' } }, f.agent, 2)
|
||||
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
|
||||
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'failed', data: { reason: 'timeout', internal: 'x' } })
|
||||
f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-card-2'), action: ACTION, phase: 'dismissed' })
|
||||
f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-orphan'), action: 'retired_action', phase: 'done' })
|
||||
const decision = await prepare(f)
|
||||
if (decision.kind !== 'enter') throw new Error('request was rejected')
|
||||
expect(decision.messages).toHaveLength(1)
|
||||
expect(decision.messages[0]!.source).toMatchObject({ kind: 'action-cards', form: 'notice', summary: 'Action card status updated' })
|
||||
expect(decision.messages[0]!.content).toMatchSnapshot('status notice')
|
||||
enter(f.agent, decision)
|
||||
expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] })
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps undelivered status through rejection, cancellation and plugin reload', async () => {
|
||||
const f = await fixture()
|
||||
f.propose()
|
||||
await f.ctx.actionCards.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()
|
||||
expect(await prepare(f, f.agent, undefined, async () => { await f.cards.dispose() })).toEqual({ kind: 'enter', messages: [] })
|
||||
await f.ctx.plugin(ActionCards, { queuedMessageLimit: 3 })
|
||||
const decision = await prepare(f)
|
||||
if (decision.kind !== 'enter') throw new Error('request was rejected')
|
||||
expect(JSON.stringify(decision.messages[0]!.content)).toContain('dismissed')
|
||||
})
|
||||
|
||||
it('adds nothing when cancellation arrives after downstream preparation', async () => {
|
||||
const f = await fixture()
|
||||
f.propose()
|
||||
await f.ctx.actionCards.act(f.agent, callId, 'dismiss')
|
||||
await f.cards.dispose()
|
||||
const controller = new AbortController()
|
||||
// Prepended before the card service returns, so it runs right after the service's own listener.
|
||||
f.ctx.on('agent/pre-step', async (_event, next) => {
|
||||
const decision = await next()
|
||||
controller.abort()
|
||||
return decision
|
||||
}, { prepend: true })
|
||||
await f.ctx.plugin(ActionCards)
|
||||
expect(await prepare(f, f.agent, controller.signal)).toEqual({ kind: 'enter', messages: [] })
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { executeModelToolFixture, executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { ACTION, fixture, proposal, SKILL } from './fixture.ts'
|
||||
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>
|
||||
const signal = new AbortController().signal
|
||||
const modelSelection = { provider: 'mock', model: 'mock' }
|
||||
|
||||
/** Run one call through the executor with the step admission the loop would resolve. */
|
||||
async function call(f: Fixture, name: string, args: object, id: string, agent: Agent = f.agent, parent?: ToolExecutionToken) {
|
||||
const toolSet = await f.ctx.tools.resolve({ agent, modelSelection, signal })
|
||||
try {
|
||||
return parent === undefined
|
||||
? await executeModelToolFixture(f.ctx, { callId: ToolCallId(id), name, arguments: args, agent, signal, toolSet })
|
||||
: await executeToolFixture(f.ctx, { callId: ToolCallId(id), name, arguments: args, agent, signal, toolSet, parent })
|
||||
} finally { toolSet.dispose() }
|
||||
}
|
||||
|
||||
async function declared(f: Fixture, agent: Agent = f.agent): Promise<boolean> {
|
||||
const toolSet = await f.ctx.tools.resolve({ agent, modelSelection, signal })
|
||||
const visible = toolSet.view(agent).has('propose_action')
|
||||
toolSet.dispose()
|
||||
return visible
|
||||
}
|
||||
|
||||
function text(result: { content: readonly { type: string; text?: string }[] }): string {
|
||||
return result.content.map(block => block.text ?? '').join('')
|
||||
}
|
||||
|
||||
it('declares propose_action from the step after a card skill loads, and logs the addition', async () => {
|
||||
const f = await fixture()
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-skill', 'skill', { name: SKILL }),
|
||||
toolCallResponse('call-card-1', 'propose_action', proposal),
|
||||
textResponse('The card is below.'),
|
||||
])
|
||||
f.ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = await f.harness.create(SessionId('loop'), modelSelection)
|
||||
expect(await declared(f, agent)).toBe(false)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'I want a counter.' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests.map(request => (request.tools ?? []).some(tool => tool.name === 'propose_action'))).toEqual([false, true, true])
|
||||
const tool = adapter.requests[1]!.tools!.find(candidate => candidate.name === 'propose_action')
|
||||
expect(tool).toMatchSnapshot('tool declaration')
|
||||
expect(agent.session.snapshotEvents().flatMap(event => event.type === 'developer/message' ? event.data.message.content : []))
|
||||
.toEqual([{ type: 'tool-addition', toolName: 'propose_action' }])
|
||||
expect(await declared(f, agent)).toBe(true)
|
||||
const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.toolCallId === 'call-card-1')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content).toEqual([
|
||||
{ type: 'text', text: 'Card shown below your answer. Do not restate it; end with one short invitation.' },
|
||||
])
|
||||
expect(f.view(agent).proposals['call-card-1']).toEqual({ callId: 'call-card-1', turn: 1, ...proposal })
|
||||
})
|
||||
|
||||
it('declares nothing for other skills and stops declaring when the kind unregisters', async () => {
|
||||
const f = await fixture()
|
||||
f.agent.session.append('turn/start', { turn: 1 })
|
||||
expect((await call(f, 'skill', { name: 'other-skill' }, 'other')).isError).toBe(false)
|
||||
expect((await call(f, 'skill', { name: 'missing-skill' }, 'missing')).isError).toBe(true)
|
||||
expect(await declared(f)).toBe(false)
|
||||
expect((await call(f, 'propose_action', proposal, 'early')).isError).toBe(true)
|
||||
expect((await call(f, 'skill', { name: SKILL }, 'load')).isError).toBe(false)
|
||||
expect((await call(f, 'skill', { name: SKILL }, 'reload')).isError).toBe(false)
|
||||
expect((await executeToolFixture(f.ctx, { callId: ToolCallId('detached'), name: 'skill', arguments: { name: SKILL }, signal })).isError).toBe(false)
|
||||
expect(await declared(f)).toBe(true)
|
||||
await f.kindFiber.dispose()
|
||||
expect(await declared(f)).toBe(false)
|
||||
await f.cards.dispose()
|
||||
expect(f.ctx.tools.get('propose_action')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses unknown actions, a skill not loaded in this turn, blank fields, invalid details and nested calls', async () => {
|
||||
const f = await fixture()
|
||||
f.agent.session.append('turn/start', { turn: 1 })
|
||||
await call(f, 'skill', { name: SKILL }, 'load')
|
||||
expect(text(await call(f, 'propose_action', { ...proposal, action: 'nope' }, 'unknown'))).toBe('Error: Unknown action "nope". Registered actions: test_action.')
|
||||
expect(text(await call(f, 'propose_action', { ...proposal, title: ' ' }, 'blank'))).toBe('Error: Write a title and a brief.')
|
||||
expect(text(await call(f, 'propose_action', { ...proposal, brief: '' }, 'blank-brief'))).toBe('Error: Write a title and a brief.')
|
||||
expect(text(await call(f, 'propose_action', { ...proposal, details: { value: 1 } }, 'invalid')))
|
||||
.toBe('Error: Invalid details: details.value: Invalid input: expected string, received number')
|
||||
expect(text(await call(f, 'propose_action', proposal, 'nested', f.agent, Symbol('outer') as ToolExecutionToken)))
|
||||
.toBe('Error: Call propose_action directly, not from code.')
|
||||
f.agent.session.append('turn/start', { turn: 2 })
|
||||
expect(text(await call(f, 'propose_action', proposal, 'stale'))).toBe('Error: Load skill test-skill first, then call propose_action.')
|
||||
await f.kindFiber.dispose()
|
||||
await call(f, 'skill', { name: SKILL }, 'load-again')
|
||||
expect(await declared(f)).toBe(false)
|
||||
expect(Object.keys(f.view().proposals)).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts one card per turn, admits a retry after a failed result and names the registered actions', async () => {
|
||||
const f = await fixture()
|
||||
f.agent.session.append('turn/start', { turn: 1 })
|
||||
await call(f, 'skill', { name: SKILL }, 'load')
|
||||
const blocked = f.ctx.on('tools/post-execute', async (exec, _result, next) => exec.callId === 'blocked'
|
||||
? { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } : await next())
|
||||
expect((await call(f, 'propose_action', proposal, 'blocked')).isError).toBe(true)
|
||||
blocked()
|
||||
const accepted = await call(f, 'propose_action', { ...proposal, title: ' Counter ' }, 'accepted')
|
||||
expect(accepted).toMatchObject({ isError: false, value: { action: ACTION, title: 'Counter' } })
|
||||
expect(text(await call(f, 'propose_action', proposal, 'second'))).toBe('Error: One card per turn; the earlier card stands.')
|
||||
f.agent.session.append('turn/start', { turn: 2 })
|
||||
await call(f, 'skill', { name: SKILL }, 'load-2')
|
||||
f.propose(ToolCallId('logged'), proposal, f.agent, 2)
|
||||
expect(text(await call(f, 'propose_action', proposal, 'after-log'))).toBe('Error: One card per turn; the earlier card stands.')
|
||||
// A disposed agent's activation and per-turn marks are released with it.
|
||||
await f.agentHandle.dispose()
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../session/session-projection" },
|
||||
{ "path": "../../api/remote-access/tsconfig.host.json" },
|
||||
{ "path": "../../sandbox/sandbox-policy" },
|
||||
{ "path": "../../util/values" }
|
||||
],
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/types.ts",
|
||||
"src/projection.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the action card plugin. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,40 @@
|
||||
# Bilingual-pair consistency record for README.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 packages/experimental/client-ui-action-cards/README.md
|
||||
/:
|
||||
en: eec43de9002f4ab1
|
||||
zh: 0d2fddca5a1f993a
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards:
|
||||
en: 6ed1f2bf10bc2f1b
|
||||
zh: e3edea71ec4373c7
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/summary:
|
||||
en: 69abe0c52072c874
|
||||
zh: b718aa6a13be4b13
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: b00dddf4c88f0a45
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package:
|
||||
en: 09d6dec2adb9699a
|
||||
zh: b6ae739fe314df54
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package/register-a-card-body:
|
||||
en: 2ab65c8c258e694a
|
||||
zh: a785becbe7015e0e
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/understand-the-implementation:
|
||||
en: bdfd09e39b6e0edf
|
||||
zh: 06f2da1c16a66547
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/further-exploration:
|
||||
en: c24d6ca9095832e7
|
||||
zh: b2ee0066a865dc5f
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/model-experience:
|
||||
en: f80a4b656c108f22
|
||||
zh: a3e30f46fa7136ac
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/model-experience/kv-cache-effect:
|
||||
en: 9cb7131bde146341
|
||||
zh: 1c7d1af00f58004d
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work:
|
||||
en: 96914db20cfc0671
|
||||
zh: cd231bcdcb757ae0
|
||||
/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: bd8a8936229e035e
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
description: "Browser half of optional action cards: the card frame below completed answers, the body registry that experiment packages fill, and the one-line process row of propose_action calls."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-client-ui-action-cards
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Users see an optional action card below a completed answer: its title, a brief that folds when long, and the controls of the experiment that proposed it. In the process area, the proposal shows as one row with the card title and whether the card was shown; a refused call expands to its reason. A card whose experiment provides no controls in this browser offers only Not now. Showing, expanding and folding cards make no model request.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<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.
|
||||
|
||||
### Register a card body
|
||||
|
||||
A kind's browser plugin injects `actionCards` and registers its body inside its own `ctx.effect`: `ctx.actionCards.register({ action, locale, Body })` returns the disposer. The frame renders `Body` for cards of that action with the card, its recorded or derived decision, whether the card state has arrived, the Session id, the frame's title and folded brief as `summary`, the frame's fold component as `Folded`, an `act(verb, payload?)` bound to the card, and a translator `t` for the body's own locale namespace. The body places `summary` in its own layout and owns its header, controls, status and footer. A second body for the same action is a registration error.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
One `conversation.chat.turnTail` entry reads the `actionCards` Session projection and shows the card admitted in its Turn once that Turn has ended, for any end reason; a suppressed card is not shown, and should several cards share a Turn, the lowest call id is shown. The frame sets `data-action-card` to the call id and names its region by the card title. Folding counts a wide grapheme, such as a Chinese character, as two Latin-letter widths and folds after about 260; it never measures layout or alters the full text.
|
||||
|
||||
A `tool.call.toolview` entry keyed `propose_action` renders the process row from the recorded call arguments and result. The plugin provides the body registry at its top level, then mounts the generated `actionCards` Remote and registers its dictionaries and slot entries for the same lifetime.
|
||||
|
||||
| Source | Contents |
|
||||
|---|---|
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | Registry, Remote, dictionaries and slot entries |
|
||||
| [`src/client/Card.tsx`](src/client/Card.tsx) | Turn tail, frame, fold and fallback body |
|
||||
| [`src/client/ProposeRow.tsx`](src/client/ProposeRow.tsx) | Process row |
|
||||
| [`src/client/registry.ts`](src/client/registry.ts) | Body registry and body props |
|
||||
| [`tests/`](tests/) | Placement, frame, fallback, row and registration tests |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
- [Slots](../../client/ui-slots/README.md) — Turn tail and tool row registration.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the Host card service: card actions reach it as Remote calls, and it owns the logged tool, decisions and status notice.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Rendering cards and process rows does not change any model request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- A card whose action has no registered body offers only Not now, so its experiment's other actions need that experiment's browser plugin.
|
||||
- The Turn tail shows one card per Turn, matching the Host rule of one card per turn.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
description: "可选操作卡片的浏览器端:已完成回答下方的卡片框架、供各实验包填入的正文注册表,以及 propose_action 调用的单行过程行。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-client-ui-action-cards
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
用户在已完成的回答下方看到一张可选操作卡片:标题、过长时会折叠的说明,以及提议该卡片的实验所提供的控件。在过程区中,提议显示为一行,带卡片标题和卡片是否已展示;被拒绝的调用可以展开查看原因。如果实验在当前浏览器中没有提供控件,卡片只提供“暂不需要”。展示、展开和折叠卡片都不会发起模型请求。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [实现原理](#understand-the-implementation)
|
||||
- [延伸阅读](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
启用[个性化页面 Bundle](../creation-recommendations/README.zh.md),它会在本包旁插入对应的 [Host 服务](../action-cards/README.zh.md)。本浏览器组件没有配置字段。
|
||||
|
||||
### 注册卡片正文
|
||||
|
||||
种类的浏览器插件注入 `actionCards`,并在自己的 `ctx.effect` 中注册正文:`ctx.actionCards.register({ action, locale, Body })` 返回注销函数。框架为该动作的卡片渲染 `Body`,并传入:卡片、已记录或推导出的决定、卡片状态是否已到达、会话 id、框架渲染的标题与折叠说明 `summary`、框架的折叠组件 `Folded`、绑定到该卡片的 `act(verb, payload?)`,以及正文自己语言命名空间的翻译函数 `t`。正文把 `summary` 放进自己的布局,并负责自己的标题栏、控件、状态和底部操作。为同一动作注册第二个正文属于注册错误。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 实现原理
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
一个 `conversation.chat.turnTail` 条目读取 `actionCards` 会话投影,在某一轮结束后(无论因何结束)展示该轮接受的卡片;未展示状态的卡片不显示,若同一轮有多张卡片,显示调用 id 最小的一张。框架把 `data-action-card` 设为调用 id,并以卡片标题命名所在区域。折叠时,宽字素(如汉字)按两个拉丁字母宽度计算,约 260 个单位后折叠;折叠不测量布局,也不改动完整文本。
|
||||
|
||||
一个以 `propose_action` 为键的 `tool.call.toolview` 条目,根据记录的调用参数和结果渲染过程行。插件在顶层提供正文注册表,然后挂载生成的 `actionCards` Remote,并在同一生命周期内注册词典和插槽条目。
|
||||
|
||||
| 源码 | 内容 |
|
||||
|---|---|
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | 注册表、Remote、词典与插槽条目 |
|
||||
| [`src/client/Card.tsx`](src/client/Card.tsx) | 轮次尾部、框架、折叠与兜底正文 |
|
||||
| [`src/client/ProposeRow.tsx`](src/client/ProposeRow.tsx) | 过程行 |
|
||||
| [`src/client/registry.ts`](src/client/registry.ts) | 正文注册表与正文属性 |
|
||||
| [`tests/`](tests/) | 位置、框架、兜底、过程行与注册测试 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
- [操作卡片](../action-cards/README.zh.md) — 框架背后的工具、卡片状态与用户操作。
|
||||
- [个性化页面卡片](../client-ui-creation-recommendations/README.zh.md) — 随产品提供的卡片正文。
|
||||
- [插槽](../../client/ui-slots/README.zh.md) — 轮次尾部与工具行的注册。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
间接影响来自 Host 卡片服务:卡片操作以 Remote 调用到达该服务,由它负责记录在日志中的工具、决定和状态通知。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
渲染卡片和过程行不会改变任何模型请求。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。
|
||||
- 轮次尾部每轮只显示一张卡片,与 Host 每轮一张卡片的规则一致。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作背景——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Action card display",
|
||||
"description": "Show optional action cards below answers, with each experiment's own controls."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "アクションカード表示",
|
||||
"description": "回答の下に任意のアクションカードを表示します。カードの内容は各実験が提供します。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "操作卡片显示",
|
||||
"description": "在回答下方显示可选操作卡片,卡片内容由各实验提供。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"license": "MIT",
|
||||
"name": "@deepseek-ai/dsh-experimental-client-ui-action-cards",
|
||||
"description": "Action cards below completed answers: the shared card frame, kind bodies and the compact tool row",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/client-ui-action-cards"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"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-tool"
|
||||
]
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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:*",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-experimental-action-cards": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.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); }
|
||||
.body { padding: 12px 20px 20px; }
|
||||
.title { display: block; font-size: 16px; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.text { margin: 8px 0 0; font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dsw-alias-label-secondary); }
|
||||
.toggle { margin: 8px 0 0; padding-inline: 0; height: auto; min-height: 24px; font-size: 12px; }
|
||||
.status { margin: 12px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
|
||||
.footer { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 12px; }
|
||||
@@ -0,0 +1,153 @@
|
||||
/** The shared card frame below a completed answer: title, folded brief, and the kind's body or a dismiss-only fallback. */
|
||||
import { useCallback, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, ActionCardView,
|
||||
} from '@deepseek-ai/dsh-experimental-action-cards/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ActionCardKey } from './locales.ts'
|
||||
import type { FoldedTextProps, RegisteredBody } from './registry.ts'
|
||||
import css from './Card.module.css'
|
||||
|
||||
/** Session-bound actions and registered bodies, without a service in the component. */
|
||||
export interface TailInjected {
|
||||
/**
|
||||
* Send one user action for a card of the displayed Session.
|
||||
* @param callId - the card's call id.
|
||||
* @param verb - `dismiss` or one of the kind's verbs.
|
||||
* @param payload - kind-defined fields.
|
||||
* @returns the Host result, or undefined when the reply was lost.
|
||||
*/
|
||||
act: (callId: ToolCallId, verb: string, payload?: ActionCardFields) => Promise<ActionCardResult | undefined>
|
||||
/**
|
||||
* Bind a body's own locale namespace.
|
||||
* @param locale - namespace the body declared.
|
||||
* @returns its translator.
|
||||
*/
|
||||
bind: (locale: string) => Translate
|
||||
hooks: { bodies: HostObservable<ReadonlyMap<string, RegisteredBody>> }
|
||||
sessionId: SessionId
|
||||
}
|
||||
|
||||
type TailProps = Pick<PropsRuntime<'conversation.chat.turnTail'>, 'turn' | 'useProjection'>
|
||||
& PropsLocale<'actionCards'> & InjectFace<TailInjected>
|
||||
|
||||
/** Graphemes drawn about twice as wide as a Latin letter: CJK scripts, CJK punctuation and fullwidth forms. */
|
||||
const WIDE_GRAPHEME = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿-⦆¢-₩]/u
|
||||
/** Preview budget in Latin-letter widths, about two card lines at desktop width. */
|
||||
const FOLD_WIDTH = 260
|
||||
|
||||
/**
|
||||
* Fold omitted text without measuring layout; a wide grapheme counts twice so Chinese and English fold near the
|
||||
* same line. The full text is never altered.
|
||||
* @param props - text and its localized toggle labels.
|
||||
* @returns the paragraph and, when text was omitted, its toggle.
|
||||
*/
|
||||
export function FoldedText({ text, expandLabel, collapseLabel }: FoldedTextProps) {
|
||||
const id = useId()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const preview = useMemo(() => {
|
||||
const compact = text.replace(/\s+/g, ' ').trim()
|
||||
const characters = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(compact), part => part.segment)
|
||||
let width = 0
|
||||
for (const [index, character] of characters.entries()) {
|
||||
width += WIDE_GRAPHEME.test(character) ? 2 : 1
|
||||
if (width > FOLD_WIDTH) return characters.slice(0, index).join('').trimEnd() + '…'
|
||||
}
|
||||
return undefined
|
||||
}, [text])
|
||||
return <>
|
||||
<p id={id} className={css.text}>{expanded || preview === undefined ? text : preview}</p>
|
||||
{preview !== undefined && <Button variant="ghost" size="sm" className={css.toggle} aria-expanded={expanded} aria-controls={id}
|
||||
onClick={() => { setExpanded(value => !value) }}>{expanded ? collapseLabel : expandLabel}</Button>}
|
||||
</>
|
||||
}
|
||||
|
||||
/** The projection admits at most one card per turn; should several exist, the lowest call id is shown. */
|
||||
function proposalOf(view: ActionCardView | undefined, turn: number): ActionCardProposal | undefined {
|
||||
let found: ActionCardProposal | undefined
|
||||
for (const proposal of Object.values(view?.proposals ?? {})) {
|
||||
if (proposal.turn === turn && (found === undefined || proposal.callId < found.callId)) found = proposal
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function decisionOf(view: ActionCardView | undefined, callId: string): ActionCardDecision | undefined {
|
||||
return view !== undefined && Object.hasOwn(view.decisions, callId) ? view.decisions[callId] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the card admitted in a turn after that turn's final answer, once the turn has ended for any reason.
|
||||
* @param props - Turn location, projection reader, registered bodies and Session-bound actions.
|
||||
* @returns the card, or null.
|
||||
*/
|
||||
export function ActionCardTail({ turn, useProjection, ...props }: TailProps) {
|
||||
const view = useProjection('actionCards')
|
||||
const proposal = proposalOf(view, turn.turn)
|
||||
const decision = proposal === undefined ? undefined : decisionOf(view, proposal.callId)
|
||||
return proposal === undefined || decision?.phase === 'suppressed' || turn.end === undefined || turn.data.get('turn-tail')?.closing == null
|
||||
? null : <ActionCard {...props} proposal={proposal} decision={decision} ready={view !== undefined} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the frame around one card.
|
||||
* @param props - the card, its state, registered bodies and actions.
|
||||
* @returns the card section.
|
||||
*/
|
||||
export function ActionCard({ proposal, decision, ready, sessionId, act, bind, useBodies, t }: {
|
||||
proposal: ActionCardProposal
|
||||
decision: ActionCardDecision | undefined
|
||||
ready: boolean
|
||||
} & InjectFace<TailInjected> & PropsLocale<'actionCards'>) {
|
||||
const body = useBodies(bodies => bodies.get(proposal.action))
|
||||
const send = useCallback((verb: string, payload?: ActionCardFields) => act(proposal.callId, verb, payload), [act, proposal.callId])
|
||||
const summary = <>
|
||||
<strong className={css.title}>{proposal.title}</strong>
|
||||
<FoldedText text={proposal.brief} expandLabel={t('expandBrief')} collapseLabel={t('collapseBrief')} />
|
||||
</>
|
||||
// The frame re-renders on a locale change and hands the body a new summary, so the body re-renders with it.
|
||||
const content = body === undefined
|
||||
? <FallbackBody summary={summary} decision={decision} ready={ready} act={send} t={t} />
|
||||
: body.render({ proposal, decision, ready, sessionId, summary, Folded: FoldedText, act: send }, bind(body.locale))
|
||||
return <section className={css.card} aria-label={proposal.title} data-action-card={proposal.callId} data-action={proposal.action}>
|
||||
{content}
|
||||
</section>
|
||||
}
|
||||
|
||||
/** Phases the fallback can name; other kind phases leave only the summary. */
|
||||
const NAMED_PHASES: Readonly<Record<string, ActionCardKey>> = { dismissed: 'dismissed', replaced: 'replaced' }
|
||||
|
||||
/** Title, brief and Not now for a card whose kind registered no body in this browser. */
|
||||
function FallbackBody({ summary, decision, ready, act, t }: {
|
||||
summary: ReactNode
|
||||
decision: ActionCardDecision | undefined
|
||||
ready: boolean
|
||||
act: (verb: string) => Promise<ActionCardResult | undefined>
|
||||
} & PropsLocale<'actionCards'>) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<ActionCardKey>()
|
||||
const phase = decision === undefined || !Object.hasOwn(NAMED_PHASES, decision.phase) ? undefined : NAMED_PHASES[decision.phase]
|
||||
const dismiss = async () => {
|
||||
setPending(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await act('dismiss')
|
||||
if (result === undefined) setError('transport')
|
||||
else if (result.status === 'refused' || result.status === 'unconfirmed') setError('unavailable')
|
||||
} catch {
|
||||
// A lost reply does not prove failure; replay supplies the durable decision.
|
||||
setError('transport')
|
||||
} finally { setPending(false) }
|
||||
}
|
||||
return <div className={css.body}>
|
||||
{summary}
|
||||
{phase !== undefined && <p role="status" className={css.status}>{t(phase)}</p>}
|
||||
{error !== undefined && <p role="alert" className={css.status}>{t(error)}</p>}
|
||||
{decision === undefined && <footer className={css.footer}>
|
||||
<Button variant="ghost" size="sm" disabled={pending || !ready} onClick={() => { void dismiss() }}>{t('dismiss')}</Button>
|
||||
</footer>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** The card title stays readable beside the call state. */
|
||||
.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: inherit; font-size: 12px; }
|
||||
.summary > :first-child { flex-shrink: 0; }
|
||||
.title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.reason { margin: 8px 0; padding: 12px; border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
@@ -0,0 +1,43 @@
|
||||
/** One-line process row for a `propose_action` call: the card title and whether the card was shown. */
|
||||
import { useState } from 'react'
|
||||
import { DisclosureRow, IconAppsAddOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './ProposeRow.module.css'
|
||||
|
||||
type ProposeRowProps = ToolCallViewProps & PropsLocale<'actionCards'>
|
||||
|
||||
/** Call states with row copy; a preparing call has no complete arguments yet. */
|
||||
type RowState = 'preparing' | 'running' | 'ok' | 'error' | 'stopped'
|
||||
|
||||
function stateOf(props: ProposeRowProps): RowState {
|
||||
if (props.phase === 'preparing') return 'preparing'
|
||||
if (props.phase === 'start') return 'running'
|
||||
if (props.block.error?.code === 'interrupted') return 'stopped'
|
||||
return props.block.isError ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `propose_action` call from its recorded arguments and result. Only a refused call expands, to show
|
||||
* why no card followed the answer.
|
||||
* @param props - tool call and localized status copy.
|
||||
* @returns the status row.
|
||||
*/
|
||||
export function ProposeRow(props: ProposeRowProps) {
|
||||
const { block, inspect, t } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const state = stateOf(props)
|
||||
const title = block.args.text('title') ?? ''
|
||||
const reason = props.phase === 'result' && props.block.isError
|
||||
? props.block.content.flatMap(item => item.type === 'text' ? [item.text] : []).join('\n')
|
||||
: ''
|
||||
return <div data-tool="propose_action" data-state={state}>
|
||||
<DisclosureRow title={t('row.title')} icon={<IconAppsAddOutlineRegular size={14} />} running={state === 'preparing' || state === 'running'}
|
||||
open={expanded && reason !== ''} expandable={reason !== ''} expandOnRowClick keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={<span className={css.summary}><span>{t(`row.${state}`)}</span><span className={css.title}>{title}</span></span>}>
|
||||
<pre className={css.reason}>{reason}</pre>
|
||||
{inspect && <button type="button" className={css.inspect} onClick={inspect}>{t('row.inspect')}</button>}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Bind generated Remote descriptors to the source-tested browser lifecycle. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import remote from '@deepseek-ai/dsh-experimental-action-cards/remote'
|
||||
import { mountActionCards } from './mount.ts'
|
||||
export { inject } from './mount.ts'
|
||||
export type { ActionCardBodyProps, ActionCardDescriptor, ActionCardRegistry, FoldedTextProps } from './registry.ts'
|
||||
|
||||
/**
|
||||
* Mount action cards and their Remote.
|
||||
* @param ctx - Client plugin context.
|
||||
* @returns disposer for every contribution.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await mountActionCards(ctx, remote)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** English card frame, fallback and tool-row copy. */
|
||||
export const en = {
|
||||
expandBrief: 'Show more', collapseBrief: 'Show less',
|
||||
dismiss: 'Not now', dismissed: 'Dismissed', replaced: 'Replaced',
|
||||
unavailable: 'This action is unavailable. Check the plugin in Plugins.',
|
||||
transport: 'Could not confirm the result. Reconnect and check the card before retrying.',
|
||||
'row.title': 'Action card', 'row.preparing': 'Preparing a card', 'row.running': 'Proposing',
|
||||
'row.ok': 'Proposed', 'row.error': 'Not shown', 'row.stopped': 'Interrupted', 'row.inspect': 'Inspect call',
|
||||
}
|
||||
|
||||
/** Keys shared by every dictionary. */
|
||||
export type ActionCardKey = keyof typeof en
|
||||
|
||||
/** Chinese card frame, fallback and tool-row copy. */
|
||||
export const zh: Record<ActionCardKey, string> = {
|
||||
expandBrief: '展开', collapseBrief: '收起',
|
||||
dismiss: '暂不需要', dismissed: '已忽略', replaced: '已替换',
|
||||
unavailable: '暂时无法执行,请到插件页面查看',
|
||||
transport: '暂时无法确认结果,请重新连接并查看卡片状态后再重试',
|
||||
'row.title': '操作卡片', 'row.preparing': '正在准备卡片', 'row.running': '正在提议',
|
||||
'row.ok': '已提议', 'row.error': '未显示', 'row.stopped': '已中断', 'row.inspect': '查看调用',
|
||||
}
|
||||
|
||||
/** Japanese card frame, fallback and tool-row copy. */
|
||||
export const ja: Record<ActionCardKey, string> = {
|
||||
expandBrief: 'すべて表示', collapseBrief: '折りたたむ',
|
||||
dismiss: '今はしない', dismissed: '却下済み', replaced: '置き換え済み',
|
||||
unavailable: 'この操作は利用できません。プラグイン画面でプラグインを確認してください。',
|
||||
transport: '結果を確認できませんでした。再接続してカードを確認してから再試行してください。',
|
||||
'row.title': 'アクションカード', 'row.preparing': 'カードを準備中', 'row.running': '提案中',
|
||||
'row.ok': '提案済み', 'row.error': '表示されませんでした', 'row.stopped': '中断されました', 'row.inspect': '呼び出しを確認',
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Register the card frame, the tool row, their copy and the body registry. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
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-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-action-cards/remote'
|
||||
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { ActionCardTail, type TailInjected } from './Card.tsx'
|
||||
import { ProposeRow } from './ProposeRow.tsx'
|
||||
import { ActionCardRegistry } from './registry.ts'
|
||||
import { en, ja, zh, type ActionCardKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap { actionCards: ActionCardKey }
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Card bodies by action kind; kinds register theirs inside their own `ctx.effect`. */
|
||||
actionCards: ActionCardRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/** Registries available before this plugin mounts its Remote. */
|
||||
export const inject = ['slots', 'locale', 'remote']
|
||||
|
||||
/**
|
||||
* Provide the body registry, then mount the Remote and the UI 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.
|
||||
*/
|
||||
export async function mountActionCards(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
|
||||
// Built at the plugin's top level: kinds register into it from their own apply.
|
||||
const registry = new ActionCardRegistry(ctx)
|
||||
ctx.reflect.provide('actionCards', registry)
|
||||
const unmount = await ctx.remote.$mount(contribution)
|
||||
const ui = ctx.inject(['remote.actionCards'], (inner) => { registerUi(inner, registry) })
|
||||
try { await ui } catch (error) { await ui.dispose(); await unmount(); throw error }
|
||||
return async () => { await ui.dispose(); await unmount() }
|
||||
}
|
||||
|
||||
function registerUi(ctx: Context, registry: ActionCardRegistry): void {
|
||||
ctx.effect(() => ctx.locale.register('actionCards', { en, zh, ja }), 'action-cards: locale')
|
||||
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-experimental-client-ui-action-cards', locale: 'actionCards',
|
||||
inject: (sessionId: SessionId): TailInjected => ({
|
||||
sessionId,
|
||||
hooks: { bodies: registry.source },
|
||||
bind: locale => ctx.locale.bind(locale),
|
||||
act: async (callId, verb, payload) => {
|
||||
const result = await ctx.remote.actionCards.act(sessionId, callId, verb, payload)
|
||||
return result.ok ? result.value : undefined
|
||||
},
|
||||
}),
|
||||
}, ActionCardTail))
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
|
||||
{ name: 'tool.call.toolview', key: 'propose_action', locale: 'actionCards' }, ProposeRow,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Card bodies registered by action kind for the shared card frame. */
|
||||
import { createElement, type ComponentType, type ReactNode } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { HostObservable, LocaleNamespaceMap, Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult,
|
||||
} from '@deepseek-ai/dsh-experimental-action-cards/types'
|
||||
|
||||
/** Props of the frame's folded text, which bodies reuse for their own long fields. */
|
||||
export interface FoldedTextProps {
|
||||
readonly text: string
|
||||
readonly expandLabel: string
|
||||
readonly collapseLabel: string
|
||||
}
|
||||
|
||||
/** Everything a kind's body receives from the shared frame. */
|
||||
export interface ActionCardBodyProps<N extends Extract<keyof LocaleNamespaceMap, string>> {
|
||||
/** The card as admitted; the body validates `details` with its own schema. */
|
||||
readonly proposal: ActionCardProposal
|
||||
/** Recorded or derived card state; undefined while the card is unstarted. */
|
||||
readonly decision: ActionCardDecision | undefined
|
||||
/** Whether the card state has arrived from the Host; writable actions wait for it. */
|
||||
readonly ready: boolean
|
||||
readonly sessionId: SessionId
|
||||
/** Title and folded brief rendered by the frame; the body places them in its layout. */
|
||||
readonly summary: ReactNode
|
||||
/** The frame's width-weighted text fold. */
|
||||
readonly Folded: ComponentType<FoldedTextProps>
|
||||
/**
|
||||
* Send one user action for this card.
|
||||
* @param verb - `dismiss` or one of the kind's Host verbs.
|
||||
* @param payload - fields the kind's Host handler validates.
|
||||
* @returns the Host result, or undefined when the reply was lost.
|
||||
*/
|
||||
readonly act: (verb: string, payload?: ActionCardFields) => Promise<ActionCardResult | undefined>
|
||||
/** Translate the body's own locale namespace. */
|
||||
readonly t: TranslateNS<N>
|
||||
}
|
||||
|
||||
/** Browser half of one action kind. */
|
||||
export interface ActionCardDescriptor<N extends Extract<keyof LocaleNamespaceMap, string>> {
|
||||
/** Value of the card's `action`. */
|
||||
readonly action: string
|
||||
/** Locale namespace the body's copy belongs to. */
|
||||
readonly locale: N
|
||||
/** Kind-owned header, controls, status and footer. */
|
||||
readonly Body: ComponentType<ActionCardBodyProps<N>>
|
||||
}
|
||||
|
||||
/** Frame-supplied body props before the kind's translator is bound. */
|
||||
export type ActionCardFrameProps = Omit<ActionCardBodyProps<never>, 't'>
|
||||
|
||||
/** A registered body with its namespace type erased. */
|
||||
export interface RegisteredBody {
|
||||
readonly locale: string
|
||||
/**
|
||||
* Render the body.
|
||||
* @param props - frame-supplied props.
|
||||
* @param t - translator bound to {@link locale}.
|
||||
* @returns the body element.
|
||||
*/
|
||||
readonly render: (props: ActionCardFrameProps, t: Translate) => ReactNode
|
||||
}
|
||||
|
||||
/** Card bodies by action; the frame renders a dismiss-only fallback for an action without one. */
|
||||
export class ActionCardRegistry {
|
||||
private bodies: ReadonlyMap<string, RegisteredBody> = new Map()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
/** Current bodies by action; the snapshot changes on every registration change. */
|
||||
readonly source: HostObservable<ReadonlyMap<string, RegisteredBody>> = {
|
||||
getSnapshot: () => this.bodies,
|
||||
subscribe: (listener) => {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
|
||||
/** @param ctx - Context whose effects own registered bodies. */
|
||||
constructor(private readonly ctx: Context) {}
|
||||
|
||||
/**
|
||||
* Register one kind's body. The caller holds the returned disposer inside its own `ctx.effect`, so the body
|
||||
* lives exactly as long as the plugin that contributed it.
|
||||
* @param descriptor - the kind's action, locale namespace and body.
|
||||
* @returns idempotent disposer.
|
||||
* @throws when another body already serves the action.
|
||||
*/
|
||||
register<N extends Extract<keyof LocaleNamespaceMap, string>>(descriptor: ActionCardDescriptor<N>): () => void {
|
||||
const { action, locale, Body } = descriptor
|
||||
const body: RegisteredBody = { locale, render: (props, t) => createElement(Body, { ...props, t }) }
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.bodies.has(action)) throw new Error(`action-cards: a body for "${action}" is already registered`)
|
||||
this.publish(new Map([...this.bodies, [action, body]]))
|
||||
return () => {
|
||||
const next = new Map(this.bodies)
|
||||
next.delete(action)
|
||||
this.publish(next)
|
||||
}
|
||||
}, `action-cards: body ${action}`)
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
private publish(bodies: ReadonlyMap<string, RegisteredBody>): void {
|
||||
this.bodies = bodies
|
||||
for (const listener of [...this.listeners]) listener()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Readonly<Record<string, string>>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Browser registration is discovered through the package client declaration. */
|
||||
|
||||
/** Register the browser-only contribution without Host state. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Real registries bind actions to the displayed session and remove every contribution on unload. */
|
||||
import { isValidElement } from 'react'
|
||||
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 { mountActionCards, inject } from '../src/client/mount.ts'
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
import type { TailInjected } from '../src/client/Card.tsx'
|
||||
|
||||
const apply = (ctx: Context) => mountActionCards(ctx, { package: '@deepseek-ai/dsh-experimental-action-cards', descriptors: [] })
|
||||
const Body = () => null
|
||||
|
||||
it('binds actions to the displayed session, serves registered bodies and removes registrations on unload', async () => {
|
||||
hostApply()
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const slots = ctx.slots
|
||||
slots.register({ name: 'root', children: {
|
||||
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
|
||||
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
||||
} }, (_props: { renderSlot?: unknown }) => null)
|
||||
const act = vi.fn().mockResolvedValue({ ok: true, value: { status: 'accepted' } })
|
||||
const unmount = vi.fn(async () => {})
|
||||
ctx.provide('remote', { actionCards: { act }, $mount: vi.fn(async () => unmount) })
|
||||
ctx.provide('remote.actionCards', { act })
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const fiber = await ctx.plugin({ inject, apply })
|
||||
const entry = slots.entries('conversation.chat.turnTail')[0]!
|
||||
expect(slots.entries('tool.call.toolview').map(item => item.options.key)).toEqual(['propose_action'])
|
||||
const resolve = entry.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => TailInjected)
|
||||
const bound = resolve(SessionId('session-one'))
|
||||
expect(bound.sessionId).toBe('session-one')
|
||||
expect(await bound.act(ToolCallId('card'), 'create', { slot: 'page' })).toEqual({ status: 'accepted' })
|
||||
expect(act).toHaveBeenCalledWith('session-one', 'card', 'create', { slot: 'page' })
|
||||
act.mockResolvedValueOnce({ ok: false })
|
||||
expect(await bound.act(ToolCallId('card'), 'dismiss')).toBeUndefined()
|
||||
expect(bound.bind('actionCards')('dismiss')).toBe('Not now')
|
||||
const changes = vi.fn()
|
||||
const unsubscribe = bound.hooks.bodies.subscribe(changes)
|
||||
const kind = await ctx.plugin({ inject: ['actionCards'], apply: (inner: Context) => {
|
||||
inner.effect(() => inner.actionCards.register({ action: 'test_action', locale: 'actionCards', Body }))
|
||||
} })
|
||||
expect([...bound.hooks.bodies.getSnapshot().keys()]).toEqual(['test_action'])
|
||||
const translate = (key: string) => key
|
||||
const element = bound.hooks.bodies.getSnapshot().get('test_action')!.render({ proposal: { callId: ToolCallId('card'), turn: 1,
|
||||
action: 'test_action', title: 'Counter', brief: 'Keep count.', details: {} }, decision: undefined, ready: true,
|
||||
sessionId: SessionId('session-one'), summary: null, Folded: Body, act: vi.fn() }, translate)
|
||||
expect(isValidElement(element) && element.type === Body && (element.props as { t: unknown }).t === translate).toBe(true)
|
||||
expect(() => ctx.actionCards.register({ action: 'test_action', locale: 'actionCards', Body })).toThrow('a body for "test_action" is already registered')
|
||||
await kind.dispose()
|
||||
expect(bound.hooks.bodies.getSnapshot().size).toBe(0)
|
||||
expect(changes).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
const release = ctx.actionCards.register({ action: 'other_action', locale: 'actionCards', Body })
|
||||
expect(bound.hooks.bodies.getSnapshot().has('other_action')).toBe(true)
|
||||
release()
|
||||
expect(bound.hooks.bodies.getSnapshot().has('other_action')).toBe(false)
|
||||
expect(changes).toHaveBeenCalledTimes(2)
|
||||
await fiber.dispose()
|
||||
expect(unmount).toHaveBeenCalledOnce()
|
||||
expect(slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
expect(slots.entries('tool.call.toolview')).toHaveLength(0)
|
||||
expect(ctx.get('actionCards')).toBeUndefined()
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { memo, type ComponentProps } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ActionCardDecision, ActionCardResult, ActionCardView } from '@deepseek-ai/dsh-experimental-action-cards/types'
|
||||
import { ActionCard, ActionCardTail, FoldedText } from '../src/client/Card.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import type { ActionCardBodyProps, RegisteredBody } from '../src/client/registry.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
type CardProps = ComponentProps<typeof ActionCard>
|
||||
const callId = ToolCallId('card-1')
|
||||
const proposal = { callId, turn: 3, action: 'test_action', title: 'Counter', brief: 'Keep my task count visible.', details: { value: 'a' } }
|
||||
|
||||
function props(bodies: ReadonlyMap<string, RegisteredBody> = new Map(), decision?: ActionCardDecision, chinese = false): CardProps {
|
||||
return { proposal, decision, ready: true, sessionId: SessionId('session-1'),
|
||||
act: vi.fn(async (): Promise<ActionCardResult | undefined> => ({ status: 'settled', decision: { callId, action: 'test_action', phase: 'dismissed' } })),
|
||||
bind: vi.fn(() => (key: string) => `body:${key}`),
|
||||
useBodies: selector => selector(bodies),
|
||||
t: makeTranslate(chinese ? zh : en) }
|
||||
}
|
||||
|
||||
it('places the card admitted in its turn once the turn has ended, and leaves other turns empty', () => {
|
||||
type Tail = ComponentProps<typeof ActionCardTail>
|
||||
const value = props()
|
||||
let view: ActionCardView | undefined
|
||||
const turnData = vi.fn().mockReturnValue({ closing: {} })
|
||||
const ended = { type: 'turn/end', seq: SessionSeq(1), time: 0, data: { turn: 3, reason: { kind: 'max-tokens' } } } as const
|
||||
const tail = (end: typeof ended | undefined): Tail => ({ ...value, useProjection: () => view,
|
||||
turn: { turn: 3, start: undefined, end, status: end === undefined ? 'open' : 'closed', steps: [],
|
||||
data: { get: turnData, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) } } })
|
||||
const card = (id: string, turn: number) => ({ ...proposal, callId: ToolCallId(id), turn })
|
||||
const { rerender } = render(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { decisions: {}, proposals: { earlier: card('earlier', 2) } }
|
||||
rerender(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { decisions: {}, proposals: { ...view.proposals, b: card('b', 3), a: card('a', 3), c: card('c', 3) } }
|
||||
rerender(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.getAllByRole('region').map(region => region.getAttribute('data-action-card'))).toEqual(['a'])
|
||||
expect(screen.getByRole('region', { name: 'Counter' }).getAttribute('data-action')).toBe('test_action')
|
||||
expect(turnData).toHaveBeenCalledWith('turn-tail')
|
||||
rerender(<ActionCardTail {...tail(undefined)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
turnData.mockReturnValueOnce({ closing: null })
|
||||
rerender(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { ...view, decisions: { a: { callId: ToolCallId('a'), action: 'test_action', phase: 'suppressed' } } }
|
||||
rerender(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { ...view, decisions: { toString: { callId: ToolCallId('toString'), action: 'test_action', phase: 'suppressed' } } }
|
||||
rerender(<ActionCardTail {...tail(ended)} />)
|
||||
expect(screen.getByRole('region')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a registered body with the frame summary, the fold, its own translator and an action bound to the card', async () => {
|
||||
const seen: ActionCardBodyProps<never>[] = []
|
||||
const Body = memo((body: ActionCardBodyProps<never>) => {
|
||||
seen.push(body)
|
||||
return <div>{body.summary}<button type="button" onClick={() => { void body.act('start', { value: 'x' }) }}>{body.t('start' as never)}</button>
|
||||
<body.Folded text="Long details" expandLabel="more" collapseLabel="less" /></div>
|
||||
})
|
||||
const bodies = new Map<string, RegisteredBody>([['test_action', { locale: 'testCard', render: (frame, t) => <Body {...frame} t={t} /> }]])
|
||||
const value = props(bodies, { callId, action: 'test_action', phase: 'running' })
|
||||
render(<ActionCard {...value} />)
|
||||
expect(value.bind).toHaveBeenCalledWith('testCard')
|
||||
expect(screen.getByRole('region', { name: 'Counter' }).textContent).toContain('Keep my task count visible.')
|
||||
expect(screen.getByText('Long details')).toBeTruthy()
|
||||
expect(seen.at(-1)).toMatchObject({ proposal, decision: { phase: 'running' }, ready: true, sessionId: 'session-1', Folded: FoldedText })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'body:start' }))
|
||||
expect(value.act).toHaveBeenCalledWith(callId, 'start', { value: 'x' })
|
||||
})
|
||||
|
||||
it('offers only Not now for a card whose kind registered no body, and reports refusals and lost replies', async () => {
|
||||
const value = props(new Map(), undefined, true)
|
||||
const pending = Promise.withResolvers<ActionCardResult | undefined>()
|
||||
value.act = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('disconnected')).mockResolvedValueOnce({ status: 'unconfirmed', reason: 'closing' })
|
||||
.mockResolvedValueOnce({ status: 'accepted' })
|
||||
render(<ActionCard {...value} />)
|
||||
expect(screen.getByRole('region', { name: 'Counter' }).textContent).toContain('Keep my task count visible.')
|
||||
const dismiss = screen.getByRole('button', { name: zh.dismiss })
|
||||
fireEvent.click(dismiss)
|
||||
expect(dismiss.hasAttribute('disabled')).toBe(true)
|
||||
expect(value.act).toHaveBeenCalledWith(callId, 'dismiss', undefined)
|
||||
await act(async () => { pending.resolve({ status: 'refused', reason: 'unknown' }) })
|
||||
expect(screen.getByRole('alert').textContent).toBe(zh.unavailable)
|
||||
for (const text of [zh.transport, zh.transport, zh.unavailable]) {
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.dismiss }))
|
||||
await vi.waitFor(() => { expect(screen.getByRole('alert').textContent).toBe(text) })
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.dismiss }))
|
||||
await vi.waitFor(() => { expect(screen.queryByRole('alert')).toBeNull() })
|
||||
})
|
||||
|
||||
it.each([['dismissed', en.dismissed], ['replaced', en.replaced], ['finished', undefined], ['toString', undefined]] as const)(
|
||||
'shows the fallback status of a %s card without actions', (phase, text) => {
|
||||
render(<ActionCard {...props(new Map(), { callId, action: 'test_action', phase })} />)
|
||||
expect(screen.queryByRole('status')?.textContent).toBe(text)
|
||||
expect(screen.queryByRole('button', { name: en.dismiss })).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the card state before offering Not now', () => {
|
||||
render(<ActionCard {...{ ...props(), ready: false }} />)
|
||||
expect(screen.getByRole('button', { name: en.dismiss }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('only offers expansion for omitted text and preserves the full brief without layout measurement', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const short = 'Keep my task count visible.\n\nPersist across conversations.\nVerify removal.'
|
||||
const view = render(<FoldedText text={short} expandLabel={zh.expandBrief} collapseLabel={zh.collapseBrief} />)
|
||||
expect(screen.queryByRole('button', { name: zh.expandBrief })).toBeNull()
|
||||
expect(screen.getByText(/Keep my task count visible/).textContent).toBe(short)
|
||||
// 129 wide graphemes (258 units) plus one narrow emoji fill the 260-unit budget; the next grapheme folds.
|
||||
const prefix = '计'.repeat(129) + '👩🏽💻'
|
||||
const long = prefix + '\n\n保留完整方案,点击创建后交给 Creator。'
|
||||
view.rerender(<FoldedText text={long} expandLabel={zh.expandBrief} collapseLabel={zh.collapseBrief} />)
|
||||
const expand = screen.getByRole('button', { name: zh.expandBrief })
|
||||
const paragraph = document.getElementById(expand.getAttribute('aria-controls')!)!
|
||||
expect(paragraph.textContent).toBe(prefix + '…')
|
||||
expect(expand.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(expand)
|
||||
expect(screen.getByRole('button', { name: zh.collapseBrief }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(paragraph.textContent).toBe(long)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.collapseBrief }))
|
||||
expect(paragraph.textContent).toBe(prefix + '…')
|
||||
view.rerender(<FoldedText text={prefix} expandLabel={zh.expandBrief} collapseLabel={zh.collapseBrief} />)
|
||||
expect(screen.queryByRole('button', { name: zh.expandBrief })).toBeNull()
|
||||
expect(paragraph.textContent).toBe(prefix)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The process row reads the card title from the recorded arguments and expands only a refused call. */
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
|
||||
import { ProposeRow } from '../src/client/ProposeRow.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
type Props = Parameters<typeof ProposeRow>[0]
|
||||
const argsRaw = JSON.stringify({ action: 'test_action', title: 'Counter', brief: 'Keep count.', details: {} })
|
||||
const running: StartedToolCall = { phase: 'start', args: PartialArguments.fromText(argsRaw), callId: 'c', name: 'propose_action', argsRaw, turn: 1, step: 1, time: 1, subCalls: [] }
|
||||
const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'c', name: 'propose_action', args: PartialArguments.fromText(argsRaw),
|
||||
call: { name: 'propose_action', argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Card shown below your answer.' }], isError: false, subCalls: [] }
|
||||
function props(block: Props['block'], inspect?: () => void): Props {
|
||||
return { ...('kind' in block ? { phase: 'result' as const, block } : { phase: block.phase, block }),
|
||||
callId: 'c', toolName: 'propose_action', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
|
||||
}
|
||||
|
||||
it('shows a proposed card on one line without exposing the model-facing result', () => {
|
||||
const view = render(<ProposeRow {...props(settled)} />)
|
||||
expect(view.container.querySelector('[data-tool="propose_action"]')?.getAttribute('data-state')).toBe('ok')
|
||||
expect(view.getByText('Proposed')).toBeTruthy()
|
||||
expect(view.getByText('Counter')).toBeTruthy()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.textContent).not.toContain('Card shown below your answer.')
|
||||
})
|
||||
|
||||
it('explains a refused call and offers call inspection', () => {
|
||||
const inspect = vi.fn()
|
||||
const view = render(<ProposeRow {...props({ ...settled, isError: true,
|
||||
content: [{ type: 'text', text: 'Error: Load skill test-skill first, then call propose_action.' }, { type: 'reasoning', text: 'hidden' }] }, inspect)} />)
|
||||
expect(view.getByText('Not shown')).toBeTruthy()
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.click(row)
|
||||
expect(view.container.querySelector('pre')?.textContent).toBe('Error: Load skill test-skill first, then call propose_action.')
|
||||
fireEvent.click(view.getByRole('button', { name: 'Inspect call' }))
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['preparing', { phase: 'preparing', args: PartialArguments.EMPTY, callId: 'c', name: 'propose_action', turn: 1, step: 1, time: 1, subCalls: [] }, 'Preparing a card'],
|
||||
['running', running, 'Proposing'],
|
||||
['stopped', { ...settled, isError: true, content: [], error: { name: 'Interrupted', code: 'interrupted' } }, 'Interrupted'],
|
||||
] as const)('renders the %s call stage without claiming a card', (state, block, label) => {
|
||||
const view = render(<ProposeRow {...props(block)} />)
|
||||
expect(view.container.querySelector('[data-tool="propose_action"]')?.getAttribute('data-state')).toBe(state)
|
||||
expect(view.getAllByText(label).length).toBeGreaterThan(0)
|
||||
expect(view.queryByText('Proposed')).toBeNull()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../client/ui-renderer" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../api/remotes/tsconfig.client.json" },
|
||||
{ "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" },
|
||||
{ "path": "../../client/ui-primitives" },
|
||||
{ "path": "../../client/ui-tool" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../action-cards" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../util/values" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
Generated
+109
@@ -40,6 +40,9 @@ importers:
|
||||
'@deepseek-ai/dsh-cua-bundle':
|
||||
specifier: workspace:*
|
||||
version: link:packages/experimental/cua-bundle
|
||||
'@deepseek-ai/dsh-experimental-action-cards':
|
||||
specifier: workspace:*
|
||||
version: link:packages/experimental/action-cards
|
||||
'@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp':
|
||||
specifier: workspace:*
|
||||
version: link:packages/experimental/browser-use-chrome-devtools-mcp
|
||||
@@ -8061,6 +8064,58 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../core/session
|
||||
|
||||
packages/experimental/action-cards:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-agent-loop-testkit':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/agent-loop-testkit
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../../sandbox/sandbox-policy
|
||||
'@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
|
||||
'@deepseek-ai/dsh-tool-skill':
|
||||
specifier: workspace:*
|
||||
version: link:../../skill/tool-skill
|
||||
'@deepseek-ai/dsh-tool-testkit':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/tool-testkit
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
|
||||
packages/experimental/agent-team:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
@@ -8617,6 +8672,60 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../session/working-directory
|
||||
|
||||
packages/experimental/client-ui-action-cards:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/locale
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-chat':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-chat
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-conversation
|
||||
'@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-client-ui-tool':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-tool
|
||||
'@deepseek-ai/dsh-experimental-action-cards':
|
||||
specifier: workspace:*
|
||||
version: link:../action-cards
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/experimental/client-ui-agent-team:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
||||
@@ -48,9 +48,9 @@ const MAX_DECL_CHARS = 1200
|
||||
* share that hands down a subsystem instead of a share, and prose that grew
|
||||
* into a manual. Every report also lists one line per standard prop seat of its
|
||||
* scope, so the widest Session-scope slot (`tool.call.toolview`, with the
|
||||
* global and Session access seats) sets the current 122.
|
||||
* global and Session access seats) sets the current 123.
|
||||
*/
|
||||
const MAX_ENTRY_LINES = 122
|
||||
const MAX_ENTRY_LINES = 123
|
||||
|
||||
/** One register-call option as the catalog teaches it. */
|
||||
interface OptionDoc {
|
||||
|
||||
@@ -61,6 +61,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
connection: 'web-server.md',
|
||||
pluginManager: 'boot.md',
|
||||
creationRecommendations: 'boot.md',
|
||||
actionCards: 'boot.md',
|
||||
pluginMarket: 'boot.md',
|
||||
pluginRegistryProbe: 'boot.md',
|
||||
configEditor: 'boot.md',
|
||||
@@ -907,6 +908,10 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
CreationActionResult: 'packages/experimental/creation-recommendations/README.md',
|
||||
CreationSlot: 'packages/experimental/creation-recommendations/README.md',
|
||||
ActionCardDecision: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardKind: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardResult: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardView: 'packages/experimental/action-cards/README.md',
|
||||
ProductEvent: 'Desktop event fields are owned by packages/client/product-analytics/README.md and src/events.ts',
|
||||
ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts',
|
||||
ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts',
|
||||
|
||||
@@ -107,6 +107,14 @@ const GROUP_ORDER = [
|
||||
]
|
||||
|
||||
const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'actionCards',
|
||||
pkg: 'experimental-action-cards',
|
||||
title: 'Optional action cards',
|
||||
mode: 'service',
|
||||
consumers: ['experimental-client-ui-action-cards'],
|
||||
note: 'Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers.',
|
||||
},
|
||||
{
|
||||
key: 'creationRecommendations',
|
||||
pkg: 'experimental-creation-recommendations',
|
||||
|
||||
@@ -43,6 +43,8 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import ActionCards from '@deepseek-ai/dsh-experimental-action-cards'
|
||||
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
|
||||
import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
@@ -239,6 +241,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-experimental-action-cards',
|
||||
dir: 'action-cards',
|
||||
source: 'packages/experimental/action-cards/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.sessionProjections', 'ctx.remoteAccess'],
|
||||
writes: ['tool/call', 'tool/result', 'plugin:action-cards/decision', 'user/message'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
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.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('release families', () => {
|
||||
expect(members
|
||||
.filter(member => member.directory.startsWith('packages/experimental/'))
|
||||
.map(member => member.name)).toEqual([
|
||||
'@deepseek-ai/dsh-experimental-action-cards',
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-agent-team',
|
||||
'@deepseek-ai/dsh-experimental-api-speech-to-text',
|
||||
@@ -59,6 +60,7 @@ describe('release families', () => {
|
||||
'@deepseek-ai/dsh-experimental-browser-use-runtime',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-stagehand-native',
|
||||
'@deepseek-ai/dsh-experimental-claude-code-mods',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-action-cards',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-agent-team',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-claude-code-mods',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-cot-translation',
|
||||
|
||||
@@ -56,6 +56,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' },
|
||||
'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' },
|
||||
'packages/experimental/plugin-market-bundle': { kind: 'indirect', reason: 'Mounts the pluginMarket service, which lets the Creator preset register the plugin_market tool documented in packages/boot/plugin-market.' },
|
||||
'packages/experimental/client-ui-action-cards': { kind: 'indirect', reason: 'Card actions go to the Host card service, which owns the logged tool, decisions and status notice.' },
|
||||
'packages/experimental/client-ui-creation-recommendations': { kind: 'indirect', reason: 'The card starts a Creator through the Host creation service; the Host owns its logged task and tool schema.' },
|
||||
'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' },
|
||||
'packages/telemetry/otel': { kind: 'none', reason: 'The service delivers caller-selected records without changing model context.' },
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"@deepseek-ai/dsh-experimental-translator": ["./packages/experimental/translator/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-translator/types": ["./packages/experimental/translator/src/types.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-cot-translation": ["./packages/experimental/client-ui-cot-translation/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-action-cards": ["./packages/experimental/action-cards/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-action-cards/types": ["./packages/experimental/action-cards/src/types.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-action-cards": ["./packages/experimental/client-ui-action-cards/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-action-cards/client": ["./packages/experimental/client-ui-action-cards/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations": ["./packages/experimental/creation-recommendations/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations/types": ["./packages/experimental/creation-recommendations/src/types.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-creation-recommendations": ["./packages/experimental/client-ui-creation-recommendations/src/index.ts"],
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"packages/client/*/tests/**/*.host.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./packages/experimental/client-ui-action-cards" },
|
||||
{ "path": "./packages/experimental/client-ui-creation-recommendations" },
|
||||
{ "path": "./packages/client/ui-file-drag" },
|
||||
{ "path": "./packages/session/message-quotes" },
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"scripts/client-bundle-purity.spec.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./packages/experimental/action-cards" },
|
||||
{ "path": "./packages/experimental/creation-recommendations" },
|
||||
|
||||
{ "path": "./packages/experimental/client-ui-cot-translation/tsconfig.host.json" },
|
||||
|
||||
@@ -331,6 +331,8 @@ export default defineConfig({
|
||||
// The speech entry also imports generated Remote definitions; voice-input.e2e.ts
|
||||
// exercises the built entry, while source tests cover mountVoiceInput.
|
||||
'packages/experimental/client-ui-voice-input/src/client/index.ts',
|
||||
// Built Web replay covers generated action-card Remote wiring; mount.ts is source-covered.
|
||||
'packages/experimental/client-ui-action-cards/src/client/index.ts',
|
||||
// Built Web replay covers generated recommendation Remote wiring; mount.ts is source-covered.
|
||||
'packages/experimental/client-ui-creation-recommendations/src/client/index.ts',
|
||||
// The permission entry binds generated Remote code; cua-permissions.e2e.ts covers
|
||||
|
||||
Reference in new issue
Block a user