Merge feat/creation-recommendations into plugin recommendation experiment

Takes the action-card fixes: kinds reconcile before every action, only live
roots get cards, the tool refuses while messages are queued, and bodies no
longer receive ready. The plugin card drops ready, its queued-card test goes
with the suppressed phase, and the shared-row limitation follows the base's
new limitations.
This commit is contained in:
ZiyaZhang committed 2026-10-22 08:16:20 -07:00
commit dbe0eb3aa2
36 files changed
+473 -271

No files matched your search

+4 -2
View File
@@ -88,6 +88,7 @@ register<Details>(kind: ActionCardKind<Details>): () => void
* 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.
* @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives.
*/
record(agent: Agent, decision: ActionCardDecision): void
@@ -99,8 +100,9 @@ record(agent: Agent, decision: ActionCardDecision): void
view(session: Session): ActionCardView
/**
* 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.
* Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is
* recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind
* is no longer registered. 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.
+4 -2
View File
@@ -88,6 +88,7 @@ register<Details>(kind: ActionCardKind<Details>): () => void
* 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.
* @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives.
*/
record(agent: Agent, decision: ActionCardDecision): void
@@ -99,8 +100,9 @@ record(agent: Agent, decision: ActionCardDecision): void
view(session: Session): ActionCardView
/**
* 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.
* Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is
* recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind
* is no longer registered. 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.
@@ -15,17 +15,17 @@
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
/deepseek-ai-dsh-experimental-action-cards/use-this-package:
en: fe9d7180d7976c20
zh: e95542d3e55457d5
en: 5041f4b6aa4fd119
zh: 1bed53f562f622fa
/deepseek-ai-dsh-experimental-action-cards/use-this-package/register-a-card-kind:
en: a1414c3cfa8e204f
zh: 90eb7a7010972743
en: 48edcef36b626837
zh: 08e6ff9a321511a2
/deepseek-ai-dsh-experimental-action-cards/use-this-package/card-rules:
en: d37be4e928e6fa00
zh: 5d3eb834c2509707
en: 2f5b3ccb91f3b30e
zh: 9bd877d9d92615da
/deepseek-ai-dsh-experimental-action-cards/understand-the-implementation:
en: 5640f0436038aefc
zh: 5ebb181d81ad2c7e
en: d92243d71d2106c1
zh: 47f0840fa723bb31
/deepseek-ai-dsh-experimental-action-cards/further-exploration:
en: 1966ab8d7063c450
zh: 033c2c0d258a257f
@@ -36,8 +36,8 @@
en: 4925bc5f7153825a
zh: 3a7bfb2210d9c6aa
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/what-the-model-sees:
en: 04e06f15a53041b0
zh: b17c3c707c96c086
en: 1935d47905829f8c
zh: c676d4339c6e1b0f
/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/token-effect:
en: f9c78daaf64cf38e
zh: 2908740f9bc3bc9c
@@ -60,8 +60,8 @@
en: 58785543e2428dba
zh: 4b082f77b3b2f482
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work:
en: a5e4e8e3a2a4b8a2
zh: 44f46fe26d012d83
en: 163aa0c2a6b7e3f8
zh: 26cdcae33579114a
/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: bd8a8936229e035e
+14 -12
View File
@@ -29,20 +29,20 @@ The [Personalized pages](../creation-recommendations/README.md) and [Plugin reco
| Field | Default | Meaning |
|---|---|---|
| `queuedMessageLimit` | `2` | Queued next-turn user messages at or above which a card proposed in the current turn is never offered. |
| `queuedMessageLimit` | `2` | Queued next-turn user messages at or above which `propose_action` refuses the call, so no card is 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`.
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, except that Not now still dismisses an unstarted card. 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, an optional `reconcile` that updates the card's recorded decision from state outside the Session before every action, and the handler for every action except `dismiss`. The handler records each new decision with `ctx.actionCards.record()`, which refuses an action no kind registered and the derived `replaced` phase, 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.
- 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, calls dispatched from code, calls from a subagent, and any call while `queuedMessageLimit` or more user messages wait for later turns.
- A newer card of any kind replaces every unstarted earlier card. Replacement is 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.
- Before every action, including Not now, the kind's `reconcile` updates the card from state outside the Session. Not now is then recorded for an unstarted card and for the phases the kind lists; dismissed and replaced cards accept no further action. Without its registered kind, Not now still dismisses an unstarted card, and every other action is refused as unavailable.
- Concurrent actions on one card share one operation. Actions require the card's live root Agent.
-----
@@ -53,11 +53,11 @@ A kind package injects `actionCards` and calls `ctx.actionCards.register(kind)`
<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 package default-exports the `actionCards` Remote service. It registers `propose_action` once, globally, with a `when` condition: a live root Agent becomes eligible when its own direct `skill` call loads the skill of a registered kind, while loads by subagents or from code are ignored. The flag lives in memory until the Agent is disposed. The call's `execute` checks the live root, the queued messages the projection counts against `queuedMessageLimit` and 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 `actionCards` Session projection (state version 2) pairs each `propose_action` call with its successful result, records decisions from `plugin:action-cards/decision`, counts queued next-turn messages from inbox splices for the tool's limit, 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.
The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live root and the card; Not now continues without a registered kind, while other verbs need the kind, the verb and Remote access. It then shares one operation per card. The kind's `reconcile` runs first and the live root is checked again; a dismissed or replaced 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. `record()` also accepts an action whose kind has unregistered, because Cordis disposes a plugin's effects concurrently and an unloading kind still records the outcome of work it started. An `agent/pre-step` listener adds the status notice after later listeners prepared the step.
| Source | Contents |
|---|---|
@@ -85,7 +85,7 @@ The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live r
#### 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.
The [`propose_action` declaration](../../../docs/tool-catalog.md#deepseek-aidsh-experimental-action-cards) appears only to a live root Agent whose own direct call loaded the skill of a registered kind in this Session, from the next step on, for that Agent's lifetime. Subagents never see it, and neither do PTC presets, whose skill loads are dispatched from code, so they show no cards. 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.`, `The user has queued more messages; show no card and answer briefly.`, `Cards are offered in the main conversation only.`, `Unknown action "<action>". Registered actions: <actions>.`, where the list reads `none` once the last kind unregistered, or the failing `details` fields.
#### Token effect
@@ -120,9 +120,11 @@ The notice is appended after the request's other new context and never rewrites
<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.
- PTC presets show no cards: skill loads and calls dispatched from code never activate or reach the tool, because the log records them as program dispatches rather than tool calls.
- Cards are offered in the main conversation only. A subagent's skill load, including a Creator child's, activates nothing and its calls are refused, because only a live root can act on a card.
- Only one card is offered per turn across all kinds. A later card of any kind replaces every unstarted earlier card, so a second kind's card replaces an unstarted card of the first.
- Refusal reasons of user actions are plain strings by design: the service defines `closing`, `not-live-root`, `unknown`, `unavailable`, `access-denied` and `read-only`, each kind adds its own, and a browser body names the reasons it knows and treats others as unavailable.
- Each kind validates `details` on both sides, with its Host schema when the model proposes and before each action, and with its browser body's own schema, so the two schemas change together.
- The Personalized pages and Plugin recommendations bundles both insert the rows of this service and its browser half, and with both enabled the Loader keeps one entry per row id. Two optional bundles cannot yet share a dependency row addressably: while both are enabled, the Plugins page cannot address the two shared rows (`action-cards`, `client-ui-action-cards`), so they cannot be switched or configured individually. Use the bundle switches; `queuedMessageLimit` can then be changed only in the profile's `cordis.patch.yml`.
<a id="dev-note"></a>
+14 -12
View File
@@ -29,20 +29,20 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `queuedMessageLimit` | `2` | 排队到后续轮次的用户消息达到该数量时,本轮提议的卡片不再展示。 |
| `queuedMessageLimit` | `2` | 排队到后续轮次的用户消息达到该数量时,`propose_action` 拒绝调用,因此不会展示卡片。 |
生成的[配置目录](../../../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`。
种类所在的包注入 `actionCards`,并在自己的插件中调用 `ctx.actionCards.register(kind)`;该插件卸载时种类随之移除,此后对其卡片的操作会以 unavailable 拒绝,只有“暂不需要”仍可忽略尚未开始的卡片。种类需要给出:动作名、提议该动作的 Skill、卡片 `details` 的 zod 校验、接受的用户操作及各自需要的共享检查(`global` 表示需要全局 Remote 权限,`writes` 表示只读会话中拒绝)、仍可忽略卡片的已记录阶段、可选的状态说明、状态通知要报告的决定数据字段、可选的 `reconcile`(每次操作前根据会话之外的状态更新卡片的已记录决定),以及处理 `dismiss` 以外所有操作的处理器。处理器用 `ctx.actionCards.record()` 记录每个新决定(该方法拒绝没有种类注册过的动作和推导得出的 `replaced` 阶段),并返回 `accepted`、带当前决定的 `settled`、`refused` 或 `unconfirmed`。
### 卡片规则
- 一轮中第一个被接受的 `propose_action` 调用成为卡片。工具会拒绝同一轮的第二次调用、未注册的动作、本轮没有加载对应种类 Skill 的调用、空白的标题或说明、未通过种类校验的 details,以及从代码中发起的调用。
- 新卡片会替换此前所有尚未开始的卡片。用户排队到后续轮次的消息达到 `queuedMessageLimit` 条时提议的卡片只记录、不展示。这两种状态都从日志推导,从不单独记录。
- 一轮中第一个被接受的 `propose_action` 调用成为卡片。工具会拒绝同一轮的第二次调用、未注册的动作、本轮没有加载对应种类 Skill 的调用、空白的标题或说明、未通过种类校验的 details、从代码中发起的调用、来自 subagent 的调用,以及排队到后续轮次的用户消息达到 `queuedMessageLimit` 条时的任何调用。
- 任何种类的新卡片都会替换此前所有尚未开始的卡片。替换从日志推导,从不单独记录。
- fork 出的会话从父会话继承的事件不会产生卡片。
- 对尚未开始的卡片,以及处于种类列出阶段的卡片,“暂不需要”会被记录;已忽略、已替换、未展示的卡片不再接受任何操作。
- 每次操作(包括“暂不需要”)之前,种类的 `reconcile` 先根据会话之外的状态更新卡片。之后,对尚未开始的卡片,以及处于种类列出阶段的卡片,“暂不需要”会被记录;已忽略、已替换的卡片不再接受任何操作。卡片的种类未注册时,“暂不需要”仍可忽略尚未开始的卡片,其他操作一律以 unavailable 拒绝。
- 同一张卡片上的并发操作共用一次处理。操作要求卡片所在会话的根 Agent 仍然在线。
-----
@@ -53,11 +53,11 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
本包默认导出 `actionCards` Remote 服务。它在全局注册一次 `propose_action`,并附带 `when` 条件:某个 `skill` 工具结果加载了已注册种类的 Skill 后,该 Agent 才满足条件;这个标记保存在内存中,直到 Agent 被释放。调用的 `execute` 通过轮次边界检查本轮加载过的 Skill,并在内存中标记本轮已接受的调用;日志中的 `tool/call` 及其成功的 `tool/result` 就是持久的提议。
本包默认导出 `actionCards` Remote 服务。它在全局注册一次 `propose_action`,并附带 `when` 条件:在线的根 Agent 自己直接调用 `skill` 加载了已注册种类的 Skill 后,该 Agent 才满足条件,subagent 或代码中发起的加载一律忽略;这个标记保存在内存中,直到 Agent 被释放。调用的 `execute` 检查在线根 Agent、投影累计的排队消息是否达到 `queuedMessageLimit`,并通过轮次边界检查本轮加载过的 Skill,同时在内存中标记本轮已接受的调用;日志中的 `tool/call` 及其成功的 `tool/result` 就是持久的提议。
`actionCards` 会话投影(状态版本 1)把每个 `propose_action` 调用与其成功结果配对,从 `plugin:action-cards/decision` 读取决定,从收件箱拼接事件累计排队到下一轮的消息,并保留尚未报告的决定,直到 `through` 覆盖它们的状态通知写入日志。投影的客户端视图 `{ proposals, decisions }` 不进入会话摘要。
`actionCards` 会话投影(状态版本 2)把每个 `propose_action` 调用与其成功结果配对,从 `plugin:action-cards/decision` 读取决定,从收件箱拼接事件累计排队到下一轮的消息供工具上限使用,并保留尚未报告的决定,直到 `through` 覆盖它们的状态通知写入日志。投影的客户端视图 `{ proposals, decisions }` 不进入会话摘要。
Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在线根 Agent、卡片、种类与操作,以及 Remote 权限,然后让同一张卡片共用一次处理。之后,已忽略、已替换或未展示的卡片原样返回,“暂不需要”被记录,写入类操作在只读会话中被拒绝,其余操作在该 Agent 的发起方作用域内交给种类处理器。一个 `agent/pre-step` 监听器在后续监听器准备好本步之后追加状态通知。
Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在线根 Agent 和卡片;“暂不需要”在种类未注册时也继续处理,其他操作还需要种类、操作和 Remote 权限。然后同一张卡片共用一次处理。种类的 `reconcile` 先运行,之后再次检查在线根 Agent;已忽略或已替换的卡片原样返回,“暂不需要”被记录,写入类操作在只读会话中被拒绝,其余操作在该 Agent 的发起方作用域内交给种类处理器。`record()` 也接受种类已注销的动作,因为 Cordis 并发释放插件的各个 effect,卸载中的种类仍要记录已开始工作的结果。一个 `agent/pre-step` 监听器在后续监听器准备好本步之后追加状态通知。
| 源码 | 内容 |
|---|---|
@@ -85,7 +85,7 @@ Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在
#### 模型看到什么
[`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` 字段。
[`propose_action` 声明](../../../docs/tool-catalog.zh.md#deepseek-aidsh-experimental-action-cards)只对在本会话中自己直接调用加载过已注册种类 Skill 的在线根 Agent 出现,从下一步开始,持续到该 Agent 被释放。subagent 看不到它;PTC 预设的 Skill 加载由代码发起,也看不到它,因此不会展示卡片。种类的 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.`、`The user has queued more messages; show no card and answer briefly.`、`Cards are offered in the main conversation only.`、`Unknown action "<action>". Registered actions: <actions>.`(最后一个种类注销后列表为 `none`),或未通过校验的 `details` 字段。
#### Token 影响
@@ -120,9 +120,11 @@ Latest status of action cards from user actions and background work. These are s
<a id="known-limitations-and-deferred-work"></a>
- 激活按在线 Agent 记在内存中,因此会话重新打开后,要再次加载卡片 Skill 工具才会再次出现。
- 从代码中发起的调用(例如 PTC 预设中)会被拒绝:日志把它们记为程序派发而不是工具调用,因此不可能有卡片跟随。
- 所有种类合计每轮只展示一张卡片,每个对话只保留一张尚未开始的卡片。
- 投影读取 `queuedMessageLimit`,状态版本却不随它变化。修改后的上限只作用于重新从日志折叠的会话;已缓存的投影行保留按旧上限得出的“未展示”结果。
- PTC 预设不会展示卡片:从代码中发起的 Skill 加载和调用既不会激活工具,也到达不了工具,因为日志把它们记为程序派发而不是工具调用。
- 卡片只在主对话中提供。subagent(包括 Creator 子 Agent)加载 Skill 不会激活任何东西,其调用也会被拒绝,因为只有在线的根 Agent 才能对卡片进行操作。
- 所有种类合计每轮只展示一张卡片。任何种类的新卡片都会替换此前所有尚未开始的卡片,因此第二个种类的卡片会替换第一个种类尚未开始的卡片。
- 用户操作的拒绝原因有意设计为普通字符串:服务定义 `closing`、`not-live-root`、`unknown`、`unavailable`、`access-denied` 和 `read-only`,各种类可以添加自己的原因,浏览器端正文为已知原因显示文案,其余按 unavailable 处理。
- 每个种类在两端各自校验 `details`:Host 端的 schema 在模型提议时和每次操作前校验,浏览器端正文用自己的 schema 校验,因此两个 schema 要一起修改。
- 个性化页面和插件推荐两个 Bundle 都会插入本服务及其浏览器端的行,两个都启用时 Loader 对每个行 id 只保留一个条目。平台目前不支持两个可选 Bundle 以可定位的方式共享同一依赖行:两个都启用时,插件页无法定位这两条共享行(`action-cards`、`client-ui-action-cards`),不能单独开关或配置它们。请使用 Bundle 开关;此时 `queuedMessageLimit` 只能在 profile 的 `cordis.patch.yml` 中修改。
<a id="dev-note"></a>
+101 -24
View File
@@ -16,7 +16,7 @@ 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. */
/** Queued next-turn user messages at or above which `propose_action` refuses, so no card is offered. */
queuedMessageLimit: z.number().int().min(1).default(2),
}).prefault({})
/** Validated card limits after schema defaults. */
@@ -28,8 +28,10 @@ declare module '@deepseek-ai/cordis' {
/** The user action every card offers; the shared service records it. */
const DISMISS = 'dismiss'
/** Phases the projection derives from the log; no decision may record them. */
const DERIVED_PHASES: readonly string[] = ['replaced']
/** Phases after which no user action changes the card. */
const FINAL_PHASES: readonly string[] = ['dismissed', 'replaced', 'suppressed']
const FINAL_PHASES: readonly string[] = ['dismissed', ...DERIVED_PHASES]
/** Tool whose successful loads activate `propose_action`; its `name` argument names the skill. */
const SKILL_TOOL = 'skill'
const skillArguments = z.object({ name: z.string() })
@@ -96,6 +98,16 @@ export interface ActionCardKind<Details> {
* @returns decision data, or undefined to record none.
*/
dismissal?(proposal: ActionCardProposalOf<Details>, payload: ActionCardFields | undefined): ActionCardFields | undefined
/**
* Bring the card's recorded decision up to date with state outside the Session, such as an installation that
* finished while the Session was closed, recording each change with {@link ActionCards.record}. The service awaits it
* before every user action, including `dismiss`, then reads the card's decision again, so a card that is no longer
* open settles instead of being dismissed.
* @param agent - live root of the card's Session.
* @param proposal - the card with validated details.
* @throws when that state cannot be read; the action is then refused as unavailable.
*/
reconcile?(agent: Agent, proposal: ActionCardProposalOf<Details>): Promise<void>
/**
* Handle one user action other than `dismiss` after the shared checks passed. The handler records each new
* decision with {@link ActionCards.record}.
@@ -118,6 +130,8 @@ interface RegisteredKind {
/** 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
/** Absent when the kind keeps no state outside the Session. */
readonly reconcile: ((agent: Agent, proposal: ActionCardProposal) => Promise<void>) | undefined
readonly act: (
agent: Agent, proposal: ActionCardProposal, verb: string, payload: ActionCardFields | undefined,
) => Promise<ActionCardResult>
@@ -142,6 +156,11 @@ export class ActionCards extends TypertRemoteService {
static Config = Config
private projection!: ProjectionHandle<ActionCardState>
private readonly kinds = new Map<string, RegisteredKind>()
/**
* Actions whose kind unregistered. Cordis disposes a plugin's effects concurrently, so a kind's unload drain can
* still record the outcome of its in-flight work after its registration is gone.
*/
private readonly retired = new Set<string>()
/** 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. */
@@ -162,7 +181,7 @@ export class ActionCards extends TypertRemoteService {
}
protected async [Service.init](): Promise<void> {
this.projection = await this.ctx.sessionProjections.register(actionCardProjection(this.config))
this.projection = await this.ctx.sessionProjections.register(actionCardProjection)
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.',
@@ -188,6 +207,8 @@ export class ActionCards extends TypertRemoteService {
if (result.isError && this.accepted.get(agent)?.callId === exec.callId) this.accepted.delete(agent)
return
}
// Loads by subagents or from code activate nothing: `propose_action` refuses both callers.
if (exec.parent !== undefined || !this.ctx.agents.roots().includes(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
@@ -238,6 +259,7 @@ export class ActionCards extends TypertRemoteService {
const details = kind.details.safeParse(proposal.details)
return details.success ? { ...proposal, details: details.data } : undefined
}
const reconcile = kind.reconcile?.bind(kind)
const entry: RegisteredKind = {
skill: kind.skill, verbs: kind.verbs, openPhases: kind.openPhases ?? [],
statusGuidance: kind.statusGuidance, statusFields: kind.statusFields,
@@ -249,6 +271,11 @@ export class ActionCards extends TypertRemoteService {
const card = typed(proposal)
return card === undefined ? undefined : kind.dismissal?.(card, payload)
},
// A card whose details no longer validate never reached the kind, so the kind has nothing to reconcile.
reconcile: reconcile === undefined ? undefined : async (agent, proposal) => {
const card = typed(proposal)
if (card !== undefined) await reconcile(agent, card)
},
act: (agent, proposal, verb, payload) => {
const card = typed(proposal)
return card === undefined ? Promise.resolve(refused('unavailable')) : kind.act(agent, card, verb, payload)
@@ -257,7 +284,10 @@ export class ActionCards extends TypertRemoteService {
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) }
return () => {
this.kinds.delete(kind.action)
this.retired.add(kind.action)
}
}, `action-cards: kind ${kind.action}`)
return () => { void dispose() }
}
@@ -266,9 +296,14 @@ export class ActionCards extends TypertRemoteService {
* 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.
* @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives.
*/
record(agent: Agent, decision: ActionCardDecision): void {
appendPluginRecord(agent.session, 'plugin:action-cards/decision', decision)
if (!this.kinds.has(decision.action) && !this.retired.has(decision.action)) {
throw new Error(`action-cards: cannot record a decision for unregistered action "${decision.action}"`)
}
if (DERIVED_PHASES.includes(decision.phase)) throw new Error(`action-cards: phase "${decision.phase}" is derived from the log and cannot be recorded`)
this.append(agent, decision)
}
/**
@@ -281,8 +316,9 @@ export class ActionCards extends TypertRemoteService {
}
/**
* 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.
* Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is
* recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind
* is no longer registered. 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.
@@ -296,35 +332,71 @@ export class ActionCards extends TypertRemoteService {
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'))
if (verb === DISMISS) return this.coalesce(agent, callId, () => this.dismiss(agent, kind, proposal, payload))
const policy = kind === undefined ? undefined : ownEntry(kind.verbs, verb)
if (kind === undefined || policy === undefined) return Promise.resolve(refused('unavailable'))
if (policy.global === true && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve(refused('access-denied'))
return this.coalesce(agent, callId, () => this.perform(agent, kind, proposal, verb, policy, payload))
}
/** Share one operation per card, run under the Agent's initiator, until it settles. */
private coalesce(agent: Agent, callId: ToolCallId, run: () => Promise<ActionCardResult>): Promise<ActionCardResult> {
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) })
const operation = this.ctx.agents.withInitiator(agent, run).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,
/**
* Await the kind's reconciliation. A kind that unloads meanwhile still handles the action and refuses it itself.
* @returns a refusal when reconciliation failed or the root closed meanwhile; undefined to continue.
*/
private async reconciled(
agent: Agent, kind: RegisteredKind, proposal: ActionCardProposal, verb: string,
): Promise<ActionCardResult | undefined> {
if (kind.reconcile === undefined) return undefined
try { await kind.reconcile(agent, proposal) }
catch (error) {
this.ctx.logger.warn('Cannot reconcile action card %s before %s: %s', proposal.callId, verb, String(error))
return refused('unavailable')
}
return this.ctx.agents.roots().includes(agent) ? undefined : refused('not-live-root')
}
private async dismiss(
agent: Agent, kind: RegisteredKind | undefined, proposal: ActionCardProposal, payload: ActionCardFields | undefined,
): Promise<ActionCardResult> {
const refusal = kind === undefined ? undefined : await this.reconciled(agent, kind, proposal, DISMISS)
if (refusal !== undefined) return refusal
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId)
// Without its kind no recorded phase is known to be open, and the kind may return to act on it.
if (decision !== undefined && (FINAL_PHASES.includes(decision.phase) || kind?.openPhases.includes(decision.phase) !== true)) {
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.append(agent, dismissed)
return { status: 'settled', decision: dismissed }
}
private async perform(
agent: Agent, kind: RegisteredKind, proposal: ActionCardProposal, verb: string, policy: ActionCardVerb,
payload: ActionCardFields | undefined,
): Promise<ActionCardResult> {
const refusal = await this.reconciled(agent, kind, proposal, verb)
if (refusal !== undefined) return refusal
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')
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)
}
private append(agent: Agent, decision: ActionCardDecision): void {
appendPluginRecord(agent.session, 'plugin:action-cards/decision', decision)
}
/** Whether `propose_action` is declared for this agent's next step. */
private declares(agent: Agent): boolean {
const skills = this.loaded.get(agent)
@@ -339,9 +411,14 @@ export class ActionCards extends TypertRemoteService {
// 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')
// Only a live root can act on a card, so a subagent's card could never be used.
if (!this.ctx.agents.roots().includes(agent)) throw new ActionCardError('Cards are offered in the main conversation only.', 'ACTION_CARD_ROOT')
if (this.projection.stateOf(agent.session).queued >= this.config.queuedMessageLimit) {
throw new ActionCardError('The user has queued more messages; show no card and answer briefly.', 'ACTION_CARD_QUEUED')
}
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')
// Admission is decided when the step starts, so the last kind can unregister before this call runs.
if (kind === undefined) throw new ActionCardError(`Unknown action "${args.action}". Registered actions: ${[...this.kinds.keys()].join(', ') || 'none'}.`, '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)) {
@@ -50,88 +50,75 @@ const stateSchema = z.object({
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. */
/** User messages still queued for later turns, folded from inbox splices; the card tool compares it with its limit. */
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. */
/** Type of {@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.
* the first admitted call of a turn wins. A newer card of any kind replaces every unstarted earlier card.
* 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 } }
}
export const actionCardProjection: ActionCardProjectionDefinition = {
key: 'actionCards', stateVersion: 2, 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)) }
}
},
wire: { summary: false, viewSchema, view: (state: ActionCardState) => state.view },
}
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 }
// 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 },
}
@@ -25,9 +25,8 @@ export interface ActionCardProposal {
/**
* 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.
* `replaced` (a later proposal superseded an unstarted card) is derived from the
* log and never recorded.
*/
export interface ActionCardDecision {
readonly callId: ToolCallId
@@ -1,12 +1,13 @@
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'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ActionCardProposalOf, ActionCardResult } from '../src/index.ts'
import { ACTION, callId, fixture, proposal, type TestDetails } 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 () => {
it('refuses unknown cards, closed owners, verbs of 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()
@@ -20,7 +21,7 @@ it('refuses unknown cards, closed owners, removed kinds and undeclared verbs bef
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' })
expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).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' })
@@ -97,3 +98,70 @@ it('rejects a second kind for the same action and releases a kind through its di
unregister()
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-other'), 'start')).toEqual({ status: 'refused', reason: 'unavailable' })
})
it('lets the kind reconcile before every action, so a card that is no longer open settles instead of being dismissed', async () => {
let finished = false
const reconcile = vi.fn(async (agent: Agent, card: ActionCardProposalOf<TestDetails>) => {
if (finished) f.ctx.actionCards.record(agent, { callId: card.callId, action: ACTION, phase: 'installed' })
})
const f = await fixture({ openPhases: ['unconfirmed'], reconcile })
f.propose()
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'unconfirmed' })
finished = true
const installed = { status: 'settled', decision: { callId, action: ACTION, phase: 'installed' } }
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual(installed)
expect(reconcile).toHaveBeenCalledWith(f.agent, { callId, turn: 1, ...proposal })
expect(f.view().decisions[callId]?.phase).toBe('installed')
expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'settled', decision: { callId, action: ACTION, phase: 'start' } })
expect(reconcile).toHaveBeenCalledTimes(2)
f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2)
expect((await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'dismiss')).status).toBe('settled')
expect(reconcile).toHaveBeenCalledTimes(2)
})
it('refuses the action when reconciliation fails or the root closes meanwhile', async () => {
const reconcile = vi.fn<(agent: Agent) => Promise<void>>(async () => { throw new Error('profile read failed') })
const f = await fixture({ reconcile })
f.propose()
const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(warn).toHaveBeenCalledWith('Cannot reconcile action card %s before %s: %s', callId, 'dismiss', 'Error: profile read failed')
expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(warn).toHaveBeenLastCalledWith('Cannot reconcile action card %s before %s: %s', callId, 'start', 'Error: profile read failed')
expect(f.view().decisions).toEqual({})
reconcile.mockImplementationOnce(async () => { await f.agentHandle.dispose() })
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'not-live-root' })
expect(f.act).not.toHaveBeenCalled()
})
it('refuses to record decisions of actions no kind registered or of phases the projection derives', async () => {
const f = await fixture()
f.propose()
expect(() => { f.ctx.actionCards.record(f.agent, { callId, action: 'other_action', phase: 'done' }) })
.toThrow('action-cards: cannot record a decision for unregistered action "other_action"')
expect(() => { f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'replaced' }) })
.toThrow('action-cards: phase "replaced" is derived from the log and cannot be recorded')
expect(f.view().decisions).toEqual({})
// An unloading kind still records the outcome of work it had started.
await f.kindFiber.dispose()
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'interrupted' })
expect(f.view().decisions[callId]?.phase).toBe('interrupted')
})
it('dismisses an unstarted card whose kind is no longer registered, without data, and settles recorded ones', async () => {
const f = await fixture({ openPhases: ['failed'], dismissal: () => ({ value: 'kind data' }) })
f.propose(ToolCallId('call-failed'), proposal, f.agent, 1)
f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-failed'), action: ACTION, phase: 'failed' })
f.propose(ToolCallId('call-done'), proposal, f.agent, 2)
f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-done'), action: ACTION, phase: 'dismissed' })
f.propose(callId, proposal, f.agent, 3)
await f.kindFiber.dispose()
expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed())
expect(f.view().decisions[callId]).toEqual({ callId, action: ACTION, phase: 'dismissed' })
// The kind may return and act on its recorded phases, so they stay as they are.
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-failed'), 'dismiss')).toEqual({ status: 'settled',
decision: { callId: 'call-failed', action: ACTION, phase: 'failed' } })
expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-done'), 'dismiss')).toEqual({ status: 'settled',
decision: { callId: 'call-done', action: ACTION, phase: 'dismissed' } })
expect(f.act).not.toHaveBeenCalled()
})
@@ -6,7 +6,7 @@ import {
import { actionCardProjection, type ActionCardState } from '../src/projection.ts'
import type { ActionCardDecision } from '../src/types.ts'
const projection = actionCardProjection({ queuedMessageLimit: 2 })
const projection = actionCardProjection
const args = { action: 'test_action', title: 'Counter', brief: 'Keep count visible', details: { value: 'a' } }
let seq = 0
@@ -75,16 +75,17 @@ it('keeps the first admitted card of a turn and replaces only unstarted earlier
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()
// Replacement crosses kinds: a card of another action replaces the unstarted one too.
const other = fold([turn(2), call('d', { ...args, action: 'other_action' }, 2), result('d', false, 2)], first)
expect(other.view.decisions).toEqual({ a: { callId: 'a', action: 'test_action', phase: 'replaced' } })
})
it('suppresses a card proposed while user messages are queued for later turns', () => {
it('counts user messages queued for later turns for the card tool and leaves cards to it', () => {
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(Object.keys(queued.view.proposals)).toEqual(['a'])
expect(queued.view.decisions).toEqual({})
expect(fold([spliced(0, 2)], queued).queued).toBe(0)
expect(fold([spliced(0, 5)]).queued).toBe(0)
})
@@ -1,6 +1,7 @@
import { expect, it, vi } from 'vitest'
import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
import ActionCards from '../src/index.ts'
import { ACTION, callId, fixture } from './fixture.ts'
@@ -25,7 +26,8 @@ it('reports recorded decisions once on the next request, with each kind guidance
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' })
// A decision logged by a kind that is no longer registered is still reported.
appendPluginRecord(f.agent.session, 'plugin:action-cards/decision', { 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)
@@ -1,4 +1,4 @@
import { expect, it } from 'vitest'
import { expect, it, vi } 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'
@@ -108,6 +108,77 @@ it('accepts one card per turn, admits a retry after a failed result and names th
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.
// Disposing the agent releases its activation.
expect(await declared(f)).toBe(true)
await f.agentHandle.dispose()
expect(await declared(f)).toBe(false)
})
it('activates nothing for a skill load dispatched from code, so a PTC preset never declares the tool', async () => {
const f = await fixture()
f.agent.session.append('turn/start', { turn: 1 })
const outer = Symbol('outer') as ToolExecutionToken
expect((await call(f, 'skill', { name: SKILL }, 'nested-load', f.agent, outer)).isError).toBe(false)
expect(await declared(f)).toBe(false)
expect((await call(f, 'propose_action', proposal, 'unadmitted')).isError).toBe(true)
expect((await call(f, 'skill', { name: SKILL }, 'direct-load')).isError).toBe(false)
expect(await declared(f)).toBe(true)
})
it('offers cards in the main conversation only: subagent skill loads activate nothing and non-root calls are refused', async () => {
const f = await fixture()
const child = (await f.ctx.agents.create({ sessionId: SessionId('cards-child'), parentAgent: f.agent,
meta: { parentSession: f.agent.id, origin: 'subagent' } })).agent
child.session.append('turn/start', { turn: 1 })
expect((await call(f, 'skill', { name: SKILL }, 'child-load', child)).isError).toBe(false)
expect(await declared(f, child)).toBe(false)
expect((await call(f, 'propose_action', proposal, 'child-card', child)).isError).toBe(true)
f.agent.session.append('turn/start', { turn: 1 })
await call(f, 'skill', { name: SKILL }, 'load')
// Admission is fixed when the step starts; the call still checks that its agent is a live root.
const toolSet = await f.ctx.tools.resolve({ agent: f.agent, modelSelection, signal })
vi.spyOn(f.ctx.agents, 'roots').mockReturnValue([])
const refused = await executeModelToolFixture(f.ctx, { callId: ToolCallId('detached-root'), name: 'propose_action', arguments: proposal,
agent: f.agent, signal, toolSet })
toolSet.dispose()
expect(text(refused)).toBe('Error: Cards are offered in the main conversation only.')
})
it('names no actions when the last kind unregisters after the step admitted the tool', async () => {
const f = await fixture()
f.agent.session.append('turn/start', { turn: 1 })
await call(f, 'skill', { name: SKILL }, 'load')
const toolSet = await f.ctx.tools.resolve({ agent: f.agent, modelSelection, signal })
await f.kindFiber.dispose()
const result = await executeModelToolFixture(f.ctx, { callId: ToolCallId('late'), name: 'propose_action', arguments: proposal,
agent: f.agent, signal, toolSet })
toolSet.dispose()
expect(text(result)).toBe('Error: Unknown action "test_action". Registered actions: none.')
})
it('refuses a card while the user has queued messages for later turns, so the log holds no card', async () => {
const f = await fixture()
const queued = () => createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
const live: { agent?: Agent } = {}
const adapter = new MockAdapter([
toolCallResponse('call-skill', 'skill', { name: SKILL }),
() => {
// The user queues two more messages while the model writes its answer.
live.agent!.inbox.append('next-turn', queued())
live.agent!.inbox.append('next-turn', queued())
return toolCallResponse('call-card-1', 'propose_action', proposal)
},
textResponse('Short answer.'),
textResponse('Next answer.'),
textResponse('Last answer.'),
])
f.ctx.llm.registerAdapter(['mock'], adapter)
const agent = await f.harness.create(SessionId('queued'), modelSelection)
live.agent = agent
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'I want a counter.' }], source: { kind: 'user' } }))
await agent.whenIdle()
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).toMatchObject({ isError: true,
content: [{ type: 'text', text: 'Error: The user has queued more messages; show no card and answer briefly.' }] })
expect(f.view(agent)).toEqual({ proposals: {}, decisions: {} })
})
@@ -9,8 +9,8 @@
en: 6ed1f2bf10bc2f1b
zh: e3edea71ec4373c7
/deepseek-ai-dsh-experimental-client-ui-action-cards/summary:
en: 69abe0c52072c874
zh: b718aa6a13be4b13
en: 89ea57734c42a481
zh: c7a4f5f0b59b78ad
/deepseek-ai-dsh-experimental-client-ui-action-cards/table-of-contents:
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
@@ -18,11 +18,11 @@
en: 163d385e91a5df38
zh: 95bdde9e844cca51
/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package/register-a-card-body:
en: 2ab65c8c258e694a
zh: a785becbe7015e0e
en: 9ec9f9f8f3165d3e
zh: 2d8154ec2fe0c9ab
/deepseek-ai-dsh-experimental-client-ui-action-cards/understand-the-implementation:
en: bdfd09e39b6e0edf
zh: 06f2da1c16a66547
en: 168ae39a83d6f4eb
zh: 312cc12876bca834
/deepseek-ai-dsh-experimental-client-ui-action-cards/further-exploration:
en: 55e4d74a33cdeeb3
zh: f601d5a4dcf63ec5
@@ -33,8 +33,8 @@
en: 9cb7131bde146341
zh: 1c7d1af00f58004d
/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work:
en: 96914db20cfc0671
zh: cd231bcdcb757ae0
en: df94821940e4e2b8
zh: 490441ab06b153c9
/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: bd8a8936229e035e
@@ -9,7 +9,7 @@ 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.
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 call was accepted; 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
@@ -29,7 +29,7 @@ Enable the [Personalized pages](../creation-recommendations/README.md) or [Plugi
### 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 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, 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.
-----
@@ -39,7 +39,7 @@ A kind's browser plugin injects `actionCards` and registers its body inside its
<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.
One `conversation.chat.turnTail` entry reads the `actionCards` Session projection and shows the card admitted in its Turn after that Turn has ended with a final answer; a Turn that ends without answer text shows no card, 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.
@@ -75,7 +75,7 @@ Rendering cards and process rows does not change any model request.
<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.
- A card whose action has no registered body offers only Not now, so its experiment's other actions need that experiment's browser plugin. Not now still works after the experiment's Host plugin is turned off.
- The Turn tail shows one card per Turn, matching the Host rule of one card per turn.
<a id="dev-note"></a>
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
用户在已完成的回答下方看到一张可选操作卡片:标题、过长时会折叠的说明,以及提议该卡片的实验所提供的控件。在过程区中,提议显示为一行,带卡片标题和卡片是否已展示;被拒绝的调用可以展开查看原因。如果实验在当前浏览器中没有提供控件,卡片只提供“暂不需要”。展示、展开和折叠卡片都不会发起模型请求。
用户在已完成的回答下方看到一张可选操作卡片:标题、过长时会折叠的说明,以及提议该卡片的实验所提供的控件。在过程区中,提议显示为一行,带卡片标题和调用是否被接受;被拒绝的调用可以展开查看原因。如果实验在当前浏览器中没有提供控件,卡片只提供“暂不需要”。展示、展开和折叠卡片都不会发起模型请求。
## 目录
@@ -29,7 +29,7 @@ kind: "package-reference"
### 注册卡片正文
种类的浏览器插件注入 `actionCards`,并在自己的 `ctx.effect` 中注册正文:`ctx.actionCards.register({ action, locale, Body })` 返回注销函数。框架为该动作的卡片渲染 `Body`,并传入:卡片、已记录或推导出的决定、卡片状态是否已到达、会话 id、框架渲染的标题与折叠说明 `summary`、框架的折叠组件 `Folded`、绑定到该卡片的 `act(verb, payload?)`,以及正文自己语言命名空间的翻译函数 `t`。正文把 `summary` 放进自己的布局,并负责自己的标题栏、控件、状态和底部操作。为同一动作注册第二个正文属于注册错误。
种类的浏览器插件注入 `actionCards`,并在自己的 `ctx.effect` 中注册正文:`ctx.actionCards.register({ action, locale, Body })` 返回注销函数。框架为该动作的卡片渲染 `Body`,并传入:卡片、已记录或推导出的决定、会话 id、框架渲染的标题与折叠说明 `summary`、框架的折叠组件 `Folded`、绑定到该卡片的 `act(verb, payload?)`,以及正文自己语言命名空间的翻译函数 `t`。正文把 `summary` 放进自己的布局,并负责自己的标题栏、控件、状态和底部操作。为同一动作注册第二个正文属于注册错误。
-----
@@ -39,7 +39,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
一个 `conversation.chat.turnTail` 条目读取 `actionCards` 会话投影,在某一轮结束后(无论因何结束)展示该轮接受的卡片;未展示状态的卡片不显示,若同一轮有多张卡片,显示调用 id 最小的一张。框架把 `data-action-card` 设为调用 id,并以卡片标题命名所在区域。折叠时,宽字素(如汉字)按两个拉丁字母宽度计算,约 260 个单位后折叠;折叠不测量布局,也不改动完整文本。
一个 `conversation.chat.turnTail` 条目读取 `actionCards` 会话投影,在某一轮以最终回答结束后展示该轮接受的卡片;没有回答文本就结束的轮次不显示卡片,若同一轮有多张卡片,显示调用 id 最小的一张。框架把 `data-action-card` 设为调用 id,并以卡片标题命名所在区域。折叠时,宽字素(如汉字)按两个拉丁字母宽度计算,约 260 个单位后折叠;折叠不测量布局,也不改动完整文本。
一个以 `propose_action` 为键的 `tool.call.toolview` 条目,根据记录的调用参数和结果渲染过程行。插件在顶层提供正文注册表,然后挂载生成的 `actionCards` Remote,并在同一生命周期内注册词典和插槽条目。
@@ -75,7 +75,7 @@ kind: "package-reference"
<a id="known-limitations-and-deferred-work"></a>
- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。
- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。即使该实验的 Host 插件已关闭,“暂不需要”仍然可用。
- 轮次尾部每轮只显示一张卡片,与 Host 每轮一张卡片的规则一致。
<a id="dev-note"></a>
@@ -80,16 +80,15 @@ function decisionOf(view: ActionCardView | undefined, callId: string): ActionCar
}
/**
* Place the card admitted in a turn after that turn's final answer, once the turn has ended for any reason.
* Place the card admitted in a turn after that turn's final answer, once the turn has ended with one.
* @param props - Turn location, projection reader, registered bodies and Session-bound actions.
* @returns the card, or null.
* @returns the card, or null while the turn is open or when it ended without answer text.
*/
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} />
return proposal === undefined || turn.end === undefined || turn.data.get('turn-tail')?.closing == null
? null : <ActionCard {...props} proposal={proposal} decision={decisionOf(view, proposal.callId)} />
}
/**
@@ -97,10 +96,9 @@ export function ActionCardTail({ turn, useProjection, ...props }: TailProps) {
* @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 }: {
export function ActionCard({ proposal, decision, 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])
@@ -110,8 +108,8 @@ export function ActionCard({ proposal, decision, ready, sessionId, act, bind, us
</>
// 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))
? <FallbackBody summary={summary} decision={decision} act={send} t={t} />
: body.render({ proposal, decision, 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>
@@ -121,10 +119,9 @@ export function ActionCard({ proposal, decision, ready, sessionId, act, bind, us
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 }: {
function FallbackBody({ summary, decision, act, t }: {
summary: ReactNode
decision: ActionCardDecision | undefined
ready: boolean
act: (verb: string) => Promise<ActionCardResult | undefined>
} & PropsLocale<'actionCards'>) {
const [pending, setPending] = useState(false)
@@ -147,7 +144,7 @@ function FallbackBody({ summary, decision, ready, act, t }: {
{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>
<Button variant="ghost" size="sm" disabled={pending} onClick={() => { void dismiss() }}>{t('dismiss')}</Button>
</footer>}
</div>
}
@@ -20,8 +20,6 @@ export interface ActionCardBodyProps<N extends Extract<keyof LocaleNamespaceMap,
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
@@ -48,7 +48,7 @@ it('binds actions to the displayed session, serves registered bodies and removes
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,
action: 'test_action', title: 'Counter', brief: 'Keep count.', details: {} }, decision: undefined,
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')
@@ -17,14 +17,14 @@ 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'),
return { proposal, decision, 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', () => {
it('places the card admitted in its turn once the turn has ended with an answer, and leaves other turns empty', () => {
type Tail = ComponentProps<typeof ActionCardTail>
const value = props()
let view: ActionCardView | undefined
@@ -49,12 +49,14 @@ it('places the card admitted in its turn once the turn has ended, and leaves oth
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' } } }
view = { ...view, decisions: { a: { callId: ToolCallId('a'), action: 'test_action', phase: 'dismissed' } } }
rerender(<ActionCardTail {...tail(ended)} />)
expect(screen.queryByRole('region')).toBeNull()
view = { ...view, decisions: { toString: { callId: ToolCallId('toString'), action: 'test_action', phase: 'suppressed' } } }
expect(screen.getByRole('status').textContent).toBe(en.dismissed)
// A call id naming an inherited object member reads no decision.
view = { decisions: {}, proposals: { toString: card('toString', 3) } }
rerender(<ActionCardTail {...tail(ended)} />)
expect(screen.getByRole('region')).toBeTruthy()
expect(screen.getByRole('region').getAttribute('data-action-card')).toBe('toString')
expect(screen.getByRole('button', { name: en.dismiss })).toBeTruthy()
})
it('renders a registered body with the frame summary, the fold, its own translator and an action bound to the card', async () => {
@@ -70,7 +72,7 @@ it('renders a registered body with the frame summary, the fold, its own translat
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 })
expect(seen.at(-1)).toMatchObject({ proposal, decision: { phase: 'running' }, sessionId: 'session-1', Folded: FoldedText })
fireEvent.click(screen.getByRole('button', { name: 'body:start' }))
expect(value.act).toHaveBeenCalledWith(callId, 'start', { value: 'x' })
})
@@ -97,6 +99,16 @@ it('offers only Not now for a card whose kind registered no body, and reports re
await vi.waitFor(() => { expect(screen.queryByRole('alert')).toBeNull() })
})
it('dismisses a card through the fallback Not now when the Host records the dismissal without the card kind', async () => {
const value = props()
render(<ActionCard {...value} />)
const dismiss = screen.getByRole('button', { name: en.dismiss })
fireEvent.click(dismiss)
expect(value.act).toHaveBeenCalledWith(callId, 'dismiss', undefined)
await vi.waitFor(() => { expect(dismiss.hasAttribute('disabled')).toBe(false) })
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 })} />)
@@ -104,11 +116,6 @@ it.each([['dismissed', en.dismissed], ['replaced', en.replaced], ['finished', un
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.'
@@ -65,7 +65,7 @@ function LocationPicker({ value, disabled, onChange, t }: {
* @param props - card, state, actions, folded-text component and notice preference.
* @returns the card body.
*/
export function PageCardBody({ proposal, decision, ready, summary, Folded, act, infoDismissed, t }: PageCardProps) {
export function PageCardBody({ proposal, decision, summary, Folded, act, infoDismissed, t }: PageCardProps) {
const details = detailsSchema.safeParse(proposal.details)
const parsed = decision === undefined ? undefined : dataSchema.safeParse(decision.data)
const data = parsed?.success === true ? parsed.data : undefined
@@ -142,8 +142,8 @@ export function PageCardBody({ proposal, decision, ready, summary, Folded, act,
</div>}
<footer className={css.footer}>
<div className={css.actions}>
<Button variant="ghost" size="sm" disabled={busy || !ready} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={busy || !ready || location === 'custom' && customLocation.trim() === ''} onClick={() => { void run('create') }}>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={busy || location === 'custom' && customLocation.trim() === ''} onClick={() => { void run('create') }}>
{t(pending === 'create' ? 'starting' : decision === undefined ? 'create' : 'retry')}
</Button>
</div>
@@ -5,7 +5,6 @@ export const en = {
creationMethod: 'Creator mode builds a new page you can remove at any time.',
creationCost: 'Creation may take a while and use a large number of tokens.',
creationBackground: 'Click Create to start in the background. You can keep chatting here.',
expandPlan: 'View full plan', collapsePlan: 'Collapse plan',
initialContent: 'Starts with', expandContent: 'View all content', collapseContent: 'Collapse content',
'sidebar-right': 'Right sidebar', page: 'Session tab', 'sidebar-left': 'Global page', custom: 'Write your own',
'sidebar-rightHint': 'View alongside this conversation',
@@ -17,7 +16,7 @@ export const en = {
create: 'Create', starting: 'Starting…', dismiss: 'Not now', retry: 'Create again', keep: 'Keep', undo: 'Undo',
restore: 'Restore', restoring: 'Restoring…',
scope: 'You can remove it at any time',
dismissed: 'Dismissed', replaced: 'Replaced', suppressed: 'Not offered', creating: 'Creating in the background',
dismissed: 'Dismissed', replaced: 'Replaced', creating: 'Creating in the background',
cancel: 'Cancel creation', cancelling: 'Cancelling…', installing: 'Installing…',
sourceRetained: 'Undo keeps the source files in the original workspace’s .dsh-created-plugins/ folder.',
installed: 'Installed', kept: 'Kept', removed: 'Removed',
@@ -49,7 +48,6 @@ export const zh: Record<RecommendationKey, string> = {
creationMethod: '用创造模式制作新页面,可随时移除',
creationCost: '创建可能耗时较长,并消耗大量 token',
creationBackground: '点击「创建」后在后台运行,你可以继续聊天',
expandPlan: '展开方案', collapsePlan: '收起方案',
initialContent: '预填内容', expandContent: '展开内容', collapseContent: '收起内容',
'sidebar-right': '右侧栏', page: '会话 Tab', 'sidebar-left': '全局页面', custom: '自行撰写',
'sidebar-rightHint': '在当前会话旁查看',
@@ -61,7 +59,7 @@ export const zh: Record<RecommendationKey, string> = {
create: '创建', starting: '开始创建…', dismiss: '暂不需要', retry: '重新创建', keep: '保留', undo: '撤销',
restore: '恢复', restoring: '恢复中…',
scope: '可随时移除',
dismissed: '已忽略', replaced: '已替换', suppressed: '未展示', creating: '正在后台创建',
dismissed: '已忽略', replaced: '已替换', creating: '正在后台创建',
cancel: '取消创建', cancelling: '正在取消…', installing: '正在安装…',
sourceRetained: '撤销后,源码仍保留在原工作区的 .dsh-created-plugins/ 文件夹中',
installed: '已安装', kept: '已保留', removed: '已移除',
@@ -88,7 +86,6 @@ export const ja: Record<RecommendationKey, string> = {
creationMethod: 'クリエイターモードで新しいページを作成します。いつでも削除できます。',
creationCost: '作成には時間がかかり、大量のトークンを消費することがあります。',
creationBackground: '「作成」をクリックするとバックグラウンドで開始します。ここで会話を続けられます。',
expandPlan: 'プランをすべて表示', collapsePlan: 'プランを折りたたむ',
initialContent: '初期内容', expandContent: '内容をすべて表示', collapseContent: '内容を折りたたむ',
'sidebar-right': '右サイドバー', page: 'セッションタブ', 'sidebar-left': 'グローバルページ', custom: '自分で指定',
'sidebar-rightHint': 'この会話の横に表示',
@@ -100,7 +97,7 @@ export const ja: Record<RecommendationKey, string> = {
create: '作成', starting: '開始しています…', dismiss: '今はしない', retry: '再作成', keep: '保持', undo: '元に戻す',
restore: '復元', restoring: '復元中…',
scope: 'いつでも削除できます',
dismissed: '却下済み', replaced: '置き換え済み', suppressed: '非表示', creating: 'バックグラウンドで作成中',
dismissed: '却下済み', replaced: '置き換え済み', creating: 'バックグラウンドで作成中',
cancel: '作成をキャンセル', cancelling: 'キャンセルしています…', installing: 'インストール中…',
sourceRetained: '元に戻しても、ソースファイルは元のワークスペースの .dsh-created-plugins/ フォルダーに残ります。',
installed: 'インストール済み', kept: '保持済み', removed: '削除済み',
@@ -17,7 +17,7 @@ type Act = (verb: string, payload?: ActionCardFields) => Promise<ActionCardResul
function props(decision?: { phase: string; data?: ActionCardFields }, chinese = true, details: ActionCardFields = { slot: 'sidebar-right' }): PageCardProps & { act: ReturnType<typeof vi.fn<Act>> } {
const proposal = { callId, turn: 1, action: ACTION, title: 'Counter', brief: 'Keep my task count visible.', details }
return { proposal, decision: decision === undefined ? undefined : { callId, action: ACTION, ...decision }, ready: true,
return { proposal, decision: decision === undefined ? undefined : { callId, action: ACTION, ...decision },
sessionId: SessionId('session-1'), summary: <><strong>{proposal.title}</strong><p>{proposal.brief}</p></>, Folded: FoldedText,
act: vi.fn<Act>(async () => ({ status: 'accepted' })), infoDismissed: createSnapshotStore(true),
t: makeTranslate(chinese ? zh : en) }
@@ -237,11 +237,6 @@ it('keeps uncertain actions recoverable on refusal and connection loss', async (
}
})
it('waits for the card state before exposing writable actions', () => {
render(<PageCardBody {...{ ...props(), ready: false }} />)
expect(screen.getByRole('button', { name: '创建' }).hasAttribute('disabled')).toBe(true)
})
it('shows supplied initial content as folded data below the plan and omits it otherwise', () => {
const view = render(<PageCardBody {...props()} />)
expect(screen.queryByText('预填内容')).toBeNull()
@@ -29,7 +29,7 @@ it('registers the page body and its copy for the plugin lifetime', async () => {
expect(locale.bind('creationRecommendations')('heading')).toBe('Personalized pages')
const t = locale.bind('creationRecommendations')
render(<descriptor.Body proposal={{ callId: ToolCallId('card'), turn: 1, action: 'create_personalized_page', title: 'Counter', brief: 'Keep count.',
details: { slot: 'page' } }} decision={undefined} ready sessionId={SessionId('session')} summary={null} Folded={FoldedText} act={vi.fn()} t={t} />)
details: { slot: 'page' } }} decision={undefined} sessionId={SessionId('session')} summary={null} Folded={FoldedText} act={vi.fn()} t={t} />)
expect(screen.getByRole('note').textContent).toContain(en.creationCost)
await fiber.dispose()
expect(unregister).toHaveBeenCalledOnce()
@@ -67,7 +67,7 @@ function statusKey(phase: string, data: CardData): RecommendationKey | undefined
* @returns the card body.
*/
export function PluginCardBody({
proposal, decision, ready, sessionId, summary, act, catalog, describe, openDetails, resolveText, t,
proposal, decision, sessionId, summary, act, catalog, describe, openDetails, resolveText, t,
}: PluginCardProps) {
const details = detailsSchema.safeParse(proposal.details)
const name = details.success ? details.data.name : undefined
@@ -125,7 +125,7 @@ export function PluginCardBody({
setError('transport')
} finally { setPending(undefined) }
}
const busy = pending !== undefined || !ready
const busy = pending !== undefined
return <>
<header className={css.header}><span>{t('heading')}</span><span className={css.badge}>{t('official')}</span></header>
<div className={css.body} aria-busy={loading || undefined}>
@@ -36,7 +36,7 @@ it('registers the plugin body, its copy and Remote for the plugin lifetime and s
expect(locale.bind('pluginRecommendations')('heading')).toBe('Recommended plugin')
const proposal = { callId: ToolCallId('card'), turn: 1, action: 'enable_official_plugin', title: 'Search', brief: 'Find old sessions.',
details: { name: 'official-search' } }
const card = () => <descriptor.Body proposal={proposal} decision={undefined} ready sessionId={SessionId('session-one')} summary={null}
const card = () => <descriptor.Body proposal={proposal} decision={undefined} sessionId={SessionId('session-one')} summary={null}
Folded={FoldedText} act={vi.fn()} t={locale.bind('pluginRecommendations')} />
render(card())
await act(async () => {})
@@ -28,7 +28,7 @@ function catalog() {
function props(decision?: { phase: string; data?: ActionCardFields }, details: RecommendationDetails = available, store = catalog()) {
return {
proposal, decision: decision === undefined ? undefined : { callId, action: ACTION, ...decision }, ready: true,
proposal, decision: decision === undefined ? undefined : { callId, action: ACTION, ...decision },
sessionId: SessionId('session-1'), summary: <strong>{proposal.title}</strong>, Folded: FoldedText,
act: vi.fn<Act>(async () => ({ status: 'settled', decision: { callId, action: ACTION, phase: 'enabled' } })),
catalog: store, describe: vi.fn<Describe>(async () => details), openDetails: vi.fn<(name: string) => void>(),
@@ -161,11 +161,6 @@ it('keeps the recorded outcome of an enabled card whose plugin has left the cata
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('keeps actions disabled until the card state loads', async () => {
await shown({ ...props(), ready: false })
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(true)
})
it('holds the description and action lines while the first catalog lookup is pending', async () => {
const loading = props()
const lookup = Promise.withResolvers<RecommendationDetails | undefined>()
@@ -15,14 +15,14 @@
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
/deepseek-ai-dsh-experimental-creation-recommendations/use-this-package:
en: 8a96a28c04322c75
zh: 6354d18381fd5375
en: cf57a8f8347c7aba
zh: f8a2e23006c8c09d
/deepseek-ai-dsh-experimental-creation-recommendations/use-this-package/outcomes-and-recovery:
en: 3c9b20154844a5d1
zh: 292e3ed48d646261
en: 0ab58b8291b06ba8
zh: 25ca08bc167d1e4a
/deepseek-ai-dsh-experimental-creation-recommendations/understand-the-implementation:
en: ae9c8ffae12e9cf9
zh: 86a27ce22307b8fc
en: 825aa7414c0a8d90
zh: 082dd0334a10dca0
/deepseek-ai-dsh-experimental-creation-recommendations/further-exploration:
en: 056d3e9b67fd628e
zh: 71db00e1a971d495
@@ -25,7 +25,7 @@ For an explicit interface request, a clear implied viewing or editing need, or a
<a id="use-this-package"></a>
## Use this package
Open Plugins → More → Experimental plugins and enable Personalized pages. Shipped profiles leave it disabled. Enabling pins it to the main Plugins list; disabling returns it to the secondary page only. The bundle also enables the shared [action card service](../action-cards/README.md) and its browser half; that service's `queuedMessageLimit` sets how many queued next-turn messages stop the current turn's card. An explicit user location takes precedence; otherwise the Agent recommends a location by scope and interaction:
Open Plugins → More → Experimental plugins and enable Personalized pages. Shipped profiles leave it disabled. Enabling pins it to the main Plugins list; disabling returns it to the secondary page only. The bundle also enables the shared [action card service](../action-cards/README.md) and its browser half; that service's `queuedMessageLimit` sets how many queued next-turn messages make the card tool refuse the current turn's card. An explicit user location takes precedence; otherwise the Agent recommends a location by scope and interaction:
| Location | Use |
|:--|:--|
@@ -45,9 +45,9 @@ For multiple independent needs, guidance selects one page proposal for the answe
### Outcomes and recovery
The card appears after the Turn of its accepted call ends. An unstarted proposal stays available across later human messages, including follow-ups about its plan. The next proposal in the same conversation replaces it: the old card shows Replaced without creation controls, and the Host refuses its stale clicks, so one unstarted card exists per conversation. Refreshing, switching views and background notices do not change a card. Already-started work keeps its progress and retry controls. Replacement is derived from the later proposal, not a new persisted decision record. While the user has queued `queuedMessageLimit` or more messages for later turns, a proposal written in the current turn is logged but never offered.
The card appears after the Turn of its accepted call ends. An unstarted proposal stays available across later human messages, including follow-ups about its plan. The next proposal in the same conversation replaces it: the old card shows Replaced without creation controls, and the Host refuses its stale clicks, so one unstarted card exists per conversation. Refreshing, switching views and background notices do not change a card. Already-started work keeps its progress and retry controls. Replacement is derived from the later proposal, not a new persisted decision record. While the user has queued `queuedMessageLimit` or more messages for later turns, the card tool refuses the proposal, so no card is offered.
Creation requires a live root conversation, permission to modify the profile, and a Session sandbox mode other than read-only: the Creator inherits that mode, so a read-only Session refuses Create and leaves the card unstarted. Cancel is available during generation; retry waits for the child’s cleanup. Once installation starts, the card shows Installing and lets the accepted operation finish. Closing or leaving the conversation does not uninstall a completed plugin. Receipts survive reconnects; interrupted or uncertain runs are reconciled with the installed bundle before retry. Failed removal preserves the existing receipt and its application outcome for retry. Unloading this recommendation bundle stops its Creator work and local follow-up. Plugin Manager owns accepted profile changes and may finish after reload; reopening checks the installed profile before retrying. Installed plugins remain. Recovery confirms installation only; it does not infer runtime activation from the profile’s enabled flag.
Creation requires a live root conversation, permission to modify the profile, and a Session sandbox mode other than read-only: the Creator inherits that mode, so a read-only Session refuses Create and leaves the card unstarted. Cancel is available during generation; retry waits for the child’s cleanup. Once installation starts, the card shows Installing and lets the accepted operation finish. Closing or leaving the conversation does not uninstall a completed plugin. Receipts survive reconnects; interrupted or uncertain runs are reconciled with the installed bundle before any card action, including Not now, so an uncertain run whose page was installed shows Installed with Undo instead of being dismissed. Failed removal preserves the existing receipt and its application outcome for retry. Unloading this recommendation bundle stops its Creator work and local follow-up. Plugin Manager owns accepted profile changes and may finish after reload; reopening checks the installed profile before retrying. Installed plugins remain. Recovery confirms installation only; it does not infer runtime activation from the profile’s enabled flag.
Undo uninstalls the plugin and keeps its files. On the removed card, Restore validates the retained profile copy again and reinstalls it without running the Creator; it needs the same live root conversation and profile permission as Create. If that copy is missing or fails validation, Restore is refused and a new proposal is needed.
@@ -63,7 +63,7 @@ The next naturally occurring model request receives the latest unreported status
The Host entry is a function plugin that injects `actionCards`; it provides no service or Remote of its own and adds nothing to `dsh-base`. Disabling the feature removes its skill and card kind and drains its owned Creator work.
The plugin registers the `create_personalized_page` kind with the shared [action card service](../action-cards/README.md): the `personalized-pages` skill, `details` with `slot` and optional `initialContent`, and the verbs Create, Keep, Undo, Cancel and Restore. The service owns the `propose_action` tool, the card projection, Not now, the Remote action and the status notice. This plugin handles the other verbs and records decisions whose data carries the location, any written placement, the reserved package name and the latest installation outcome; the status notice reports all but the package name. Not now is recorded for an unstarted card and for cancelled, failed, interrupted or unconfirmed runs.
The plugin registers the `create_personalized_page` kind with the shared [action card service](../action-cards/README.md): the `personalized-pages` skill, `details` with `slot` and optional `initialContent`, and the verbs Create, Keep, Undo, Cancel and Restore. The service owns the `propose_action` tool, the card projection, Not now, the Remote action and the status notice. This plugin handles the other verbs and records decisions whose data carries the location, any written placement, the reserved package name and the latest installation outcome; the status notice reports all but the package name. The plugin's `reconcile` checks interrupted or uncertain runs against the installed profile before every action; Not now is then recorded for an unstarted card and for cancelled, failed, interrupted or unconfirmed runs.
The card tool carries typed proposals and defines click-to-start semantics; each kind supplies its fields and Skill entry. `personalized-pages` owns eligibility, duplicate checks, minimal scope, placement and content preparation. It allows implementation details needed for the agreed behavior, such as input validation and empty states; independent operations, sources, automation or broader access require a basis in the request or agreed context.
@@ -25,7 +25,7 @@ kind: "package-bundle"
<a id="use-this-package"></a>
## 使用本包
打开插件 → 更多 → 实验性插件,启用个性化页面。内置配置档案默认不启用此插件。启用后在插件一级页显示,关闭后仅保留在二级页。该 Bundle 同时启用共享的[操作卡片服务](../action-cards/README.zh.md)及其浏览器端;该服务的 `queuedMessageLimit` 规定为下一轮排队的消息达到多少条时本轮不再出卡。用户明确指定的位置优先;否则 Agent 根据作用范围和交互方式推荐:
打开插件 → 更多 → 实验性插件,启用个性化页面。内置配置档案默认不启用此插件。启用后在插件一级页显示,关闭后仅保留在二级页。该 Bundle 同时启用共享的[操作卡片服务](../action-cards/README.zh.md)及其浏览器端;该服务的 `queuedMessageLimit` 规定为下一轮排队的消息达到多少条时,卡片工具拒绝本轮的卡片。用户明确指定的位置优先;否则 Agent 根据作用范围和交互方式推荐:
| 位置 | 用途 |
|:--|:--|
@@ -45,9 +45,9 @@ kind: "package-bundle"
### 操作结果与恢复
卡片在被接受的调用所在轮次结束后出现。尚未创建的方案在后续人工消息之后仍然可用,包括对方案的追问。同一会话里的下一个方案会替换它:旧卡片显示“已替换”,没有创建控件,Host 也会拒绝对它的过时点击,因此每个会话只有一张未开始的卡片。刷新、切换视图和后台通知不会改变卡片。已开始的任务保留进度和重试控件。替换由后来的方案推导得出,不是新的持久化决定记录。当用户为后续轮次排队的消息达到 `queuedMessageLimit` 条及以上时,本轮写出的方案只记录、不出卡。
卡片在被接受的调用所在轮次结束后出现。尚未创建的方案在后续人工消息之后仍然可用,包括对方案的追问。同一会话里的下一个方案会替换它:旧卡片显示“已替换”,没有创建控件,Host 也会拒绝对它的过时点击,因此每个会话只有一张未开始的卡片。刷新、切换视图和后台通知不会改变卡片。已开始的任务保留进度和重试控件。替换由后来的方案推导得出,不是新的持久化决定记录。当用户为后续轮次排队的消息达到 `queuedMessageLimit` 条及以上时,卡片工具拒绝该方案,因此不会出卡。
创建要求当前会话是活动的根会话、连接有权修改配置档案,且 Session 的沙箱模式不是只读:Creator 继承该模式,因此只读 Session 拒绝创建,卡片保持未开始。生成阶段可取消,子任务清理完毕后才能重试。安装开始后显示“正在安装”,等待已接纳的操作结束。关闭或离开会话不会卸载已完成的插件。重新连接后会恢复操作记录;重试之前会对照已安装插件检查中断或结果不明的创建。移除失败时保留已有记录和实际生效结果,以便重试。卸载推荐插件会停止它负责的 Creator 任务和本地后续处理。已接纳的配置变更由插件管理器负责,可能在重载后完成;重新打开会话时先检查安装状态再允许重试。已安装插件仍然保留。恢复只确认安装,不根据配置中的开启标记推断运行时已生效。
创建要求当前会话是活动的根会话、连接有权修改配置档案,且 Session 的沙箱模式不是只读:Creator 继承该模式,因此只读 Session 拒绝创建,卡片保持未开始。生成阶段可取消,子任务清理完毕后才能重试。安装开始后显示“正在安装”,等待已接纳的操作结束。关闭或离开会话不会卸载已完成的插件。重新连接后会恢复操作记录;任何卡片操作(包括“暂不需要”)之前,都会对照已安装插件检查中断或结果不明的创建,因此页面其实已安装的不明结果会显示“已安装”和撤销,而不是被忽略。移除失败时保留已有记录和实际生效结果,以便重试。卸载推荐插件会停止它负责的 Creator 任务和本地后续处理。已接纳的配置变更由插件管理器负责,可能在重载后完成;重新打开会话时先检查安装状态再允许重试。已安装插件仍然保留。恢复只确认安装,不根据配置中的开启标记推断运行时已生效。
撤销会卸载插件,但保留其文件。已移除的卡片提供“恢复”:重新校验配置档案中保留的副本并重新安装,不运行 Creator;与创建一样,需要活动的根会话和修改配置档案的权限。副本缺失或校验失败时拒绝恢复,需要重新提案。
@@ -63,7 +63,7 @@ kind: "package-bundle"
Host 入口是注入 `actionCards` 的普通函数插件;它不提供自己的 service 或 Remote,也不向 `dsh-base` 增加任何内容。关闭功能插件时移除它的 Skill 和卡片种类,并清理其负责的 Creator 任务。
插件向共享的[操作卡片服务](../action-cards/README.zh.md)注册 `create_personalized_page` 种类:`personalized-pages` Skill、含 `slot` 和可选 `initialContent` 的 `details`,以及创建、保留、撤销、取消和恢复五个操作。该服务负责 `propose_action` 工具、卡片投影、“暂不需要”、Remote 操作和状态通知。本插件处理其余操作,记录的决定数据包含位置、手写位置、预留包名和最近一次安装结果;状态通知报告其中除包名以外的字段。尚未开始的卡片,以及已取消、失败、中断或结果不明的创建,都可以记录“暂不需要”。
插件向共享的[操作卡片服务](../action-cards/README.zh.md)注册 `create_personalized_page` 种类:`personalized-pages` Skill、含 `slot` 和可选 `initialContent` 的 `details`,以及创建、保留、撤销、取消和恢复五个操作。该服务负责 `propose_action` 工具、卡片投影、“暂不需要”、Remote 操作和状态通知。本插件处理其余操作,记录的决定数据包含位置、手写位置、预留包名和最近一次安装结果;状态通知报告其中除包名以外的字段。每次操作之前,插件的 `reconcile` 会对照已安装的配置档案检查中断或结果不明的创建;之后,尚未开始的卡片,以及已取消、失败、中断或结果不明的创建,都可以记录“暂不需要”。
卡片工具承载类型化提案,定义点击后执行的语义;各种类提供自己的字段和 Skill 入口。`personalized-pages` 负责适用条件、查重、最小范围、选位和内容准备。它允许补齐实现已确认行为所需的细节,例如输入校验和空状态;新增独立操作、数据源、自动任务或访问范围,需要用户请求或已确认上下文的依据。
@@ -78,7 +78,8 @@ function refused(reason: string): ActionCardResult {
/** Creator jobs, profile installation and recovery behind the `create_personalized_page` card. */
class PersonalizedPages {
private readonly jobs = new Map<string, CreationJob>()
private readonly operations = new Set<Promise<ActionCardResult>>()
/** Card reconciliations and actions in flight; unloading waits for them. */
private readonly operations = new Set<Promise<unknown>>()
private readonly stopped = Promise.withResolvers<undefined>()
private closing = false
private readonly owner: Context['fiber']
@@ -104,11 +105,9 @@ class PersonalizedPages {
const placement = custom.success ? { customLocation: custom.data } : {}
return fieldsOf({ slot: slot.success ? slot.data : proposal.details.slot, ...placement })
},
act: (agent, proposal, verb, payload) => {
const operation = this.decide(agent, proposal, verb, payload)
this.operations.add(operation)
return operation.finally(() => { this.operations.delete(operation) })
},
// Every action, including Not now, first confirms interrupted or uncertain runs against the installed profile.
reconcile: agent => this.tracked(this.recover(agent)),
act: (agent, proposal, verb, payload) => this.tracked(this.decide(agent, proposal, verb, payload)),
})
ctx.effect(() => async () => {
this.closing = true
@@ -132,6 +131,12 @@ class PersonalizedPages {
for (const agent of ctx.agents.roots()) await this.recoverOnOpen(agent)
}
/** Track an operation until it settles, so unloading waits for it. */
private tracked<T>(operation: Promise<T>): Promise<T> {
this.operations.add(operation)
return operation.finally(() => { this.operations.delete(operation) })
}
private record(agent: Agent, card: PageCard): void {
this.ctx.actionCards.record(agent, { callId: card.callId, action: ACTION, phase: card.phase, data: fieldsOf(card) })
}
@@ -195,13 +200,8 @@ class PersonalizedPages {
): Promise<ActionCardResult> {
const location = locationSchema.safeParse(payload)
if (verb === 'create' && !location.success) return refused('invalid-location')
try { await this.recover(agent) }
catch (error) {
this.ctx.logger.warn('Cannot confirm creation before card action: %s', String(error))
return refused('unavailable')
}
// The card service hands over only a live root, checked again after reconciliation.
if (this.isStopping()) return refused('closing')
if (!this.ctx.agents.roots().includes(agent)) return refused('not-live-root')
const previous = this.cardOf(agent, proposal.callId)
const key = JSON.stringify([agent.id, proposal.callId])
const job = this.jobs.get(key)
@@ -1,6 +1,6 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { appendPluginRecord, SessionId } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
@@ -298,7 +298,7 @@ describe('interface creation', () => {
const f = await fixture()
await f.propose()
const other = { callId: ToolCallId('other-card'), action: 'other_action', phase: 'creating', data: { slot: 'page', bundleName: '@dsh-local/other' } }
f.ctx.actionCards.record(f.agent, other)
appendPluginRecord(f.agent.session, 'plugin:action-cards/decision', other)
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'installed', data: { slot: 'elsewhere' } })
expect(await f.act('keep')).toEqual({ status: 'refused', reason: 'unavailable' })
f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'installed' })
@@ -328,6 +328,21 @@ describe('interface creation', () => {
expect(f.dispose).toHaveBeenCalledOnce()
})
it('reconciles an unconfirmed card before Not now, so its installed page keeps Undo', async () => {
const f = await fixture({ callId, phase: 'creating', slot: 'page', bundleName: '@dsh-local/view-recover' })
await f.propose()
await f.fiber.dispose()
f.manager.listBundles.mockRejectedValue(new Error('profile read failed'))
await f.ctx.plugin(CreationRecommendations)
expect(f.current()?.phase).toBe('unconfirmed')
expect(await f.act('dismiss')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(f.current()?.phase).toBe('unconfirmed')
f.manager.listBundles.mockResolvedValue([installedBundle('@dsh-local/view-recover')])
expect(await f.act('dismiss')).toEqual(settled('installed', { slot: 'page', bundleName: '@dsh-local/view-recover' }))
expect(await f.act('undo')).toEqual(settled('removed', { slot: 'page', bundleName: '@dsh-local/view-recover', application: 'applied' }))
expect(f.manager.removeBundle).toHaveBeenCalledWith('@dsh-local/view-recover')
})
it('reconciles an interrupted receipt with the installed profile before retrying', async () => {
const f = await fixture()
await f.propose()
@@ -688,14 +703,3 @@ describe('interface creation', () => {
expect(f.current()?.phase).toBe('installing')
})
})
it('never offers a proposal written while the user has queued more messages for later turns', async () => {
const f = await fixture()
const queued = () => createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
f.agent.inbox.append('next-turn', queued())
f.agent.inbox.append('next-turn', queued())
await f.propose()
expect(f.current()).toEqual({ callId, phase: 'suppressed' })
expect(await f.act('create')).toEqual({ status: 'settled', decision: { callId, action: ACTION, phase: 'suppressed' } })
expect(f.startActivation).not.toHaveBeenCalled()
})
@@ -18,8 +18,8 @@
en: 8e26a2013becb72f
zh: 37f5cf1e56588ec2
/deepseek-ai-dsh-experimental-plugin-recommendations/use-this-package/outcomes-and-recovery:
en: 446f9cc1de009cf4
zh: e0a1465d508540c1
en: 46ad015c1d38bb9c
zh: 5f2dce63a2328ea3
/deepseek-ai-dsh-experimental-plugin-recommendations/understand-the-implementation:
en: 1a87a7a2fd2a3b5a
zh: e0a82f0b03219ffb
@@ -31,7 +31,7 @@ The experiment works with or without Personalized pages, and neither package dep
### Outcomes and recovery
The card appears after the Turn of its accepted call ends. Enable requires permission to modify the profile. The card reads the plugin's catalog text when shown; a refused lookup shows its reason, a lost reply says the details could not be loaded, and both offer Not now and Try again but no Enable. The Host rechecks the plugin's catalog eligibility when the user acts. A saved selection can require a restart. A failed change can be retried from the same card, and so can an enable whose result a plugin reload cut off, which the card shows as unconfirmed. A plugin already selected elsewhere settles as already enabled without another change. A later proposal replaces an undecided card, which then shows Replaced; a proposal written while `queuedMessageLimit` or more messages are queued is never offered. A lost response leaves the outcome uncertain until the Session projection reconnects; repeated card actions are idempotent.
The card appears after the Turn of its accepted call ends. Enable requires permission to modify the profile. The card reads the plugin's catalog text when shown; a refused lookup shows its reason, a lost reply says the details could not be loaded, and both offer Not now and Try again but no Enable. The Host rechecks the plugin's catalog eligibility when the user acts. A saved selection can require a restart. A failed change can be retried from the same card, and so can an enable whose result a plugin reload cut off, which the card shows as unconfirmed. A plugin already selected elsewhere settles as already enabled without another change. A later proposal replaces an undecided card, which then shows Replaced; while `queuedMessageLimit` or more messages are queued, the card tool refuses a proposal. A lost response leaves the outcome uncertain until the Session projection reconnects; repeated card actions are idempotent.
-----
@@ -31,7 +31,7 @@ kind: "package-bundle"
### 结果与恢复
被接受的调用所在的轮次结束后卡片才会出现。启用需要修改 profile 的权限。卡片显示时读取插件的目录文本;查询被拒绝时显示原因,回复丢失时说明无法读取插件信息,两种情况都提供「暂不需要」和「重试」,不提供启用。用户操作时 Host 会重新检查插件是否在可推荐目录里。已保存的选择可能需要重启。失败的变更可在同一张卡片上重试;插件重载打断启用、结果未能确认时,卡片显示未确认,同样可以重试。已在别处开启的插件会直接记为已启用,不再改动。之后的新提案会替换尚未处理的卡片,旧卡片显示「已替换」;排队消息达到 `queuedMessageLimit` 时写下的提案不会展示。响应丢失时结果暂不确定,Session 投影重连后会恢复;重复点击卡片是幂等的。
被接受的调用所在的轮次结束后卡片才会出现。启用需要修改 profile 的权限。卡片显示时读取插件的目录文本;查询被拒绝时显示原因,回复丢失时说明无法读取插件信息,两种情况都提供「暂不需要」和「重试」,不提供启用。用户操作时 Host 会重新检查插件是否在可推荐目录里。已保存的选择可能需要重启。失败的变更可在同一张卡片上重试;插件重载打断启用、结果未能确认时,卡片显示未确认,同样可以重试。已在别处开启的插件会直接记为已启用,不再改动。之后的新提案会替换尚未处理的卡片,旧卡片显示「已替换」;排队消息达到 `queuedMessageLimit` 时,卡片工具会拒绝提案。响应丢失时结果暂不确定,Session 投影重连后会恢复;重复点击卡片是幂等的。
-----
@@ -1,5 +1,5 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager/types'
import * as PluginRecommendations from '../src/index.ts'
@@ -164,17 +164,12 @@ describe('official recommendations', () => {
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
it('leaves a replaced card and a card proposed while messages were queued unenabled', async () => {
it('leaves a replaced card unenabled', async () => {
const f = await fixture()
const first = f.propose('call-first')
f.agent.session.append('turn/start', { turn: 2 })
f.propose('call-second')
expect(await f.ctx.actionCards.act(f.agent, first, 'enable')).toEqual({ status: 'settled', decision: { callId: first, action: ACTION, phase: 'replaced' } })
const message = createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
f.agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message, message] })
f.agent.session.append('turn/start', { turn: 3 })
const queued = f.propose('call-queued')
expect(await f.ctx.actionCards.act(f.agent, queued, 'enable')).toEqual({ status: 'settled', decision: { callId: queued, action: ACTION, phase: 'suppressed' } })
expect(f.manager.setBundleEnabled).not.toHaveBeenCalled()
})
@@ -97,6 +97,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'record(agent: Agent, decision: ActionCardDecision): void',
description: 'Record a card decision in the Session log; the next model request reports it.',
parameters: [{ name: 'agent', description: 'agent whose Session owns the card.' }, { name: 'decision', description: 'the card\'s new state.' }],
throws: ['when no kind ever registered the decision\'s action with this service, or its phase is one the projection derives.'],
},
{
signature: 'view(session: Session): ActionCardView',
@@ -106,7 +107,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote act(agent: Agent, callId: ToolCallId, verb: string, payload?: Record<string, JsonValue>): Promise<ActionCardResult>',
description: '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.',
description: 'Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is recorded here and every other verb goes to the card\'s kind. `dismiss` also works on an unstarted card whose kind is no longer registered. Concurrent actions on one card share one operation.',
parameters: [{ name: 'agent', description: 'live root resolved from the displayed Session.' }, { name: 'callId', description: 'the card\'s `propose_action` call id.' }, { name: 'verb', description: '`dismiss` or one of the kind\'s verbs.' }, { name: 'payload', description: 'kind-defined fields such as a chosen location.' }],
returns: 'acceptance of background work, the decision in effect, or a refusal.',
},
@@ -4941,7 +4942,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ActionCardKind',
declaration: 'export interface ActionCardKind<Details> {\n readonly action: string;\n readonly skill: string;\n readonly details: ZodType<Details>;\n readonly verbs: Readonly<Record<string, ActionCardVerb>>;\n readonly openPhases?: readonly string[];\n readonly statusGuidance?: string;\n readonly statusFields?: readonly string[];\n dismissal?(proposal: ActionCardProposalOf<Details>, payload: ActionCardFields | undefined): ActionCardFields | undefined;\n act(agent: Agent, proposal: ActionCardProposalOf<Details>, verb: string, payload: ActionCardFields | undefined): Promise<ActionCardResult>;\n}',
declaration: 'export interface ActionCardKind<Details> {\n readonly action: string;\n readonly skill: string;\n readonly details: ZodType<Details>;\n readonly verbs: Readonly<Record<string, ActionCardVerb>>;\n readonly openPhases?: readonly string[];\n readonly statusGuidance?: string;\n readonly statusFields?: readonly string[];\n dismissal?(proposal: ActionCardProposalOf<Details>, payload: ActionCardFields | undefined): ActionCardFields | undefined;\n reconcile?(agent: Agent, proposal: ActionCardProposalOf<Details>): Promise<void>;\n act(agent: Agent, proposal: ActionCardProposalOf<Details>, verb: string, payload: ActionCardFields | undefined): Promise<ActionCardResult>;\n}',
},
{
name: 'ActionCardProposal',