diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index fa95967793..0cef9a67e4 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -88,6 +88,7 @@ register
(kind: ActionCardKind
): () => 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. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 70d8ee81d4..fe8014ab82 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -88,6 +88,7 @@ register
(kind: ActionCardKind
): () => 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. diff --git a/packages/experimental/action-cards/README.i18n.yaml b/packages/experimental/action-cards/README.i18n.yaml index 5ec708ad8a..97d4fe2455 100644 --- a/packages/experimental/action-cards/README.i18n.yaml +++ b/packages/experimental/action-cards/README.i18n.yaml @@ -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 diff --git a/packages/experimental/action-cards/README.md b/packages/experimental/action-cards/README.md index dee9eff361..17e5596d27 100644 --- a/packages/experimental/action-cards/README.md +++ b/packages/experimental/action-cards/README.md @@ -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)`
Implementation internals — click to expand -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 first, then call propose_action.`, `One card per turn; the earlier card stands.`, `Unknown action "". Registered 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 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 "". Registered 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 - 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`. diff --git a/packages/experimental/action-cards/README.zh.md b/packages/experimental/action-cards/README.zh.md index 7f533491f8..106a984062 100644 --- a/packages/experimental/action-cards/README.zh.md +++ b/packages/experimental/action-cards/README.zh.md @@ -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"
实现细节——点击展开 -本包默认导出 `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 first, then call propose_action.`、`One card per turn; the earlier card stands.`、`Unknown action "". Registered 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 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 "". Registered actions: .`(最后一个种类注销后列表为 `none`),或未通过校验的 `details` 字段。 #### Token 影响 @@ -120,9 +120,11 @@ Latest status of action cards from user actions and background work. These are s - 激活按在线 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` 中修改。 diff --git a/packages/experimental/action-cards/src/index.ts b/packages/experimental/action-cards/src/index.ts index b7102cfd20..7bb30104df 100644 --- a/packages/experimental/action-cards/src/index.ts +++ b/packages/experimental/action-cards/src/index.ts @@ -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
{ * @returns decision data, or undefined to record none. */ dismissal?(proposal: ActionCardProposalOf
, 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
): Promise /** * 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) | undefined readonly act: ( agent: Agent, proposal: ActionCardProposal, verb: string, payload: ActionCardFields | undefined, ) => Promise @@ -142,6 +156,11 @@ export class ActionCards extends TypertRemoteService { static Config = Config private projection!: ProjectionHandle private readonly kinds = new Map() + /** + * 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() /** Skills of registered kinds each live agent has loaded; `propose_action` is declared while one is still registered. */ private readonly loaded = new Map>() /** Kind skills each agent loaded in its latest turn. */ @@ -162,7 +181,7 @@ export class ActionCards extends TypertRemoteService { } protected async [Service.init](): Promise { - 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): Promise { 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 { + 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 { + 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 { + 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)) { diff --git a/packages/experimental/action-cards/src/projection.ts b/packages/experimental/action-cards/src/projection.ts index 72d55c4d9c..3d1be18a97 100644 --- a/packages/experimental/action-cards/src/projection.ts +++ b/packages/experimental/action-cards/src/projection.ts @@ -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 -/** 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, 'wire'> & { readonly wire: NonNullable['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 }, } diff --git a/packages/experimental/action-cards/src/types.ts b/packages/experimental/action-cards/src/types.ts index 5269a18b36..d240f47126 100644 --- a/packages/experimental/action-cards/src/types.ts +++ b/packages/experimental/action-cards/src/types.ts @@ -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 diff --git a/packages/experimental/action-cards/tests/act.spec.ts b/packages/experimental/action-cards/tests/act.spec.ts index e580a38575..6e9eff8d25 100644 --- a/packages/experimental/action-cards/tests/act.spec.ts +++ b/packages/experimental/action-cards/tests/act.spec.ts @@ -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) => { + 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>(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() +}) diff --git a/packages/experimental/action-cards/tests/projection.spec.ts b/packages/experimental/action-cards/tests/projection.spec.ts index 7b6b2611c8..b734b1bfdd 100644 --- a/packages/experimental/action-cards/tests/projection.spec.ts +++ b/packages/experimental/action-cards/tests/projection.spec.ts @@ -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) }) diff --git a/packages/experimental/action-cards/tests/status.spec.ts b/packages/experimental/action-cards/tests/status.spec.ts index 84db0153e7..76a6727a95 100644 --- a/packages/experimental/action-cards/tests/status.spec.ts +++ b/packages/experimental/action-cards/tests/status.spec.ts @@ -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) diff --git a/packages/experimental/action-cards/tests/tool.spec.ts b/packages/experimental/action-cards/tests/tool.spec.ts index 1797da095f..5896015f5f 100644 --- a/packages/experimental/action-cards/tests/tool.spec.ts +++ b/packages/experimental/action-cards/tests/tool.spec.ts @@ -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: {} }) }) diff --git a/packages/experimental/client-ui-action-cards/README.i18n.yaml b/packages/experimental/client-ui-action-cards/README.i18n.yaml index fb65671637..4041a832d8 100644 --- a/packages/experimental/client-ui-action-cards/README.i18n.yaml +++ b/packages/experimental/client-ui-action-cards/README.i18n.yaml @@ -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 diff --git a/packages/experimental/client-ui-action-cards/README.md b/packages/experimental/client-ui-action-cards/README.md index dcc1be1a72..197fc9b7cb 100644 --- a/packages/experimental/client-ui-action-cards/README.md +++ b/packages/experimental/client-ui-action-cards/README.md @@ -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
Implementation internals — click to expand -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 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. diff --git a/packages/experimental/client-ui-action-cards/README.zh.md b/packages/experimental/client-ui-action-cards/README.zh.md index 9aabf5192b..7ba7e626b6 100644 --- a/packages/experimental/client-ui-action-cards/README.zh.md +++ b/packages/experimental/client-ui-action-cards/README.zh.md @@ -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"
实现细节——点击展开 -一个 `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" -- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。 +- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。即使该实验的 Host 插件已关闭,“暂不需要”仍然可用。 - 轮次尾部每轮只显示一张卡片,与 Host 每轮一张卡片的规则一致。 diff --git a/packages/experimental/client-ui-action-cards/src/client/Card.tsx b/packages/experimental/client-ui-action-cards/src/client/Card.tsx index a6cfc4a465..49bc7a982e 100644 --- a/packages/experimental/client-ui-action-cards/src/client/Card.tsx +++ b/packages/experimental/client-ui-action-cards/src/client/Card.tsx @@ -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 : + return proposal === undefined || turn.end === undefined || turn.data.get('turn-tail')?.closing == null + ? null : } /** @@ -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 & 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 - ? - : body.render({ proposal, decision, ready, sessionId, summary, Folded: FoldedText, act: send }, bind(body.locale)) + ? + : body.render({ proposal, decision, sessionId, summary, Folded: FoldedText, act: send }, bind(body.locale)) return
{content}
@@ -121,10 +119,9 @@ export function ActionCard({ proposal, decision, ready, sessionId, act, bind, us const NAMED_PHASES: Readonly> = { 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 } & PropsLocale<'actionCards'>) { const [pending, setPending] = useState(false) @@ -147,7 +144,7 @@ function FallbackBody({ summary, decision, ready, act, t }: { {phase !== undefined &&

{t(phase)}

} {error !== undefined &&

{t(error)}

} {decision === undefined &&
- +
} } diff --git a/packages/experimental/client-ui-action-cards/src/client/registry.ts b/packages/experimental/client-ui-action-cards/src/client/registry.ts index 3634a983fb..fa025f167c 100644 --- a/packages/experimental/client-ui-action-cards/src/client/registry.ts +++ b/packages/experimental/client-ui-action-cards/src/client/registry.ts @@ -20,8 +20,6 @@ export interface ActionCardBodyProps 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') diff --git a/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx index eefb14dc6e..5fa565121c 100644 --- a/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx +++ b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx @@ -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 = 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 => ({ 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 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() 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() - 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() - 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() + 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() @@ -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() - 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.' diff --git a/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx index 246d1d3e2b..ade786148f 100644 --- a/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx +++ b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx @@ -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, }