feat(experimental): restore a removed page from its card

This commit is contained in:
ZiyaZhang committed 2026-10-21 23:38:49 -07:00
1 parent cb1e21f77b
commit ea91bd7c16
17 files changed
+223 -47

No files matched your search

+2 -2
View File
@@ -106,12 +106,12 @@ Internal Remote and effect owner assembled by the personalized-pages plugin.
/** Apply a human decision to a logged proposal.
* @param agent - Live root resolved from the displayed session.
* @param callId - Logged proposal id.
* @param action - Create, cancel generation, dismiss, confirm retention, or remove the owned bundle.
* @param action - Create, cancel generation, dismiss, confirm retention, remove the owned bundle, or reinstall its retained profile copy.
* @param slot - Location chosen by the user when creating or dismissing.
* @param customLocation - Omitted: use slot. Present: Creator resolves this human-authored placement instead.
* @returns acceptance for background work, a durable receipt, or a refusal.
*/
@Remote act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>
@Remote act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel' | 'restore', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>
```
Types: [Agent](core.md) · [ToolCallId](core.md)
+2 -2
View File
@@ -106,12 +106,12 @@ Internal Remote and effect owner assembled by the personalized-pages plugin.
/** Apply a human decision to a logged proposal.
* @param agent - Live root resolved from the displayed session.
* @param callId - Logged proposal id.
* @param action - Create, cancel generation, dismiss, confirm retention, or remove the owned bundle.
* @param action - Create, cancel generation, dismiss, confirm retention, remove the owned bundle, or reinstall its retained profile copy.
* @param slot - Location chosen by the user when creating or dismissing.
* @param customLocation - Omitted: use slot. Present: Creator resolves this human-authored placement instead.
* @returns acceptance for background work, a durable receipt, or a refusal.
*/
@Remote act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>
@Remote act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel' | 'restore', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>
```
Types: [Agent](core.zh.md) · [ToolCallId](core.zh.md)
@@ -9,8 +9,8 @@
en: dc8e9b2fafade59a
zh: fab88019b957e3d9
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/summary:
en: 9758f03518fec752
zh: 03eada1ef25be3fe
en: 4a6eb817f5cb148d
zh: 09b8ce62c9ecf805
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/table-of-contents:
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
@@ -18,8 +18,8 @@
en: 1483f3c6b4dc31ef
zh: 042566fbead0d9a3
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/use-this-package/outcomes-and-recovery:
en: fa3e5923560422d3
zh: df82b4f84b0a7c15
en: d0dbaf664250e229
zh: 91bacedf9a7f8014
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/understand-the-implementation:
en: 3ca6636feb47b6a8
zh: 4968753a28b8da19
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Users see one creation proposal below the completed answer. Short plans appear in full; longer ones show an expandable 80-grapheme preview. A dropdown offers Right sidebar, Session tab, Global page, or Write your own. The cost notice opens until dismissed; this browser remembers the choice across sessions. The information button reopens it. Only Create starts background work; chatting can continue. Progress appears in the header. Keep confirms retention; Undo removes the plugin.
Users see one creation proposal below the completed answer. Short plans appear in full; longer ones show an expandable 80-grapheme preview. A dropdown offers Right sidebar, Session tab, Global page, or Write your own. The cost notice opens until dismissed; this browser remembers the choice across sessions. The information button reopens it. Only Create starts background work; chatting can continue. Progress appears in the header. Keep confirms retention; Undo removes the plugin, and Restore reinstalls it.
## Table of Contents
@@ -31,7 +31,7 @@ Enable the [Personalized pages bundle](../creation-recommendations/README.md). T
An unstarted card stays available until a newer proposal in the same conversation replaces it. The replaced card keeps its history with a Replaced label and no creation or location controls. Supplied initial content appears below the plan as folded data, so the user sees what the Creator will receive. Started tasks retain their progress and retry actions. The Host projection owns this state, so reload does not reactivate a card.
Creation, installation, retention and removal receipts come from the Session projection. The card restores them after reconnecting. A lost action reply shows an actionable error; retry uses the Host’s recorded state. Pending requests disable repeated actions. During generation, Cancel stops the child and waits for cleanup before retry. Accepted installations finish without cancellation. The information disclosure contains the source directory and explains that Undo keeps those files.
Creation, installation, retention and removal receipts come from the Session projection. The card restores them after reconnecting. A lost action reply shows an actionable error; retry uses the Host’s recorded state. Pending requests disable repeated actions. During generation, Cancel stops the child and waits for cleanup before retry. Accepted installations finish without cancellation. The information disclosure contains the source directory and explains that Undo keeps those files. The removed card offers Restore; when the retained files are gone, it asks for a new proposal.
Write your own reveals a placement field, limited to 1,000 characters; blank input cannot start creation. Choosing a preset ignores that custom text. Accepted requests persist it with the receipt for reconnect and retry, and Creator receives it with the full brief. Editing the draft, opening the menu and reading the cost notice make no model request. Dismissing the notice or choosing Create/Not now acknowledges it in browser-local storage; a new browser or cleared/unavailable storage can show it again. Historical settled cards keep the notice collapsed.
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
用户在完整回答下方看到一条创建建议。短方案完整显示,长方案显示前 80 个字素的预览,可展开全文。下拉菜单提供右侧栏、会话 Tab、全局页面和自行撰写四项。消耗提示在用户收起前默认展开,本浏览器跨会话记住选择,信息按钮可重新打开。只有点击创建才启动后台任务,主对话可继续。进度显示在顶部,保留用于确认继续使用,撤销则移除插件。
用户在完整回答下方看到一条创建建议。短方案完整显示,长方案显示前 80 个字素的预览,可展开全文。下拉菜单提供右侧栏、会话 Tab、全局页面和自行撰写四项。消耗提示在用户收起前默认展开,本浏览器跨会话记住选择,信息按钮可重新打开。只有点击创建才启动后台任务,主对话可继续。进度显示在顶部,保留用于确认继续使用,撤销则移除插件,恢复会重新安装它。
## 目录
@@ -31,7 +31,7 @@ kind: "package-reference"
尚未创建的卡片一直可用,直到同一会话里更新的方案替换它。被替换的卡片保留历史内容和“已替换”标签,没有创建和位置控件。提供的预填内容以折叠形式显示在方案下方,用户能看到交给 Creator 的内容。已开始的任务保留进度和重试操作。Host 投影拥有这个状态,所以刷新不会重新激活卡片。
创建、安装、保留和移除记录来自 Session 投影,重新连接后会在卡片中恢复。操作回复丢失时显示可采取行动的错误信息;重试以 Host 已记录的状态为准。请求处理中禁用重复操作。生成阶段可取消,等待子任务清理后才能重试;已接纳的安装不再取消。信息说明中包含源码目录,并明确撤销会保留这些文件。
创建、安装、保留和移除记录来自 Session 投影,重新连接后会在卡片中恢复。操作回复丢失时显示可采取行动的错误信息;重试以 Host 已记录的状态为准。请求处理中禁用重复操作。生成阶段可取消,等待子任务清理后才能重试;已接纳的安装不再取消。信息说明中包含源码目录,并明确撤销会保留这些文件。已移除的卡片提供“恢复”;保留的文件已不在时,提示需要重新提案。
自行撰写会显示位置输入框,最多 1,000 字,空白内容不能启动创建;改选预设位置后不提交自定义文字。已接受的请求将文字存入操作记录,供重连和重试恢复,并连同完整方案交给 Creator。编辑草稿、打开菜单或查看消耗说明都不会调用模型。收起提示或点击创建、暂不需要后,浏览器本地存储记住已读;更换浏览器、清除或无法使用存储时可能再次显示。历史已结束的卡片默认收起提示。
@@ -1,4 +1,4 @@
/** Completed-answer creation card with location selection and undo. */
/** Completed-answer creation card with location selection, undo and restore. */
import { useId, useMemo, useState } from 'react'
import { Button, IconChevronDownOutlineRegular, IconWarningTriangleOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -13,7 +13,7 @@ import css from './Card.module.css'
/** Session-bound user actions; installation paths remain Host-owned. */
export interface CardInjected {
act: (callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel', slot: CreationSlot, customLocation?: string) => Promise<CreationActionResult | undefined>
act: (callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel' | 'restore', slot: CreationSlot, customLocation?: string) => Promise<CreationActionResult | undefined>
hooks: { infoDismissed: ObservableSnapshot<boolean> }
dismissInfo: () => void
}
@@ -91,7 +91,7 @@ export function RecommendationTail({ turn, ...props }: Pick<PropsRuntime<'conver
}
/* jscpd:ignore-end */
/** Display creation, persistent installation, and reversible removal.
/** Display creation, persistent installation, reversible removal and restoration.
* @param props - Proposal, receipts and human actions.
* @returns the creation card.
*/
@@ -188,6 +188,11 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo
{decision.phase !== 'kept' && <Button variant="primary" size="sm" disabled={busy} onClick={() => { void run('keep') }}>{t('keep')}</Button>}
</div>
</footer>}
{decision?.phase === 'removed' && <footer className={css.footer}>
<div className={css.actions}>
<Button variant="primary" size="sm" disabled={busy} onClick={() => { void run('restore') }}>{t(pending === 'restore' ? 'restoring' : 'restore')}</Button>
</div>
</footer>}
</div>
</section>
}
@@ -15,6 +15,7 @@ export const en = {
customLabel: 'Where should it appear?',
customPlaceholder: 'For example, a left sidebar entry that opens a full-page calendar',
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',
cancel: 'Cancel creation', cancelling: 'Cancelling…', installing: 'Installing…',
@@ -35,6 +36,7 @@ export const en = {
'not-live-root': 'Open the original conversation to use this card.',
closing: 'The creation plugin is stopping. Try again after it reloads.',
'read-only': 'This Session is read-only. Switch the permission preset to create pages.',
'source-missing': 'The page files are gone. Ask for a new proposal.',
}
/** Keys shared by both dictionaries. */
@@ -57,6 +59,7 @@ export const zh: Record<RecommendationKey, string> = {
customLabel: '你希望放在哪里?',
customPlaceholder: '例如:在左侧加一个入口,打开全屏日历',
create: '创建', starting: '开始创建…', dismiss: '暂不需要', retry: '重新创建', keep: '保留', undo: '撤销',
restore: '恢复', restoring: '恢复中…',
scope: '可随时移除',
dismissed: '已忽略', replaced: '已替换', suppressed: '未展示', creating: '正在后台创建',
cancel: '取消创建', cancelling: '正在取消…', installing: '正在安装…',
@@ -75,6 +78,7 @@ export const zh: Record<RecommendationKey, string> = {
'not-live-root': '请打开原会话后使用这张卡片',
closing: '创建插件正在停止,请等待重新加载后再试',
'read-only': '当前会话为只读模式,创建页面需要切换权限预设',
'source-missing': '页面文件已不在,需要重新提案',
}
/** Japanese creation and recovery copy. */
@@ -94,6 +98,7 @@ export const ja: Record<RecommendationKey, string> = {
customLabel: 'どこに表示しますか?',
customPlaceholder: '例:左サイドバーに全画面カレンダーを開く項目を追加',
create: '作成', starting: '開始しています…', dismiss: '今はしない', retry: '再作成', keep: '保持', undo: '元に戻す',
restore: '復元', restoring: '復元中…',
scope: 'いつでも削除できます',
dismissed: '却下済み', replaced: '置き換え済み', suppressed: '非表示', creating: 'バックグラウンドで作成中',
cancel: '作成をキャンセル', cancelling: 'キャンセルしています…', installing: 'インストール中…',
@@ -114,4 +119,5 @@ export const ja: Record<RecommendationKey, string> = {
'not-live-root': 'このカードを使うには元の会話を開いてください。',
closing: '作成プラグインが停止中です。再読み込み後に再試行してください。',
'read-only': 'このセッションは読み取り専用です。ページを作成するには権限プリセットを切り替えてください。',
'source-missing': 'ページのファイルが見つかりません。新しい提案を依頼してください。',
}
@@ -149,7 +149,7 @@ it('disables pending decisions, labels only a pending Create as starting, and su
expect(screen.getByRole('button', { name: '创建' }).hasAttribute('disabled')).toBe(false)
})
it.each(['installing', 'dismissed', 'removed', 'replaced'] as const)('restores %s without repeat actions', (phase) => {
it.each(['installing', 'dismissed', 'replaced'] as const)('restores %s without repeat actions', (phase) => {
render(<RecommendationCard {...props({ callId, phase, slot: 'sidebar-left' }, false)} />)
expect(screen.getByRole('status').textContent).toBe(en[phase])
expect(screen.getAllByRole('button')).toEqual([screen.getByRole('button', { name: en.creationInfo })])
@@ -218,6 +218,25 @@ it('retains installed and kept plugins with an Undo action and the actual applic
await waitFor(() => { expect(kept.act).toHaveBeenCalledWith(callId, 'undo', 'page', undefined) })
})
it('offers only Restore on a removed card, labels it while pending and explains missing page files', async () => {
const value = props({ callId, phase: 'removed', slot: 'page', bundleName: '@dsh-local/view-fixture', application: 'applied' })
const pending = Promise.withResolvers<Awaited<ReturnType<Props['act']>>>()
value.act = vi.fn(() => pending.promise)
render(<RecommendationCard {...value} />)
expect(screen.getByRole('status').textContent).toBe(zh.removed)
const restore = screen.getByRole('button', { name: zh.restore })
expect(screen.getAllByRole('button')).toEqual([screen.getByRole('button', { name: zh.creationInfo }), restore])
expect(screen.queryByRole('button', { name: zh.undo })).toBeNull()
expect(screen.queryByRole('button', { name: zh.keep })).toBeNull()
expect(restore.closest('footer')).not.toBeNull()
fireEvent.click(restore)
expect(value.act).toHaveBeenCalledExactlyOnceWith(callId, 'restore', 'page', undefined)
expect(screen.getByRole('button', { name: zh.restoring }).hasAttribute('disabled')).toBe(true)
await act(async () => { pending.resolve({ status: 'refused', reason: 'source-missing' }) })
expect(screen.getByRole('alert').textContent).toBe(zh['source-missing'])
expect(screen.getByRole('button', { name: zh.restore }).hasAttribute('disabled')).toBe(false)
})
it.each([
['installed', 'failed', zh.applicationFailed],
['kept', 'cancelled', zh.applicationCancelled],
@@ -9,8 +9,8 @@
en: f4b9d65da579ef83
zh: 6525d553f7758ac2
/deepseek-ai-dsh-experimental-creation-recommendations/summary:
en: 86e928e7e7fb2d4f
zh: f84cf046bf695bb8
en: 3c8dec4a44f298a4
zh: 6d6bd77206942c76
/deepseek-ai-dsh-experimental-creation-recommendations/table-of-contents:
en: d152484eb41ac6b4
zh: b00dddf4c88f0a45
@@ -18,8 +18,8 @@
en: 0ea638aadccfd463
zh: 55fb6431856f5706
/deepseek-ai-dsh-experimental-creation-recommendations/use-this-package/outcomes-and-recovery:
en: 7f28232d0881d037
zh: 771e1537953fff5b
en: 75cdb583d25d31f5
zh: 897056d3f05e387f
/deepseek-ai-dsh-experimental-creation-recommendations/understand-the-implementation:
en: d0d61f56c8e6e179
zh: 2ed81dd8cf863e27
@@ -42,8 +42,8 @@
en: 23fdd018d8974366
zh: e2a4d7fef8b16215
/deepseek-ai-dsh-experimental-creation-recommendations/known-limitations-and-deferred-work:
en: be404a0f1980ae75
zh: 401be8b31a39ed32
en: 9415033c52000328
zh: 2a325c5ab180bf41
/deepseek-ai-dsh-experimental-creation-recommendations/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
For an explicit interface request, a clear implied viewing or editing need, or a result with concrete continuing use, the Agent answers first and may offer one optional creation card at the completed Turn’s end. Explicit requests can serve a single task. The user can change the suggested location, then choose Create. A background Creator builds a new plugin and installs it into the profile. Keep confirms retention; Undo removes that plugin.
For an explicit interface request, a clear implied viewing or editing need, or a result with concrete continuing use, the Agent answers first and may offer one optional creation card at the completed Turn’s end. Explicit requests can serve a single task. The user can change the suggested location, then choose Create. A background Creator builds a new plugin and installs it into the profile. Keep confirms retention; Undo removes that plugin, and Restore reinstalls it.
## Table of Contents
@@ -49,6 +49,8 @@ The card appears after the Turn whose answer carries the block ends. An unstarte
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.
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.
The next naturally occurring model request receives the latest unreported status for each changed card. Status updates never wake the conversation or authorize another action. The Session projection acknowledges delivery only after the status context is logged, so cancellation, reload and reopen retain undelivered updates. Several background transitions before a request collapse to the latest receipt.
-----
@@ -109,7 +111,7 @@ Skill contents and proposal metadata append to the transcript; loading the skill
<a id="known-limitations-and-deferred-work"></a>
- Only new plugins are supported; editing existing plugins and restoring old versions are deferred.
- Only new plugins are supported; editing existing plugins is deferred. Restore reinstalls only the profile copy named by the card’s receipt; there is no version history.
- Intent recognition depends on model semantics. Guidance does not enforce tool choice or factual grounding: the Agent can still inspect files unnecessarily or infer capabilities absent from the catalog. Generated code runs with the existing Creator permissions; package validation is not a code sandbox.
- Source directories and profile copies remain after success, failure, cancellation and Undo. Undo removes the installed plugin and its UI, not these files. The card identifies `.dsh-created-plugins/` in the original workspace as the source location. Historical receipts do not continuously track manual plugin changes.
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
用户明确提出界面需求、隐含表达清晰的查看或编辑需求,或任务结果有具体的后续用途时,Agent 先回答,再视情况在本轮末尾提供一张可选创建卡片。明确提出的界面可以只服务一次任务。用户可修改建议位置,再选择创建。后台 Creator 生成一个新插件并安装到配置档案;保留用于确认继续使用,撤销则移除该插件。
用户明确提出界面需求、隐含表达清晰的查看或编辑需求,或任务结果有具体的后续用途时,Agent 先回答,再视情况在本轮末尾提供一张可选创建卡片。明确提出的界面可以只服务一次任务。用户可修改建议位置,再选择创建。后台 Creator 生成一个新插件并安装到配置档案;保留用于确认继续使用,撤销则移除该插件,恢复会重新安装它。
## 目录
@@ -49,6 +49,8 @@ kind: "package-bundle"
创建要求当前会话是活动的根会话、连接有权修改配置档案,且 Session 的沙箱模式不是只读:Creator 继承该模式,因此只读 Session 拒绝创建,卡片保持未开始。生成阶段可取消,子任务清理完毕后才能重试。安装开始后显示“正在安装”,等待已接纳的操作结束。关闭或离开会话不会卸载已完成的插件。重新连接后会恢复操作记录;重试之前会对照已安装插件检查中断或结果不明的创建。移除失败时保留已有记录和实际生效结果,以便重试。卸载推荐插件会停止它负责的 Creator 任务和本地后续处理。已接纳的配置变更由插件管理器负责,可能在重载后完成;重新打开会话时先检查安装状态再允许重试。已安装插件仍然保留。恢复只确认安装,不根据配置中的开启标记推断运行时已生效。
撤销会卸载插件,但保留其文件。已移除的卡片提供“恢复”:重新校验配置档案中保留的副本并重新安装,不运行 Creator;与创建一样,需要活动的根会话和修改配置档案的权限。副本缺失或校验失败时拒绝恢复,需要重新提案。
下一次自然发起的模型请求会收到各张已变化卡片尚未告知模型的最新状态。状态更新不会唤醒对话,也不授权其他动作。Session 投影只在状态上下文写入日志后确认送达,因此取消、重载和重新打开会话都保留未送达的更新。一次请求之前发生的多次后台变化合并为最新记录。
-----
@@ -109,7 +111,7 @@ Skill 内容和建议元数据追加到对话;加载 Skill 不改写公共 sys
<a id="known-limitations-and-deferred-work"></a>
- 只支持新增插件;修改已有插件和恢复旧版本留待后续。
- 只支持新增插件;修改已有插件留待后续。恢复只重新安装卡片记录对应的配置档案副本,不提供版本历史。
- 意图识别依赖模型语义。指引不强制工具选择或事实依据:Agent 仍可能多余地查看文件,或推断目录没有说明的能力。生成代码使用现有 Creator 权限执行,包校验不构成代码沙箱。
- 成功、失败、取消及撤销后均保留工作区源目录和配置档案副本。撤销移除已安装插件及其界面,不删除这些文件。卡片会说明源码位于原工作区的 `.dsh-created-plugins/` 目录。历史记录不会持续跟踪手动插件变更。
@@ -37,6 +37,9 @@ declare module '@deepseek-ai/cordis' {
interface Context { creationRecommendations: CreationRecommendations }
}
/** Generated page packages take this name prefix; the remainder names their copy in the profile's `created-plugins` directory. */
const RESERVED_PREFIX = '@dsh-local/view-'
interface CreationJob {
readonly parent: Agent
readonly childId: SessionId
@@ -87,6 +90,7 @@ export class CreationRecommendations extends TypertRemoteService {
content: [{ type: 'text', text: 'Latest personalized page status from card actions and background creation. '
+ 'These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. '
+ 'Cancelled means a started background creation was stopped before installation. Cancelled, failed or interrupted runs can be retried from their existing card; no new proposal is needed. '
+ 'A removed page can be restored from its card while its files remain; if they are gone, a new proposal is needed. '
+ 'Continue with the user’s current request.\n' + JSON.stringify(updates) }],
})] }
}, { prepend: true })
@@ -116,14 +120,14 @@ export class CreationRecommendations extends TypertRemoteService {
/** Apply a human decision to a logged proposal.
* @param agent - Live root resolved from the displayed session.
* @param callId - Logged proposal id.
* @param action - Create, cancel generation, dismiss, confirm retention, or remove the owned bundle.
* @param action - Create, cancel generation, dismiss, confirm retention, remove the owned bundle, or reinstall its retained profile copy.
* @param slot - Location chosen by the user when creating or dismissing.
* @param customLocation - Omitted: use slot. Present: Creator resolves this human-authored placement instead.
* @returns acceptance for background work, a durable receipt, or a refusal.
*/
@Remote
act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult> {
if ((action === 'create' || action === 'undo') && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve({ status: 'refused', reason: 'access-denied' })
act(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel' | 'restore', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult> {
if ((action === 'create' || action === 'undo' || action === 'restore') && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve({ status: 'refused', reason: 'access-denied' })
if (this.isStopping()) return Promise.resolve({ status: 'refused', reason: 'closing' })
if (!this.ctx.agents.roots().includes(agent)) return Promise.resolve({ status: 'refused', reason: 'not-live-root' })
const custom = customLocationSchema.optional().safeParse(customLocation)
@@ -192,7 +196,7 @@ export class CreationRecommendations extends TypertRemoteService {
}
}
private async decide(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel', slot: CreationSlot, key: string, customLocation?: string): Promise<CreationActionResult> {
private async decide(agent: Agent, callId: ToolCallId, action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel' | 'restore', slot: CreationSlot, key: string, customLocation?: string): Promise<CreationActionResult> {
try { await this.recover(agent) }
catch (error) {
this.ctx.logger.warn('Cannot confirm creation before card action: %s', String(error))
@@ -229,6 +233,7 @@ export class CreationRecommendations extends TypertRemoteService {
this.record(agent, decision)
return { status: 'settled', decision }
}
if (previous?.phase === 'removed' && action === 'restore') return this.restore(agent, previous)
if (previous?.phase === 'dismissed' || previous?.phase === 'removed' || previous?.phase === 'replaced' || previous?.phase === 'suppressed'
|| action === 'cancel' && previous !== undefined) return { status: 'settled', decision: previous }
if (action === 'dismiss') {
@@ -244,7 +249,7 @@ export class CreationRecommendations extends TypertRemoteService {
const directory = join(this.ctx.profileContext.dir, 'created-plugins', id)
// Spawn preserves the parent's workspace-write boundary even with a different cwd.
const sourceDirectory = join(agent.session.header.cwd ?? this.ctx.profileContext.cwd, '.dsh-created-plugins', id)
const bundleName = '@dsh-local/view-' + id
const bundleName = RESERVED_PREFIX + id
const decision: CreationDecision = { callId, phase: 'creating', slot, bundleName,
...(customLocation === undefined ? {} : { customLocation }) }
this.record(agent, decision)
@@ -317,16 +322,7 @@ export class CreationRecommendations extends TypertRemoteService {
await cp(sourceDirectory, directory, { recursive: true, force: false, errorOnExist: true, verbatimSymlinks: true })
await validateGeneratedBundle(directory, bundleName)
signal.throwIfAborted()
this.record(agent, { ...decision, phase: 'installing' })
// PluginManager owns the accepted installation, which can itself reload us.
// Stop local follow-up on unload and reconcile its outcome when reopened.
const result = await Promise.race([this.ctx.pluginManager.installBundle(directory), this.stopped.promise])
if (result === undefined || this.isStopping()) return
const installed = (await this.ctx.pluginManager.listBundles()).some(bundle => bundle.name === bundleName && bundle.installed)
const current = this.ctx.agents.roots().find(root => root.id === agent.id)
if (current !== undefined) this.record(current, {
...decision, phase: installed ? 'installed' : 'failed', application: result.application,
})
await this.installProfileCopy(agent, decision, directory)
} catch (error) {
this.ctx.logger.warn('Interface creation failed: %s', String(error))
const current = this.ctx.agents.roots().find(root => root.id === agent.id)
@@ -334,6 +330,54 @@ export class CreationRecommendations extends TypertRemoteService {
if (current !== undefined) this.record(current, { ...decision, phase })
}
}
/** Reinstall the retained profile copy of a removed page without running the Creator. */
private async restore(agent: Agent, previous: CreationDecision): Promise<CreationActionResult> {
if (previous.bundleName === undefined) return { status: 'refused', reason: 'unavailable' }
const directory = join(this.ctx.profileContext.dir, 'created-plugins', previous.bundleName.slice(RESERVED_PREFIX.length))
try { await validateGeneratedBundle(directory, previous.bundleName) }
catch (error) {
this.ctx.logger.warn('Cannot restore %s from its profile copy: %s', previous.bundleName, String(error))
return { status: 'refused', reason: 'source-missing' }
}
const installed = (await this.ctx.pluginManager.listBundles()).some(bundle => bundle.name === previous.bundleName && bundle.installed)
if (this.isStopping()) return { status: 'refused', reason: 'closing' }
// The removal outcome does not describe the reinstallation.
const { application: _application, ...receipt } = previous
if (installed) {
const decision: CreationDecision = { ...receipt, phase: 'installed' }
this.record(agent, decision)
return { status: 'settled', decision }
}
let outcome: CreationDecision | undefined
try { outcome = await this.installProfileCopy(agent, receipt, directory) }
catch (error) {
this.ctx.logger.warn('Restoring %s failed: %s', previous.bundleName, String(error))
// While unloading, Plugin Manager still owns the installation; reopening reconciles its outcome.
const current = this.isStopping() ? undefined : this.ctx.agents.roots().find(root => root.id === agent.id)
if (current === undefined) return { status: 'unconfirmed', reason: 'closing' }
outcome = { ...receipt, phase: 'failed' }
this.record(current, outcome)
}
return outcome === undefined ? { status: 'unconfirmed', reason: 'closing' } : { status: 'settled', decision: outcome }
}
/** Install a validated profile copy, then record the outcome on the Session's current root.
* @returns the recorded outcome; undefined when unloading released the follow-up or the root closed, which reopening reconciles.
*/
private async installProfileCopy(agent: Agent, decision: CreationDecision, directory: string): Promise<CreationDecision | undefined> {
this.record(agent, { ...decision, phase: 'installing' })
// PluginManager owns the accepted installation, which can itself reload us.
// Stop local follow-up on unload and reconcile its outcome when reopened.
const result = await Promise.race([this.ctx.pluginManager.installBundle(directory), this.stopped.promise])
if (result === undefined || this.isStopping()) return undefined
const installed = (await this.ctx.pluginManager.listBundles()).some(bundle => bundle.name === decision.bundleName && bundle.installed)
const current = this.ctx.agents.roots().find(root => root.id === agent.id)
if (current === undefined) return undefined
const outcome: CreationDecision = { ...decision, phase: installed ? 'installed' : 'failed', application: result.application }
this.record(current, outcome)
return outcome
}
}
/**
@@ -44,7 +44,7 @@ export interface CreationView {
export type CreationActionResult =
| { readonly status: 'accepted' }
| { readonly status: 'settled'; readonly decision: CreationCardState }
| { readonly status: 'refused'; readonly reason: 'unknown' | 'access-denied' | 'not-live-root' | 'closing' | 'unavailable' | 'invalid-location' | 'read-only' }
| { readonly status: 'refused'; readonly reason: 'unknown' | 'access-denied' | 'not-live-root' | 'closing' | 'unavailable' | 'invalid-location' | 'read-only' | 'source-missing' }
| { readonly status: 'unconfirmed'; readonly reason: 'closing' }
declare module '@deepseek-ai/dsh-llm' {
@@ -3,7 +3,7 @@
exports[`reports the latest card status on the next request without waking the conversation > cancelled page status 1`] = `
[
{
"text": "Latest personalized page status from card actions and background creation. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Cancelled means a started background creation was stopped before installation. Cancelled, failed or interrupted runs can be retried from their existing card; no new proposal is needed. Continue with the user’s current request.
"text": "Latest personalized page status from card actions and background creation. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Cancelled means a started background creation was stopped before installation. Cancelled, failed or interrupted runs can be retried from their existing card; no new proposal is needed. A removed page can be restored from its card while its files remain; if they are gone, a new proposal is needed. Continue with the user’s current request.
[{"callId":"block:creation-1","title":"Counter","phase":"cancelled","slot":"page"}]",
"type": "text",
},
@@ -585,6 +585,104 @@ describe('interface creation', () => {
expect(f.current()?.phase).toBe('installed')
})
it('restores an undone page from its retained profile copy without running the Creator again', async () => {
const f = await fixture()
await f.propose()
await f.ctx.creationRecommendations.act(f.agent, callId, 'create', 'page')
await f.complete()
const bundleName = f.current()!.bundleName!
const directory = join(f.directory, 'created-plugins', bundleName.slice('@dsh-local/view-'.length))
expect(f.manager.installBundle).toHaveBeenCalledExactlyOnceWith(directory)
await f.ctx.creationRecommendations.act(f.agent, callId, 'undo', 'page')
expect(f.current()).toMatchObject({ phase: 'removed', application: 'applied' })
const access = vi.spyOn(f.ctx.remoteAccess, 'allows').mockReturnValue(false)
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'refused', reason: 'access-denied' })
access.mockRestore()
let installing: unknown
f.manager.installBundle.mockImplementationOnce(async (path: string) => {
installing = f.current()
f.bundles.push(installedBundle(bundleName))
return { application: 'applied', changed: true, stage: 'install', target: path }
})
const decision = { callId, phase: 'installed', slot: 'page', bundleName, application: 'applied' }
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'settled', decision })
expect(installing).toEqual({ callId, phase: 'installing', slot: 'page', bundleName })
expect(f.manager.installBundle).toHaveBeenLastCalledWith(directory)
expect(f.current()).toEqual(decision)
expect(f.startActivation).toHaveBeenCalledOnce()
})
it('refuses Restore without its profile copy or package name and records nothing', async () => {
const f = await fixture()
await f.propose()
await f.ctx.creationRecommendations.act(f.agent, callId, 'create', 'page')
await f.complete()
await f.ctx.creationRecommendations.act(f.agent, callId, 'undo', 'page')
await rm(f.manager.installBundle.mock.calls[0]![0], { recursive: true })
const logged = f.events.length
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'refused', reason: 'source-missing' })
expect(f.events).toHaveLength(logged)
expect(f.current()?.phase).toBe('removed')
appendPluginRecord(f.agent.session, 'plugin:creation-recommendations/decision', { callId, phase: 'removed', slot: 'page' })
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'refused', reason: 'unavailable' })
expect(f.manager.installBundle).toHaveBeenCalledOnce()
})
it('records a failed receipt instead of leaving Restore installing when the reinstallation throws', async () => {
const f = await fixture()
await f.propose()
await f.ctx.creationRecommendations.act(f.agent, callId, 'create', 'page')
await f.complete()
const bundleName = f.current()!.bundleName!
await f.ctx.creationRecommendations.act(f.agent, callId, 'undo', 'page')
f.manager.installBundle.mockRejectedValueOnce(new Error('profile locked'))
const decision = { callId, phase: 'failed', slot: 'page', bundleName }
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'settled', decision })
expect(f.current()).toEqual(decision)
expect(f.startActivation).toHaveBeenCalledOnce()
})
it('settles Restore on a page that is not removed and confirms one reinstalled elsewhere without installing it', async () => {
const f = await fixture()
await f.propose()
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'refused', reason: 'unavailable' })
await f.ctx.creationRecommendations.act(f.agent, callId, 'create', 'page')
await f.complete()
const installed = f.current()!
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'settled', decision: installed })
await f.ctx.creationRecommendations.act(f.agent, callId, 'undo', 'page')
f.bundles.push(installedBundle(installed.bundleName!))
const decision = { callId, phase: 'installed', slot: 'page', bundleName: installed.bundleName }
expect(await f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')).toEqual({ status: 'settled', decision })
expect(f.current()).toEqual(decision)
expect(f.manager.installBundle).toHaveBeenCalledOnce()
})
it.each(['lookup', 'install', 'outcome lookup'] as const)('releases Restore when the plugin unloads during %s', async (stage) => {
const f = await fixture()
await f.propose()
await f.ctx.creationRecommendations.act(f.agent, callId, 'create', 'page')
await f.complete()
await f.ctx.creationRecommendations.act(f.agent, callId, 'undo', 'page')
const entered = Promise.withResolvers<undefined>()
const lookup = Promise.withResolvers<ReturnType<typeof installedBundle>[]>()
const installing = Promise.withResolvers<ChangeResult>()
onTestFinished(() => { lookup.resolve(f.bundles); installing.resolve({ application: 'applied', changed: true, stage: 'install', target: 'fixture' }) })
const blocked = () => { entered.resolve(undefined); return lookup.promise }
if (stage === 'lookup') f.manager.listBundles.mockImplementationOnce(blocked)
else if (stage === 'install') f.manager.installBundle.mockImplementationOnce(() => { entered.resolve(undefined); return installing.promise })
else f.manager.listBundles.mockImplementationOnce(async () => f.bundles).mockImplementationOnce(blocked)
const restoring = f.ctx.creationRecommendations.act(f.agent, callId, 'restore', 'page')
await entered.promise
const disposing = f.fiber.dispose()
if (stage === 'outcome lookup') lookup.reject(new Error('profile read failed'))
else lookup.resolve(f.bundles)
await disposing
expect(await restoring).toEqual({ status: stage === 'lookup' ? 'refused' : 'unconfirmed', reason: 'closing' })
expect(f.current()?.phase).toBe(stage === 'lookup' ? 'removed' : 'installing')
expect(f.manager.installBundle).toHaveBeenCalledTimes(stage === 'lookup' ? 1 : 2)
})
it('does not publish an install result returned in the same callback that unloads its owner', async () => {
const f = await fixture()
await f.propose()
@@ -844,9 +844,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
description: 'Internal Remote and effect owner assembled by the personalized-pages plugin.',
methods: [
{
signature: '@Remote act(agent: Agent, callId: ToolCallId, action: \'create\' | \'dismiss\' | \'keep\' | \'undo\' | \'cancel\', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>',
signature: '@Remote act(agent: Agent, callId: ToolCallId, action: \'create\' | \'dismiss\' | \'keep\' | \'undo\' | \'cancel\' | \'restore\', slot: CreationSlot, customLocation?: string): Promise<CreationActionResult>',
description: 'Apply a human decision to a logged proposal.',
parameters: [{ name: 'agent', description: 'Live root resolved from the displayed session.' }, { name: 'callId', description: 'Logged proposal id.' }, { name: 'action', description: 'Create, cancel generation, dismiss, confirm retention, or remove the owned bundle.' }, { name: 'slot', description: 'Location chosen by the user when creating or dismissing.' }, { name: 'customLocation', description: 'Omitted: use slot. Present: Creator resolves this human-authored placement instead.' }],
parameters: [{ name: 'agent', description: 'Live root resolved from the displayed session.' }, { name: 'callId', description: 'Logged proposal id.' }, { name: 'action', description: 'Create, cancel generation, dismiss, confirm retention, remove the owned bundle, or reinstall its retained profile copy.' }, { name: 'slot', description: 'Location chosen by the user when creating or dismissing.' }, { name: 'customLocation', description: 'Omitted: use slot. Present: Creator resolves this human-authored placement instead.' }],
returns: 'acceptance for background work, a durable receipt, or a refusal.',
},
],
@@ -5472,7 +5472,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreationActionResult',
declaration: 'export type CreationActionResult = {\n readonly status: \'accepted\';\n} | {\n readonly status: \'settled\';\n readonly decision: CreationCardState;\n} | {\n readonly status: \'refused\';\n readonly reason: \'unknown\' | \'access-denied\' | \'not-live-root\' | \'closing\' | \'unavailable\' | \'invalid-location\' | \'read-only\';\n} | {\n readonly status: \'unconfirmed\';\n readonly reason: \'closing\';\n};',
declaration: 'export type CreationActionResult = {\n readonly status: \'accepted\';\n} | {\n readonly status: \'settled\';\n readonly decision: CreationCardState;\n} | {\n readonly status: \'refused\';\n readonly reason: \'unknown\' | \'access-denied\' | \'not-live-root\' | \'closing\' | \'unavailable\' | \'invalid-location\' | \'read-only\' | \'source-missing\';\n} | {\n readonly status: \'unconfirmed\';\n readonly reason: \'closing\';\n};',
},
{
name: 'CreationCardState',
@@ -28,7 +28,7 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"What is the counter status now?"}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"source":{"kind":"personalized-pages","through":23,"form":"notice","summary":"Personalized page status updated"},"content":[{"type":"text","text":"Latest personalized page status from card actions and background creation. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Cancelled means a started background creation was stopped before installation. Cancelled, failed or interrupted runs can be retried from their existing card; no new proposal is needed. Continue with the user’s current request.\n[{\"callId\":\"{{id:1}}\",\"title\":\"Counter\",\"phase\":\"dismissed\",\"slot\":\"page\"}]"}],"role":"user","id":"{{message:10}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"source":{"kind":"personalized-pages","through":23,"form":"notice","summary":"Personalized page status updated"},"content":[{"type":"text","text":"Latest personalized page status from card actions and background creation. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Cancelled means a started background creation was stopped before installation. Cancelled, failed or interrupted runs can be retried from their existing card; no new proposal is needed. A removed page can be restored from its card while its files remain; if they are gone, a new proposal is needed. Continue with the user’s current request.\n[{\"callId\":\"{{id:1}}\",\"title\":\"Counter\",\"phase\":\"dismissed\",\"slot\":\"page\"}]"}],"role":"user","id":"{{message:10}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The counter proposal was dismissed. Creation never started."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"stream":[{"type":"chunk","time":1792505539296,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792505539297,"index":0,"dt":[],"texts":["The counter proposal was dismissed. Creation never started."]},{"type":"chunk","time":1792505539297,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The counter proposal was dismissed. Creation never started."}}},{"type":"chunk","time":1792505539297,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}