From f0410d592d1b32f810437cd82d93b96a2581ae71 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:39:00 +0800 Subject: [PATCH 01/23] feat(web): permission presets and approval answering for the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web host now composes the sandboxed product path (sandbox-local + sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and permission on top); BootHostOptions.sandbox carries the deployment defaults (workspace-write + ask). createApiProxy owns the approval pending registry: a ctx.approval ask becomes an answerable approval/requested mux frame with a stable rpcId, replayed verbatim on every mux open until settled; respond routes by the echoed rpcId, validates the ApprovalResponsePayload audit correlation, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled. session.permissions / session.setPermission project ctx.permission into a protocol-owned PermissionOption select; idle switches are held last-write-wins and flushed into the next prompted turn (the ACP bridge's anchoring pattern). The shared hasOpenTurn fold moved to dsh-session, deduplicating the private copies in user-approval, the ACP bridge, and the proxy. Client, per the designer draft: a pending approval takes over the composer (ApprovalPanel replaces the InputBar — amber strip, justification headline, paired command, one-shot refuse/allow, keyed by rpcId so a queued second approval remounts live; the resolved frame restores the composer); the sidebar session row shows an amber waiting-approval dot that outranks the running ring (manager-tracked approvalId set, idempotent under mux-open replays, cleared per connection generation, lit for uninstantiated sessions too); the permission selector is a composer bottom-row chip over an invisible native select, with a presentation-only title-case transform (workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The connection fixture mirrors the host behavior for keyless browser acceptance. --- ...7-23-web-permission-and-approval.i18n.yaml | 6 + .../2026-07-23-web-permission-and-approval.md | 33 +++ ...26-07-23-web-permission-and-approval.zh.md | 33 +++ apps/web/tests/smoke-fixture.e2e.ts | 38 ++- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 12 +- docs/persistence-catalog.md | 6 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 62 +++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 8 + .../client/connection/tests/fixture.spec.ts | 57 ++++ packages/client/runtime/src/client/index.ts | 10 +- .../runtime/src/client/sessions/lineage.ts | 9 +- .../runtime/src/client/sessions/manager.ts | 31 +- .../runtime/src/client/sessions/service.ts | 3 + .../runtime/src/client/sessions/session.ts | 25 ++ packages/client/runtime/tests/fake-api.ts | 9 + packages/client/runtime/tests/manager.spec.ts | 39 +++ packages/client/runtime/tests/session.spec.ts | 24 ++ packages/client/ui-conversation/README.md | 5 +- .../ui-conversation/src/client/apply.ts | 29 +- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/chat/PendingCard.module.css | 18 +- .../src/client/chat/PendingCard.tsx | 23 +- .../src/client/contract/slots.ts | 68 ++++- .../client/skeleton/ApprovalPanel.module.css | 107 +++++++ .../src/client/skeleton/ApprovalPanel.tsx | 71 +++++ .../src/client/skeleton/ConversationRoot.tsx | 4 +- .../src/client/skeleton/InputBar.tsx | 7 +- .../skeleton/PermissionSelect.module.css | 49 ++++ .../src/client/skeleton/PermissionSelect.tsx | 88 ++++++ .../tests/apply-inject.spec.tsx | 30 ++ .../tests/chat-branch-tails.spec.tsx | 6 +- .../tests/chat-stats-bash-sample.spec.tsx | 6 +- .../tests/chat-toolview-slot.spec.tsx | 9 +- .../ui-conversation/tests/chat-view.spec.tsx | 13 +- .../tests/skeleton-branches.spec.tsx | 4 + .../ui-conversation/tests/skeleton.spec.tsx | 2 + packages/client/ui-sidebar/README.md | 2 +- .../client/ui-sidebar/src/client/Rows.tsx | 6 +- packages/client/ui-sidebar/src/client/tree.ts | 3 + .../client/ui-sidebar/tests/apply.spec.tsx | 2 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 15 + packages/client/ui-sidebar/tests/tree.spec.ts | 2 + .../client/ui-trajectory/tests/views.spec.tsx | 2 + packages/core/session/src/index.ts | 19 ++ packages/core/session/tests/session.spec.ts | 12 + packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 31 +- packages/host/apiproxy/src/api/sessions.ts | 35 +++ packages/host/apiproxy/src/fetch/client.ts | 8 + packages/host/apiproxy/src/fetch/handler.ts | 4 + .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 10 +- packages/host/runtime/README.md | 8 +- packages/host/runtime/package.json | 9 +- packages/host/runtime/src/api-proxy.ts | 195 ++++++++++++- packages/host/runtime/src/boot.ts | 41 ++- .../runtime/tests/api-proxy-approval.spec.ts | 275 ++++++++++++++++++ .../tests/api-proxy-permission.spec.ts | 152 ++++++++++ packages/host/runtime/tsconfig.json | 19 +- packages/ui/acp/src/index.ts | 15 +- packages/ui/user-approval/src/index.ts | 17 +- pnpm-lock.yaml | 25 +- 69 files changed, 1744 insertions(+), 139 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md create mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx create mode 100644 packages/host/runtime/tests/api-proxy-approval.spec.ts create mode 100644 packages/host/runtime/tests/api-proxy-permission.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml new file mode 100644 index 0000000000..4d686731fe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 +2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md new file mode 100644 index 0000000000..cd402a039e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI permission presets and approval answering + +Status: implemented + +English | [中文](2026-07-23-web-permission-and-approval.zh.md) + +## Problem + +The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/requested`/`approval/resolved` mux frames, `POST /api/respond` with `ApprovalResponsePayload`, client-side `pendingBuffers` — but the host `respond` was a stub, no answerer bridged `ctx.approval` to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable. + +## Decision + +The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`). + +`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. + +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. + +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. + +## Alternatives considered + +**Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. + +**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. + +**Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. + +**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead. + +## Consequences + +Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md new file mode 100644 index 0000000000..ce4964789b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI 权限预设与审批应答 + +Status: implemented + +[English](2026-07-23-web-permission-and-approval.md) | 中文 + +## 问题 + +Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。 + +## 决策 + +Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。 + +`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 + +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 + +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 + +## 曾考虑的替代方案 + +**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 + +**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 + +**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 + +**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。 + +## 后果 + +Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0726d14c8b..423e42f14d 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -280,9 +280,45 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter') await composer.waitFor({ state: 'detached' }) + // The composer does not fall back to the InputBar yet: fx-alpha's resident + // approval is still pending, so the approval takeover elects next (the + // approval scenario below answers it and watches the InputBar return). + await page.locator('[data-approval-key]').waitFor({ state: 'visible', timeout: 10_000 }) + }) + + it('answers the composer-takeover approval panel; the InputBar returns on the resolved frame', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-approval-answer')) + // fx-alpha carries the resident pending approval; the session is already + // selected by the previous scenario. With the question settled, the + // approval owns the composer slot — no textarea is mounted. + const panel = page.locator('[data-approval-key]') + await panel.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await panel.getByText('等待审批').count()).toBe(1) + expect(await page.locator('textarea').count()).toBe(0) + // Sidebar mirrors the blocked state: the session row shows the amber + // warning dot in place of the running ring. + expect(await page.locator('[role="treeitem"] [data-state="warning"]').count()).toBeGreaterThan(0) + await panel.getByRole('button', { name: '允许一次' }).click() + // The broadcast resolved frame removes the panel and restores the + // composer; the sidebar dot clears with it. + await panel.waitFor({ state: 'detached', timeout: 5000 }) const restoredInput = page.locator('textarea[placeholder]') - await restoredInput.waitFor() + await restoredInput.waitFor({ state: 'visible', timeout: 5000 }) expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') + await expect.poll(() => page.locator('[role="treeitem"] [data-state="warning"]').count(), { timeout: 5000 }).toBe(0) + }) + + it('switches the permission preset through the composer select', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-permission-select')) + const select = page.locator('select').filter({ hasText: 'Workspace Write' }).first() + await select.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await select.inputValue()).toBe('workspace-write') + await select.selectOption('danger-full-access') + // The control disables during the round trip and adopts the confirmed + // value; host-side persistence across re-reads is covered by the fixture + // unit suite (fixture.spec.ts permissions/setPermission). + await expect.poll(() => select.inputValue(), { timeout: 5000 }).toBe('danger-full-access') + await expect.poll(() => select.isDisabled(), { timeout: 5000 }).toBe(false) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..75b9f56eed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1719,7 +1719,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:183`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..1602ee6b1e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -420,7 +420,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [ApprovalService](../core-data-structures/approval.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/ui/user-approval/src/index.ts:30`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) ## `commands/*` @@ -572,7 +572,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:98`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -593,7 +593,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:108`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -616,7 +616,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:120`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -637,7 +637,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:130`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5858799e7c..ca7ab31b9d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -246,7 +246,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:198`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -1246,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:624`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..291bae5122 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), `runtime` | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | @@ -24,17 +24,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp), `runtime` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:98`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:108`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:120`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:130`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8c1d815bc0..7a1d5ecfb6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -106,7 +106,7 @@ Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -122,7 +122,7 @@ Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approv } ``` -Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -138,7 +138,7 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c7e1ed5c68..033d801243 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7585ca9fba..5ca929eb2d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -299,10 +299,19 @@ export function createFixtureApi(): ApiProxy { let nextSession = 1 let nextRpc = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ + /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ const pendingApprovalRpcId = mint() + const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] + /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ + let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true + /** Per-session permission preset (fixture mirror of the host permission select). */ + const permissionValues = new Map() + const PERMISSION_OPTIONS = [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ] const fixtureQuestions: Extract['questions'] = [ { id: 'harness-profile', @@ -522,6 +531,24 @@ export function createFixtureApi(): ApiProxy { } return ok(request, { accepted: true as const }) }, + permissions: (request) => { + const { sessionId: id } = request.payload + if (summaryOf(id) === undefined) { + return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' }) + }, + setPermission: (request) => { + const { sessionId: id, value } = request.payload + if (summaryOf(id) === undefined) { + return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + if (!PERMISSION_OPTIONS.some(option => option.value === value)) { + return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) + } + permissionValues.set(id, value) + return ok(request, { currentValue: value }) + }, }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), @@ -539,14 +566,16 @@ export function createFixtureApi(): ApiProxy { const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? []) if (title !== undefined) conn.push({ rpcId: mint(), payload: title }) } - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract['approvalId'], - toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)', - }, - }) + if (approvalPending) { + conn.push({ + rpcId: pendingApprovalRpcId, + payload: { + type: 'approval/requested', sessionId: sid('fx-alpha'), + approvalId: pendingApprovalId, + toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + } if (questionPending) { conn.push({ rpcId: pendingQuestionRpcId, @@ -584,6 +613,19 @@ export function createFixtureApi(): ApiProxy { }, }, respond(message: ClientResponse): Promise { + // Same routing discipline as the host: rpcId first, then the payload's + // audit correlation; a settled or unknown id is not-pending. + if (message.rpcId === pendingApprovalRpcId) { + if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const value = message.result.value as { approvalId?: unknown; outcome?: unknown } + if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approvalPending = false + emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) + return Promise.resolve({ accepted: true }) + } if (!questionPending || message.rpcId !== pendingQuestionRpcId) { return Promise.resolve({ accepted: false, reason: 'not-pending' }) } @@ -633,6 +675,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) + case 'session.permissions': return this.api.sessions.permissions(request) + case 'session.setPermission': return this.api.sessions.setPermission(request) case 'host.describe': return this.api.host.describe(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..fa2ddf9bdc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,7 +15,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..0bdef3432d 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -49,6 +49,12 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPermissions: (payload: unknown) => + Promise> = + () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) + + onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = + payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -65,6 +71,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), + setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..187869e42d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -255,6 +255,61 @@ describe('createFixtureApi', () => { })).toEqual({ accepted: true }) }) + it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + const api = createFixtureApi() + // Discover the resident approval's stable rpcId from the mux baseline. + const abort = new AbortController() + const seen: { rpcId: string; frame: MuxFrame }[] = [] + const consuming = (async () => { + for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) + })() + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) + }) + const requested = seen.find(s => s.frame.type === 'approval/requested') + if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') + const approvalId = requested.frame.approvalId + + // Routed but malformed answers. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // The real answer settles the question and broadcasts resolved. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) + .toEqual({ accepted: true }) + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) + }) + // Settled: a duplicate answer is late, and a fresh mux open replays nothing. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + await consuming + const abort2 = new AbortController() + const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) + expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + }) + + it('permissions/setPermission mirror the host select: read, switch, validation', async () => { + const api = createFixtureApi() + const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) + expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } }) + const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' })) + expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) + const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) + expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) + // Validation: ghost session and unknown value. + const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') })) + expect(ghostRead.result.ok).toBe(false) + const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' })) + expect(ghostSwitch.result.ok).toBe(false) + const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' })) + expect(unknown.result.ok).toBe(false) + }) + it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -345,6 +400,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) + expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true) + expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 33097d4573..65f378684d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -38,7 +38,15 @@ export type { // PendingWait is a value export: tests construct fixture waits directly. export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' -export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** The permission select material as the object layer serves it to UI plugins. */ +export interface PermissionSelect { + /** Switchable presets plus (when derived) the current-only `custom`. */ + options: { value: string; name: string; description?: string }[] + /** The effective current value (`custom` when knobs match no preset). */ + currentValue: string +} // ---- Narrowed aliases (the single narrowing point of the slot type chain: // ui-slots/web-react stay generic and dependency-inverted; the client-tree diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index c6bd572ea7..fb6a85704b 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary { title?: string } -/** One flattened session-list row (summary + lineage indent depth). */ +/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -17,6 +17,8 @@ export interface SessionListEntry { running: boolean parentSessionId?: SessionId cwd?: string + /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ + waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -25,9 +27,10 @@ export interface SessionListEntry { * summaries -> flat list with lineage indentation (pure; roots by updatedAt * desc, DFS children in the same order, orphans degrade to roots). * @param summaries - the host's session.list items. + * @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { +export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess return } visited.add(s.sessionId) - out.push({ ...s, depth }) + out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) const kids = children.get(s.sessionId) if (kids === undefined) return kids.sort(byUpdatedDesc) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b65935a97d..f13600f037 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -36,6 +36,11 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open + * replays of the same requested frame). Manager-owned rather than read off Session instances + * because the sidebar must light up for sessions never instantiated. Cleared per connection + * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ + private readonly waitingApprovals = new Map>() private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' @@ -186,6 +191,22 @@ export class SessionManager { this.notifier.markDirty() } } + // List-level waiting-approval bit (the sidebar amber dot): tracked here for + // every session, instantiated or not; approvalId keys make replays idempotent. + if (frame.type === 'approval/requested') { + let ids = this.waitingApprovals.get(frame.sessionId) + if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) + if (!ids.has(frame.approvalId)) { + ids.add(frame.approvalId) + this.notifier.markDirty() + } + } else if (frame.type === 'approval/resolved') { + const ids = this.waitingApprovals.get(frame.sessionId) + if (ids !== undefined && ids.delete(frame.approvalId)) { + if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) + this.notifier.markDirty() + } + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question frames never hit history: buffer for replay on instantiation; @@ -232,6 +253,7 @@ export class SessionManager { this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone this.titleSnapshots.delete(frame.sessionId) this.notifier.markDirty() return @@ -254,6 +276,12 @@ export class SessionManager { /** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */ handleConnected(): void { + // Approvals resolved while disconnected send no frame: drop the bits and + // let the mux-open replay re-add every still-pending question. + if (this.waitingApprovals.size > 0) { + this.waitingApprovals.clear() + this.notifier.markDirty() + } void this.refreshList() for (const session of this.sessions.values()) void session.resync() } @@ -265,13 +293,14 @@ export class SessionManager { ? summary : { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) } }) - const fresh = flattenLineage(merged) + const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.title === entry.title && prev.depth === entry.depth + && prev.waitingApproval === entry.waitingApproval ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index d8a6f05762..daf81d102f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -32,6 +32,8 @@ export interface SessionSummary { cwd?: string parentId?: SessionId running: boolean + /** An approval question is pending on this session (sidebar amber-dot state). */ + waitingApproval: boolean updatedAt: number } @@ -310,6 +312,7 @@ export class SessionsService { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + waitingApproval: entry.waitingApproval, updatedAt: entry.updatedAt, ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6b773e0903..741716f44a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -128,6 +128,31 @@ export class Session implements ObservableSnapshot { return result } + /** + * Read the permission select (options + effective current value). + * @returns the select material, or the error branch on failure. + */ + async permissions(): Promise> { + try { + return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result + } catch (error) { + return transportError(error) + } + } + + /** + * Switch the permission preset. + * @param value - a preset value advertised by {@link Session.permissions} (never `custom`). + * @returns the confirmed current value, or the error branch on failure. + */ + async setPermission(value: string): Promise> { + try { + return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result + } catch (error) { + return transportError(error) + } + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..5ec6f4737f 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -52,6 +52,13 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPermissions: (payload: unknown) => + Promise> = + () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) + + onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = + payload => Promise.resolve(ok({ currentValue: payload.value })) + onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -68,6 +75,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), + setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index d37ef6dbce..64c01247e2 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -281,3 +281,42 @@ describe('connected generation', () => { }) }) }) + +describe('waiting-approval list bit', () => { + it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + // Mux-open replay of the same question (same approvalId) is idempotent. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + }) + + it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + // Removed sessions drop their bit outright. + manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(manager.getListSnapshot().items).toHaveLength(0) + }) + + it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleConnected() // resolved-while-disconnected questions send no frame + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..8678096856 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -669,3 +669,27 @@ describe('reference stability (the memo contract)', () => { expect(resolved.pending).toBe(after.pending) }) }) + +describe('permissions / setPermission', () => { + it('passes the select read and switch through with the session id', async () => { + const { api, session } = makeSession() + api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' })) + const read = await session.permissions() + expect(read.ok).toBe(true) + if (read.ok) expect(read.value.currentValue).toBe('workspace-write') + expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }]) + + const switched = await session.setPermission('danger-full-access') + expect(switched.ok).toBe(true) + if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access') + expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }]) + }) + + it('folds transport failures into the error branch', async () => { + const { api, session } = makeSession() + api.onPermissions = () => Promise.reject(new Error('down')) + api.onSetPermission = () => Promise.reject(new Error('down')) + expect((await session.permissions()).ok).toBe(false) + expect((await session.setPermission('x')).ok).toBe(false) + }) +}) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ebd7474298..75967f46a4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state: the session row's amber warning dot (a manager-tracked `waitingApproval` list bit, lit for uninstantiated sessions too) outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. The composer's bottom-row chip mounts the permission-preset select (`PermissionSelect`), fed by the injected `permissions`/`setPermission` callbacks over the object layer's session RPCs; empty options (a permission-less host) hide the control, and the derived `custom` value renders as current-only. + Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). @@ -26,4 +28,5 @@ None; this package neither assembles nor sends a provider request. - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **The permission select reads once per mount** — a host-side preset change from another client surfaces only after a session re-select; live knob-event-driven refresh is deferred. +- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..f17e32d1ee 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,12 +15,13 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { - ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected, + ApprovalWait, ChatViewInjected, ComposerChainProps, ConversationInjected, DetailsInjected, EmptyStateInjected, } from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' @@ -37,6 +38,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat return conversation } +/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */ +function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null { + return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null +} + /** * Client plugin body. * @param ctx - client root context. @@ -104,10 +110,31 @@ export function apply(ctx: Context): void { }) }, open: (target: SessionId) => { sessions.open(target) }, + permissions: async () => { + const result = await sessions.manager.get(sessionId).permissions() + // Empty options = permission-less host composition: hide the control + // rather than show an empty select (deployment shape, not an error). + if (!result.ok || result.value.options.length === 0) return null + return result.value + }, + setPermission: async (value) => { + const result = await sessions.manager.get(sessionId).setPermission(value) + return result.ok ? result.value.currentValue : null + }, } }, }, ConversationRoot) + // The approval takeover: a selector-routed entry of the chain this package + // just declared (the ui-question registration pattern; the entry lives here + // because approval answering is core conversation UX, not an optional tool). + // Zero business face — data and verbs both ride the matched carrier. + // priority 1: question takeovers (default 0) win when both kinds are + // pending — a question is a conversation the model is waiting on, while an + // approval only blocks one tool call; answering the question first cannot + // strand the approval (it re-elects the moment the question resolves). + slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel) + // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the // only component authorized to render per-tool rows. Shares the chat diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8023acddde..23f822ee19 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -254,7 +254,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl ))} )} - {pending.map((item) => )} + {/* Approvals take over the composer (ApprovalPanel); only question + placeholders remain in the flow. */} + {pending.filter((item) => item.kind === 'question').map((item) => )} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.module.css b/packages/client/ui-conversation/src/client/chat/PendingCard.module.css index 2318c88c94..cb17b4387c 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.module.css +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.module.css @@ -1,4 +1,4 @@ -/* Amber pending strip (approval waiting = warn semantic, figma state colors). */ +/* Amber pending strip (question waiting = warn semantic, figma state colors). */ .card { margin: 6px 0; @@ -13,19 +13,3 @@ font-weight: 500; color: var(--dsw-alias-label-primary); } - -.mono { - font-family: var(--ds-font-family-code); -} - -.reason { - margin-top: 4px; - font-size: 12px; - color: var(--dsw-alias-label-secondary); -} - -.hint { - margin-top: 6px; - font-size: 11px; - color: var(--dsw-alias-label-tertiary); -} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index b6825aed9a..c5a5ead161 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,6 +1,7 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: question pending placeholder in the message flow (visible +// while the question composer owns the takeover slot elsewhere). Approvals +// do not render here: they take over the composer (skeleton ApprovalPanel) +// per the designer draft. import { memo } from 'react' import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' @@ -8,24 +9,14 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: Extract } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
- {item.kind === 'approval' ? ( - <> -
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
} - - ) : ( - <> -
等待回答({item.payload.questions.length} 题)
- - - )} -
请在原客户端处理(web 端作答后续里程碑提供)
+
等待回答({item.payload.questions.length} 题)
+
) }) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index ffbc13ff59..fa8a493135 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,7 +11,7 @@ * here. */ import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, PendingWait, PermissionSelect, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -113,6 +113,10 @@ export interface ConversationInjected { stop(): void /** Navigate to another session (breadcrumb ancestors). */ open(id: SessionId): void + /** Read the permission select (options + effective current value); null hides the control. */ + permissions(): Promise + /** Switch the permission preset; resolves the confirmed value, or null on failure (caller keeps the old value). */ + setPermission(value: string): Promise } /** @@ -132,6 +136,68 @@ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'> & PropsStore & ConversationInjected +/** The pending approval carrier the owner dispatches into the composer chain. */ +export type ApprovalWait = PendingWait<'approval'> + +/** + * Approval domain face over the carrier (the ui-question PendingQuestion + * pattern): render identity and question material forwarded transparently; + * answer owns the wire encoding — the ApprovalResponsePayload value shape + * with the audit correlation the host reconciles — and turns a rejected + * carrier receipt into a thrown error. Minted per carrier via useMemo. + */ +export class PendingApproval { + /** + * @param wait - the runtime carrier for one pending approval question. + */ + constructor(private readonly wait: ApprovalWait) {} + + /** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */ + get key(): string { + return this.wait.key + } + + /** The tool the question is about (headline fallback), forwarded from the carrier payload. */ + get toolName(): string { + return this.wait.payload.toolName + } + + /** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */ + get reason(): string | undefined { + return this.wait.payload.reason + } + + /** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */ + get callId(): string | undefined { + return this.wait.payload.callId + } + + /** + * Deliver the user's decision; a rejected carrier receipt throws. Panel + * removal stays frame-driven: the broadcast `approval/resolved` settles the + * wait and drops it from the pending list. + * @param outcome - the only two client-answerable outcomes. + */ + async answer(outcome: 'allowed-once' | 'rejected'): Promise { + const receipt = await this.wait.respond({ + ok: true, + value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome }, + }) + if (!receipt.accepted) { + throw new Error(`approval response rejected: ${receipt.reason}`) + } + } +} + +/** + * Full approval-composer props: the framework runtime share (chain currency + + * session/global standard kit) plus the chain `matched` share — the entry's + * selector result, already narrowed to the approval carrier. No injected + * share: the carrier plus the domain face above carry the whole behavior + * surface; the paired command line derives from useSession in-component. + */ +export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } + /** * Injected share of the chat view entry: the two callbacks whose targets live * outside the view (layout orchestration; the session object layer). diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css new file mode 100644 index 0000000000..c3d8ef47bd --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -0,0 +1,107 @@ +/* Composer-takeover approval panel (draft approval.png): the same floating + capsule footprint as the InputBar card, with an amber header band, the + justification headline, a muted command line, and right-aligned actions. + Warn semantics ride the alias state tokens; no hardcoded colors. */ + +/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */ +.root { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 32px 12px; +} + +.card { + overflow: hidden; + width: 100%; + max-width: 776px; + border: 1px solid var(--dsw-alias-state-warn-secondary); + border-radius: 20px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv2); +} + +/* Tinted full-width header band. */ +.strip { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-primary); + font-size: 13px; + line-height: 18px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-primary); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 16px 14px; +} + +/* The model's justification is the panel's message, not a footnote. */ +.headline { + color: var(--dsw-alias-label-primary); + font-size: 15px; + font-weight: 500; + line-height: 24px; +} + +.command { + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + word-break: break-all; +} + +.actionRow { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.allow, +.reject { + padding: 6px 16px; + border-radius: 10px; + font-size: 13px; + line-height: 18px; + cursor: pointer; +} + +.allow:disabled, +.reject:disabled { + opacity: 0.5; + cursor: default; +} + +/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped + always-allow button). */ +.allow { + border: none; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-primary-foreground); +} + +/* Secondary: quiet outline. */ +.reject { + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + background: transparent; + color: var(--dsw-alias-label-secondary); +} + +.reject:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + border-color: transparent; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx new file mode 100644 index 0000000000..7b8d52a6ec --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -0,0 +1,71 @@ +// ApprovalPanel: the composer-takeover approval prompt (designer draft +// approval.png), registered as a selector-routed entry of the +// conversation-declared composer chain. While an approval question is +// pending, this panel occupies the composer slot in place of the InputBar: +// an amber "Waiting for approval" strip on the card top, the model's +// justification as the headline, the paired command in muted code text, and +// a right-aligned refuse/allow action row. One-shot: the buttons disable +// after a click and the panel leaves (the InputBar returns) on the broadcast +// resolved frame. The draft's "Always allow this type" is deferred with +// grant storage. + +import { useMemo, useState } from 'react' +import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ +export function commandOf(call: RunningToolCall | undefined): string | undefined { + if (call === undefined) return undefined + try { + const args = JSON.parse(call.argsRaw) as Record + return typeof args.command === 'string' ? args.command : undefined + } catch { + // Unparseable model args: the panel still renders, just without the command line. + return undefined + } +} + +/** + * Composer takeover boundary: mints the domain face on the carrier's stable + * identity and remounts the flow per request key, so the one-shot answered + * latch never leaks to the next pending approval. + * @param props - the selector-matched pending approval carrier plus the framework standard kit. + * @returns The approval prompt for this request. + */ +export function ApprovalPanel(props: ApprovalComposerProps) { + const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) + const command = props.useSession(s => commandOf( + approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) + return +} + +function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) { + // Local one-shot latch: the panel leaves only when the resolved frame + // lands; until then the buttons must not re-fire. An answer failure + // (rejected receipt / transport) re-arms them for retry. + const [answered, setAnswered] = useState(false) + const answer = (outcome: 'allowed-once' | 'rejected'): void => { + setAnswered(true) + void pending.answer(outcome).catch(() => { setAnswered(false) }) + } + return ( +
+
+
等待审批
+
+
{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}
+ {command !== undefined &&
{command}
} +
+ + +
+
+
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..d904c73b68 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { PermissionSelect } from './PermissionSelect.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -38,7 +39,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, - views, send, stop, open, + views, send, stop, open, permissions, setPermission, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -68,6 +69,7 @@ export function ConversationRoot({ disabled={removed} error={error} variant="composer" + controls={} onDraftChange={actions.setDraft} onSend={(mode) => { send(draft, mode) }} onStop={stop} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 04d1dd867d..551ce70cac 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -30,6 +30,8 @@ export interface InputBarProps { placeholder?: string /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode + /** Host-wired access-mode control (the composer mounts the permission chip here); replaces the visual-only placeholder. */ + controls?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void @@ -56,7 +58,7 @@ const MODEL_OPTIONS: readonly SelectOption[] = [ ] export function InputBar({ - draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, + draft, running, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop, }: InputBarProps) { const empty = draft.trim() === '' const inputRef = useRef(null) @@ -177,7 +179,8 @@ export function InputBar({
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} + {/* The wired permission chip supersedes the visual-only Access placeholder. */} + {controls ?? renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css new file mode 100644 index 0000000000..dd5986992c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -0,0 +1,49 @@ +/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a + quiet text chip with a chevron; hover paints the standard interactive pill. + The native select is stretched invisibly over the chip so the platform + dropdown does the menu work — keyboard/AT semantics come free. */ + +.root { + position: relative; + display: inline-flex; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + pointer-events: none; /* the overlaid select owns the interaction */ +} + +.root:hover .chip { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + color: var(--dsw-alias-label-caption); +} + +/* Invisible native select stretched over the chip: real menu, zero drawing. */ +.select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + cursor: pointer; +} + +.select:disabled { + cursor: default; +} + +.root:has(.select:disabled) .chip { + opacity: 0.5; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx new file mode 100644 index 0000000000..a32e2af037 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -0,0 +1,88 @@ +// PermissionSelect: the composer bottom-row permission chip (draft +// start.jpeg's `Read-only ∨` control). Options and the current value load on +// mount from the injected permissions() callback; empty options +// (permission-less host composition) render nothing. The visible chip is +// presentation only — an invisible native select stretched over it owns the +// menu and interaction. A switch disables the control until the host +// confirms, then adopts the confirmed value (`custom` is shown as the current +// value but never offered as a target — the host already omits it from +// switchable options; a stale-select failure restores the previous value). + +import { useEffect, useRef, useState } from 'react' +import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/client' +import css from './PermissionSelect.module.css' + +/** + * Display transform: kebab-case machine names render as title-case labels + * (`workspace-write` → `Workspace Write`). Presentation-only — the wire + * vocabulary and the host's advertised names are untouched; a host-configured + * name that is not kebab-case (contains spaces or uppercase) passes through. + */ +function displayName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} + +export interface PermissionSelectProps { + /** Read the select material; null hides the control. */ + permissions: () => Promise + /** Switch the preset; resolves the confirmed value, or null on failure. */ + setPermission: (value: string) => Promise +} + +export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) { + const [data, setData] = useState(null) + const [switching, setSwitching] = useState(false) + // Unmount guard: the load/switch promises outlive a session switch's remount. + const aliveRef = useRef(true) + useEffect(() => { + aliveRef.current = true + void permissions().then((loaded) => { + if (aliveRef.current) setData(loaded) + }) + return () => { + aliveRef.current = false + } + }, [permissions]) + + if (data === null) return null + + const onChange = (value: string): void => { + if (value === data.currentValue) return + setSwitching(true) + const previous = data + setData({ ...data, currentValue: value }) + void setPermission(value).then((confirmed) => { + if (!aliveRef.current) return + setSwitching(false) + if (confirmed === null) setData(previous) + else setData({ ...previous, currentValue: confirmed }) + }) + } + + const current = data.options.find(option => option.value === data.currentValue) + + return ( + + ) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index edd9f7d54d..aa06d6853e 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -61,6 +61,10 @@ async function bench() { () => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( () => Promise.resolve({ ok: true, value: { accepted: true } })), + permissions: vi.fn<() => Promise<{ ok: boolean; value?: { options: { value: string; name: string }[]; currentValue: string }; error?: { code: string; message: string } }>>( + () => Promise.resolve({ ok: true, value: { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } })), + setPermission: vi.fn<() => Promise<{ ok: boolean; value?: { currentValue: string }; error?: { code: string; message: string } }>>( + () => Promise.resolve({ ok: true, value: { currentValue: 'danger-full-access' } })), } const scopes = new Map() const mint = (id: SessionId): Context => { @@ -143,6 +147,32 @@ describe('conversation slot inject surface', () => { expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) }) + it('permissions/setPermission thread the object layer; empty options and failures fold to null', async () => { + const b = await bench() + const { injected } = b.conversationSurface(ROOT) + expect(await injected.permissions()).toMatchObject({ currentValue: 'workspace-write' }) + expect(await injected.setPermission('danger-full-access')).toBe('danger-full-access') + // Empty options (permission-less host) and the error branch both hide the control. + b.sessionFake.permissions.mockResolvedValueOnce({ ok: true, value: { options: [], currentValue: 'custom' } }) + expect(await injected.permissions()).toBeNull() + b.sessionFake.permissions.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } }) + expect(await injected.permissions()).toBeNull() + b.sessionFake.setPermission.mockResolvedValueOnce({ ok: false, error: { code: 'bad-request', message: 'x' } }) + expect(await injected.setPermission('nope')).toBeNull() + }) + + it('registers the approval takeover on the composer chain: routing selector, no inject face', async () => { + const b = await bench() + const entry = b.slots.entries('conversation.composer')[0]! + expect(entry.inject).toBeUndefined() + // The selector narrows the chain currency: approval wait in → that wait; none → null. + const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown + const approval = { kind: 'approval' } + expect(select({ interactions: [{ kind: 'question' }, approval] })).toBe(approval) + expect(select({ interactions: [{ kind: 'question' }] })).toBeNull() + expect(select({ interactions: [] })).toBeNull() + }) + it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => { const b = await bench() const { instance, injected } = b.conversationSurface(ROOT) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..95565add35 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -44,11 +44,11 @@ describe('MessageItem arms', () => { }) describe('small branch tails', () => { - it('PendingCard approval reason renders when present', () => { + it('PendingCard renders the question count', () => { const view = render( - ['payload'], vi.fn())} />, + ['payload'], vi.fn())} />, ) - expect(view.getByText('careful')).toBeTruthy() + expect(view.getByText(/等待回答(1 题)/)).toBeTruthy() }) it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 4d3383b2d1..383bc92883 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -123,8 +123,8 @@ describe('bash sample row', () => { return createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 }, - [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 }, + [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, updatedAt: 0 }, + [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, updatedAt: 0 }, }, current: undefined, } as SessionListState) @@ -158,7 +158,7 @@ describe('bash sample row', () => { const orphan = 'late-child' as SessionId store.update((d) => { d.ids.push(orphan) - d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 } + d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, updatedAt: 0 } }) const view = render() expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 5d2b3408a2..2fa9224a95 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -75,7 +75,14 @@ async function bench(nodes: ToolResultNode[]) { const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('sessions', { list, - manager: { get: () => ({ loadOlder: vi.fn() }) }, + manager: { + get: () => ({ + loadOlder: vi.fn(), + // The mounted PermissionSelect loads on mount; empty options hide the control. + permissions: vi.fn(() => Promise.resolve({ ok: true, value: { options: [], currentValue: 'custom' } })), + setPermission: vi.fn(() => Promise.resolve({ ok: false, error: { code: 'bad-request', message: 'unused', details: { issues: [] } } })), + }), + }, scope: () => ({ get: () => scoped }), cell: (id: string) => (id === SID ? cell : undefined), create: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3f1db55199..314080f9be 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -342,12 +342,17 @@ describe('ChatView', () => { expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('pending interactions render placeholder cards', () => { + it('question waits render placeholder cards; approvals leave the flow (composer takeover)', () => { const h = makeHarness({ - pending: [new PendingWait('approval', RpcId('r1'), SID, - { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())], + pending: [ + new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), + new PendingWait('question', RpcId('r2'), SID, + { questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn()), + ], }) const view = render() - expect(view.getByText(/等待审批/)).toBeTruthy() + expect(view.getByText(/等待回答(1 题)/)).toBeTruthy() + expect(view.queryByText(/等待审批/)).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 4eb70ea39f..9231211a47 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -82,6 +82,8 @@ describe('ConversationRoot branches', () => { send={vi.fn()} stop={vi.fn()} open={open} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) return { view, open, chat } @@ -141,6 +143,8 @@ describe('ConversationRoot branches', () => { send={vi.fn()} stop={vi.fn()} open={vi.fn()} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) expect(view.getByTestId('view-body')).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a598803a25..f7cce649e7 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -221,6 +221,8 @@ describe('ConversationRoot', () => { send={send} stop={stop} open={open} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />) return { ui, chat, send, stop, open, renderSlot } } diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index c165455b1a..af56b1ac7d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx index 53f8bbe14f..541e2c9388 100644 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ b/packages/client/ui-sidebar/src/client/Rows.tsx @@ -102,7 +102,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: { ) : } - {row.running && } + {/* Waiting-approval (amber) outranks the running ring: the session is + blocked on the user, which is the more actionable fact. */} + + {row.waitingApproval ? : row.running && } + {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-sidebar/src/client/tree.ts index 021c7e9276..a37a61cf75 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-sidebar/src/client/tree.ts @@ -38,6 +38,8 @@ export interface SessionRow { hasChildren: boolean expanded: boolean running: boolean + /** An approval question is pending (amber warning dot outranks the running ring). */ + waitingApproval: boolean updatedAt: number } @@ -159,6 +161,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo hasChildren, expanded, running: s.running, + waitingApproval: s.waitingApproval, updatedAt: s.updatedAt, } } diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 44fdae18f8..1757a9623a 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -23,7 +23,7 @@ async function bench() { await ctx.plugin(SlotsService).await() const list = createSnapshotStore({ ids: [sid('a')], - byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, waitingApproval: false, updatedAt: 1 } }, current: undefined, }) const sessions = { diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 71416aef36..437f2398b1 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -31,6 +31,7 @@ interface SummaryInit { cwd?: string parentId?: string running?: boolean + waitingApproval?: boolean updatedAt?: number } @@ -40,6 +41,7 @@ function summary(init: SummaryInit): SessionSummary { title: init.title ?? init.id, displayTitle: init.title ?? init.id, running: init.running ?? false, + waitingApproval: init.waitingApproval ?? false, updatedAt: init.updatedAt ?? 0, } if (init.cwd !== undefined) s.cwd = init.cwd @@ -290,4 +292,17 @@ describe('SidebarRoot', () => { expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull() }) + + it('waiting-approval shows the amber warning dot and outranks the running ring', () => { + mount( + summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }), + summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }), + ) + act(() => { fireEvent.click(screen.getByText('p')) }) + const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')! + const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')! + expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy() + expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull() + expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() + }) }) diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 037a2a82ac..79a8afefa0 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -15,6 +15,7 @@ interface SummaryInit { cwd?: string parentId?: string running?: boolean + waitingApproval?: boolean updatedAt?: number } @@ -23,6 +24,7 @@ function summary(init: SummaryInit): SessionSummary { id: sid(init.id), displayTitle: init.displayTitle ?? init.title ?? init.id, running: init.running ?? false, + waitingApproval: init.waitingApproval ?? false, updatedAt: init.updatedAt ?? 0, } if (init.title !== undefined) s.title = init.title diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 4818e67db5..d77c1cdf74 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -144,6 +144,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES send={vi.fn()} stop={vi.fn()} open={vi.fn()} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..e00babb648 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -59,6 +59,25 @@ export function findLastMessageTurnEnd( return latest } +/** + * Whether the log currently sits inside an open turn (a `turn/start` not yet + * closed by a `turn/end`). The turn is the durable log's commit/replay + * boundary: a bare event appended between turns is indistinguishable from a + * crash tail and silently dropped on reload, so writers of turn-enclosed + * events (approval audit pairs, permission/sandbox knob switches) gate on + * this fold and hold idle writes until the next turn opens. + * @param events - session events, or an owned suffix, to inspect. + * @returns true when the last turn boundary event is a `turn/start`. + */ +export function hasOpenTurn(events: readonly SessionEvent[]): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const type = (events[index] as SessionEvent).type + if (type === 'turn/start') return true + if (type === 'turn/end') return false + } + return false +} + declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..83a2fa1172 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -4,6 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { displayPromptContent, findLastMessageTurnEnd, + hasOpenTurn, SESSION_FORMAT_VERSION, Session, SessionEvent, @@ -91,6 +92,17 @@ describe('Session', () => { expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) }) + it('reports an open turn only between turn/start and its turn/end', () => { + const session = new Session(SessionId('open-turn')) + expect(hasOpenTurn(session.events)).toBe(false) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(hasOpenTurn(session.events)).toBe(true) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(hasOpenTurn(session.events)).toBe(true) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(hasOpenTurn(session.events)).toBe(false) + }) + it('round-trips the coarse aborted turn outcome', () => { const session = new Session(SessionId('aborted')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1badfe65e8..9a2df36b47 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there. +- **`respond` is routed by host-side pending tables** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final here; the approval and question registries that make late/duplicate answers meaningful live in `dsh-host-runtime`. - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c2fbb0d189..242c5f20a9 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -19,7 +19,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, PermissionOption, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..6ada97d673 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -15,6 +15,8 @@ export interface RpcMethodMap { 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] + 'session.permissions': SessionsApi['permissions'] + 'session.setPermission': SessionsApi['setPermission'] 'host.describe': HostApi['describe'] } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..4c3da79dc9 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { HistoryEntry, SessionSummary } from './sessions.ts' +import type { HistoryEntry, PermissionOption, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ @@ -108,3 +108,32 @@ export const sessionCancelRequestSchema = z.object({ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> + +/** One permission select option (a preset table key, or the derived `custom`). */ +export const permissionOptionSchema = z.object({ + value: z.string(), + name: z.string(), + description: z.string().optional(), +}) satisfies z.ZodType> + +/** session.permissions request payload. */ +export const sessionPermissionsRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** session.permissions response value. */ +export const sessionPermissionsValueSchema = z.object({ + options: z.array(permissionOptionSchema), + currentValue: z.string(), +}) satisfies z.ZodType>> + +/** session.setPermission request payload. */ +export const sessionSetPermissionRequestSchema = z.object({ + sessionId: sessionIdSchema, + value: z.string(), +}) satisfies z.ZodType>> + +/** session.setPermission response value. */ +export const sessionSetPermissionValueSchema = z.object({ + currentValue: z.string(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..b2c56405e2 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -44,6 +44,21 @@ export interface SessionSummary { cwd?: string } +/** + * One selectable permission preset (or the derived `custom` state) as the + * client renders it. Protocol-owned DTO (the ACP bridge precedent: each + * protocol owns its presentation shape); the host projects it from + * `ctx.permission` without exposing that service's types on the wire. + */ +export interface PermissionOption { + /** The machine value (`session.setPermission` vocabulary): a preset table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means. */ + description?: string +} + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -70,4 +85,24 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Reads the session's permission select: every switchable preset plus the + * effective current value (`custom` when the knobs match no preset — shown, + * never a switch target). A host composed without the permission service + * returns empty options and `custom`; clients hide the control. + */ + permissions(request: RpcRequest<{ sessionId: SessionId }>): + Promise> + + /** + * Switches the session's permission preset. Mirrors the ACP bridge's + * turn-anchoring: inside an open turn the knob events append immediately; + * idle switches are held last-write-wins and flushed into the next prompted + * turn (approval-policy and sandbox-mode events must stay turn-enclosed for + * durable replay). A current-value echo is acknowledged without recording. + * Unknown values and a permission-less composition are bad-request. + */ + setPermission(request: RpcRequest<{ sessionId: SessionId; value: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..79941033bd 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -19,7 +19,9 @@ import { sessionCreateValueSchema, sessionHistoryValueSchema, sessionListValueSchema, + sessionPermissionsValueSchema, sessionPromptValueSchema, + sessionSetPermissionValueSchema, } from '../api/sessions.schema.ts' /** @@ -44,6 +46,8 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> + permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise>> + setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -66,6 +70,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), + permissions: (payload, signal) => this.callUnary('session.permissions', payload, signal), + setPermission: (payload, signal) => this.callUnary('session.setPermission', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..085238ac0f 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -19,7 +19,9 @@ import { sessionCreateRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, + sessionPermissionsRequestSchema, sessionPromptRequestSchema, + sessionSetPermissionRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' @@ -43,6 +45,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, + 'session.permissions': { schema: sessionPermissionsRequestSchema, invoke: (api, r) => api.sessions.permissions(r) }, + 'session.setPermission': { schema: sessionSetPermissionRequestSchema, invoke: (api, r) => api.sessions.setPermission(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..e39d8d553a 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -31,6 +31,8 @@ function scriptedApi(overrides: { history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), + permissions: r => ok(r, { options: [], currentValue: 'custom' }), + setPermission: r => ok(r, { currentValue: r.payload.value }), ...overrides.sessions, }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..5272c65b9e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -36,6 +36,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async permissions(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { options: [], currentValue: 'custom' } } } + }, + async setPermission(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { currentValue: request.payload.value } } } + }, }, host: { async describe(request) { @@ -75,11 +81,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/describe passthrough', async () => { + it('covers create/prompt/cancel/permissions/setPermission/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) + expect((await c.sessions.permissions({ sessionId: 's' as never })).result.ok).toBe(true) + expect((await c.sessions.setPermission({ sessionId: 's' as never, value: 'workspace-write' })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) }) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..c1817d8c6f 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, the provider-neutral user-interaction service, and the sandboxed product path — `dsh-sandbox-local` + `dsh-sandbox-policy` behind the confined `dsh-bash-sandbox`/`dsh-fs-sandbox` families, with `dsh-user-approval` and `dsh-permission` on top), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -15,11 +15,15 @@ Which plugins mount and with what defaults is decided only here — shells must | `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. | | `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. | | `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. | +| `sandbox.mode` | `'workspace-write'` | File-sandbox mode sessions start from (`ctx.sandboxPolicy` default; per-session switches ride `sandbox/mode` events). | +| `sandbox.approvalPolicy` | `'ask'` | Approval policy for sessions without an `approval/policy` override. | ## ApiProxy implementation notes Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position. +The proxy is also the approval channel for the agents this host owns: an ask through `ctx.approval` becomes an answerable `approval/requested` mux frame with a stable rpcId held in a pending table, replayed verbatim on every mux open until settled. `respond` routes by the echoed rpcId (approvals first, then questions), validates the `ApprovalResponsePayload` audit correlation at the wire boundary, resolves the answerer, and broadcasts `approval/resolved`; the ask's own abort signal withdraws the question as `cancelled`. `session.permissions`/`session.setPermission` project `ctx.permission` (empty select when not composed); idle switches are held last-write-wins and flushed into the next prompted turn on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay (the ACP bridge's anchoring pattern). + ## Model Experience Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled. @@ -30,6 +34,6 @@ No main-request invalidation; when enabled, the auxiliary title request has its ## Known Limitations and Deferred Work -- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence. +- **Question and approval waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence. - **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version. - **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..47327683b8 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -31,13 +31,17 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", @@ -63,6 +67,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^" diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 3792d17da3..4cf8af1d65 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -1,19 +1,31 @@ /** - * Host-side ApiProxy implementation. Signature discipline: unary takes the - * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. + * Host-side ApiProxy implementation. Unary methods, both streams, and the + * pending-interaction registries are real: the approval registry turns + * `ctx.approval` asks into answerable `approval/requested` mux frames and the + * question provider does the same for `ask_user_question`; both are answered + * through `respond` (routed by the echoed rpcId). Signature discipline: unary + * takes the narrow RpcRequest

and echoes request.rpcId on the RpcResponse. */ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +// Side-effect type imports: resolve `ctx.approval` / `ctx.get('permission')` +// without value dependencies on the seams (both are optional compositions here). +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-permission' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' +import { approvalResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/approvals.schema' import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -183,6 +195,36 @@ interface ToolCallData { callId: string; name: string; arguments: string } /** The tool/result payload fields the presenter path reads. */ interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue } +/** + * One outstanding approval question: the stable server-request id, the frame + * material replayed to late mux subscribers, and the resolver that settles the + * answerer's promise back into `ctx.approval`. + */ +interface PendingApproval { + rpcId: RpcId + sessionId: SessionId + approvalId: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + resolve(outcome: ApprovalOutcome): void +} + +/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ +function requestedFrame(pending: PendingApproval): RpcRequest { + return { + rpcId: pending.rpcId, + payload: { + type: 'approval/requested', + sessionId: pending.sessionId, + approvalId: pending.approvalId, + toolName: pending.toolName, + ...pending.callId === undefined ? {} : { callId: pending.callId }, + ...pending.reason === undefined ? {} : { reason: pending.reason }, + }, + } +} + /** One host-owned question wait, addressed by the stable server-request id. */ interface PendingQuestion { rpcId: RpcId @@ -283,6 +325,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() const pendingQuestions = new Map() + const pendingApprovals = new Map() const muxQueues = new Set>>() /** Send one transient frame to every connected mux consumer. */ @@ -341,6 +384,95 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, 'api-proxy: user-interaction provider') + // --- Approval pending registry ------------------------------------------ + // The proxy is the approval channel for every agent this host owns: an ask + // through `ctx.approval` becomes an answerable server-request on the mux + // stream (stable rpcId), settled by POST /api/respond. The entry survives + // client disconnects — mux-open replays still-pending requested frames with + // the same rpcId (the refresh-recovery baseline) — and withdraws on the + // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. + if (ctx.get('approval') !== undefined) { + ctx.on('approval/request', (req, next) => { + // The audit pair `approval/asked` is already appended by the service + // before dispatch, but dispatch rides a microtask: parallel tool calls + // can append several asked events before any answerer runs. THIS + // request's event is therefore the newest asked event that is still + // undecided, unclaimed by another pending entry, and — when the ask + // names a call — carries the same callId. + const events = req.agent.session.events + const claimed = new Set() + for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) + const decided = new Set() + let approvalId: ApprovalRequestId | undefined + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i] as SessionEvent + if (event.type === 'approval/decided') { + decided.add(event.data.id) + } else if (event.type === 'approval/asked') { + if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + if (req.callId !== undefined && event.data.callId !== req.callId) continue + approvalId = event.data.id + break + } + } + // No asked event means the request bypassed the service's audit path — + // not this channel's question; delegate to the fail-closed default. + if (approvalId === undefined) return next() + const id = approvalId + return new Promise((resolve) => { + const settle = (outcome: ApprovalOutcome): void => { + /* v8 ignore next 3 -- defensive double-settle guard: respond() routes + through the pending table (a settled id is not-pending before it can + re-settle) and the first settle removes the abort listener, so no + reachable path settles twice; kept against future settle callers. */ + if (!pendingApprovals.delete(pending.rpcId)) return + req.signal?.removeEventListener('abort', onAbort) + broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) + // A cancelled ask was already settled by the service's own signal + // race, which discards this late resolution; resolving is a no-op + // there and keeps this promise from dangling forever. + resolve(outcome) + } + const onAbort = (): void => { settle('cancelled') } + const pending: PendingApproval = { + rpcId: RpcId(randomUUID()), + sessionId: req.agent.session.id, + approvalId: id, + toolName: req.toolName, + ...req.callId === undefined ? {} : { callId: req.callId }, + ...req.reason === undefined ? {} : { reason: req.reason }, + resolve: settle, + } + pendingApprovals.set(pending.rpcId, pending) + req.signal?.addEventListener('abort', onAbort, { once: true }) + const envelope = requestedFrame(pending) + for (const queue of muxQueues) queue.push(envelope) + }) + }) + } + + // --- Permission switch anchoring ---------------------------------------- + // Knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) must + // be turn-enclosed for durable replay, so an idle switch is held here + // last-write-wins and flushed when the next prompted turn opens (the ACP + // bridge's pendingSwitches pattern; prompt-submit is inside the new turn but + // before prompt assembly, so the switch is visible to that turn's request). + const pendingSwitches = new Map() + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => { + const preset = pendingSwitches.get(agent.session.id) + if (preset !== undefined) { + pendingSwitches.delete(agent.session.id) + const presets = ctx.get('permission') + /* v8 ignore next -- a pending preset exists only if setPermission saw the + service; it cannot unmount between that and the next turn here. */ + if (presets !== undefined) presets.set(agent.session, preset) + } + return next() + }) + ctx.on('session/disposed', (session: Session) => { + pendingSwitches.delete(session.id) + }) + /** * Gate the cold path on the store: an id absent from it, or naming a legacy * log without a cwd (pre-release stance: not served, no compatibility), is @@ -468,6 +600,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro agent.cancel() return Promise.resolve(ok(request, { accepted: true as const })) }, + + async permissions(request) { + const { sessionId } = request.payload + const presets = ctx.get('permission') + // A permission-less composition advertises an empty select (client + // hides the control) rather than erroring — the control's absence is + // deployment shape, not a caller mistake. + if (presets === undefined) return ok(request, { options: [], currentValue: 'custom' }) + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const events = found.agent.session.events + const currentValue = pendingSwitches.get(sessionId) ?? presets.current(events) + const options = [ + ...presets.names.map(name => presets.optionOf(name)), + // `custom` echoes the current derived state but is never a target. + ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], + ] + return ok(request, { options, currentValue }) + }, + + async setPermission(request) { + const { sessionId, value } = request.payload + const presets = ctx.get('permission') + if (presets === undefined) { + return err(request, { code: 'bad-request', message: 'no permission service is composed on this host', details: { issues: [] } }) + } + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const agent = found.agent + // A current-value echo is acknowledged without recording a switch. + const current = pendingSwitches.get(sessionId) ?? presets.current(agent.session.events) + if (value === current) return ok(request, { currentValue: value }) + if (!presets.names.includes(value)) { + return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) + } + // Turn-anchoring (the ACP bridge pattern): knob events must be enclosed + // by the durable log's turn boundary, so an idle switch is held + // last-write-wins and flushed into the next prompted turn. + if (hasOpenTurn(agent.session.events)) presets.set(agent.session, value) + else pendingSwitches.set(sessionId, value) + return ok(request, { currentValue: value }) + }, }, host: { @@ -499,6 +673,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }) } + // Refresh recovery: still-pending approval questions replay with their + // stable rpcId so a reconnecting client can still answer them. + for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream // opened mid-turn) backscans the session's in-memory events instead. @@ -564,6 +741,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, respond(message: ClientResponse): Promise { + // Route by the echoed rpcId (the wire correlation): approvals first, + // then questions — the two registries share one id space of UUIDs. + const approval = pendingApprovals.get(message.rpcId) + if (approval !== undefined) { + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) + // The payload's audit correlation must match the entry the rpcId routed + // to — a mismatched answer is malformed, not merely late. + if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approval.resolve(parsed.data.outcome) + return Promise.resolve({ accepted: true }) + } const pending = pendingQuestions.get(message.rpcId) if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) if (!message.result.ok) { diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index c0960ab678..3417956f62 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -18,11 +18,17 @@ import TaskService from '@deepseek-ai/dsh-tasks' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import SandboxLocal from '@deepseek-ai/dsh-sandbox-local' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import SandboxBashExecutor from '@deepseek-ai/dsh-bash-sandbox' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import PermissionService from '@deepseek-ai/dsh-permission' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as toolTodo from '@deepseek-ai/dsh-tool-todo' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' -import FsLocal from '@deepseek-ai/dsh-fs-local' +import FsSandbox from '@deepseek-ai/dsh-fs-sandbox' import * as fsPolicy from '@deepseek-ai/dsh-fs-policy' import * as toolFs from '@deepseek-ai/dsh-tool-fs' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -73,6 +79,17 @@ export interface BootHostOptions { sessionTitle?: SessionTitleConfig /** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */ sessionTitleLlm?: true | SessionTitleLlmConfig + /** + * Sandbox/approval composition knobs. The host always composes the confined + * bash + fs families over `ctx.sandboxPolicy` (the acp-agent composition); + * these fields choose the deployment defaults every session starts from. + */ + sandbox?: { + /** File-sandbox mode sessions start from (default `workspace-write`). */ + mode?: SandboxMode + /** Approval policy for sessions without an override (default `ask`). */ + approvalPolicy?: ApprovalPolicy + } /** * Default project directory for sessions created without an explicit cwd * (defaults to the host process working directory). A session's cwd is its @@ -131,16 +148,30 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) - await ctx.plugin(LocalBashExecutor, {}) + // The sandboxed product path (the acp-agent composition, sandbox Agent + // Note): per-platform runner provider, the shared policy home, the confined + // bash executor, and the approval seam its escalation asks through. Sessions + // start from the configured default mode; per-session switches ride the + // `sandbox/mode` / `approval/policy` events written by ctx.permission. + await ctx.plugin(SandboxLocal, {}) + await ctx.plugin(SandboxPolicy, { + mode: options.sandbox?.mode ?? 'workspace-write', + workspaceRoot: defaults.cwd, + }) + await ctx.plugin(SandboxBashExecutor, {}) + await ctx.plugin(ApprovalService, { policy: options.sandbox?.approvalPolicy ?? 'ask' }) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + // the agent-spine bundle) so web sessions get the same coding-agent tool // face; deviations are noted inline. await ctx.plugin(toolBash, {}) + // Presets over the two knobs (requires the confining executor + approval). + await ctx.plugin(PermissionService, {}) await ctx.plugin(toolTodo) await ctx.plugin(toolTasks, {}) // fs paths resolve against the host default project rather than the raw - // process cwd — the same source create() injects into session.cwd. - await ctx.plugin(FsLocal, { cwd: defaults.cwd }) + // process cwd — the same source create() injects into session.cwd. The + // sandboxed backend fences write/edit by the same policy as bash. + await ctx.plugin(FsSandbox, { cwd: defaults.cwd }) await ctx.plugin(fsPolicy) await ctx.plugin(toolFs, {}) await ctx.plugin(toolFsSearch, {}) diff --git a/packages/host/runtime/tests/api-proxy-approval.spec.ts b/packages/host/runtime/tests/api-proxy-approval.spec.ts new file mode 100644 index 0000000000..e0cb0c71e5 --- /dev/null +++ b/packages/host/runtime/tests/api-proxy-approval.spec.ts @@ -0,0 +1,275 @@ +/** + * Approval pending registry over the proxy: an ask through `ctx.approval` + * becomes an answerable `approval/requested` mux frame (stable rpcId, replayed + * verbatim on a later mux open), `respond` routes by the echoed rpcId and + * validates the audit correlation, and the ask's abort signal withdraws the + * question with a broadcast `cancelled`. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ApprovalService) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + return { ctx, api } +} + +/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */ +function agentOf(ctx: Context): Agent { + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return { session } as unknown as Agent +} + +/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */ +function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest[]; waitFor(type: MuxFrame['type']): Promise } { + const frames: MuxFrame[] = [] + const envelopes: RpcRequest[] = [] + const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = [] + void (async () => { + for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) { + frames.push(envelope.payload) + envelopes.push(envelope) + for (let i = waiters.length - 1; i >= 0; i -= 1) { + const waiter = waiters[i] as (typeof waiters)[number] + if (waiter.type === envelope.payload.type) { + waiters.splice(i, 1) + waiter.resolve(envelope.payload) + } + } + } + })() + return { + frames, + envelopes, + waitFor: (type) => { + const found = frames.find(frame => frame.type === type) + if (found !== undefined) return Promise.resolve(found) + return new Promise((resolve) => { waiters.push({ type, resolve }) }) + }, + } +} + +function requestedOf(frame: MuxFrame): Extract { + if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`) + return frame +} + +/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */ +async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise { + for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count) +} + +function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters[0] { + return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } } +} + +describe('approval pending registry', () => { + it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + + const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id }) + + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' }) + + // The question settled: a duplicate answer is late, not re-decidable. + const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected')) + expect(dup).toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => { + const { ctx, api } = await harness() + const first = new AbortController() + const firstMux = openMux(api, first) + const agent = agentOf(ctx) + const asked = ctx.approval.request({ agent, toolName: 'write' }) + const requested = requestedOf(await firstMux.waitFor('approval/requested')) + const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + first.abort() + + // A fresh subscriber (refresh recovery) sees the same stable rpcId. + const second = new AbortController() + const secondMux = openMux(api, second) + const replayed = requestedOf(await secondMux.waitFor('approval/requested')) + const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId) + expect(replayed.approvalId).toBe(requested.approvalId) + + const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('rejected') + second.abort() + }) + + it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + void ctx.approval.request({ agent, toolName: 'bash' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + // Unknown rpcId: not routed to any pending entry. + expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + // Error-branch result: the client can only answer with a value. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Wrong audit correlation: the rpcId routed, but the payload disagrees. + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Malformed payload shape. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + abort.abort() + }) + + it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + cancel.abort() + await expect(asked).resolves.toBe('cancelled') + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('carries callId on the frame and ignores a late abort after the answer settled', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested.callId).toBe('call-9') + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + // Late abort: the pending entry is gone; settle's delete-guard returns. + cancel.abort() + expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1) + abort.abort() + }) + + it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + // Both asks append their approval/asked audit events before either + // answerer's microtask dispatch runs — the parallel tool-call window. + const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never }) + const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest + // Each frame claimed the asked event with its own callId, not merely the newest. + const askedIdByCall = new Map(agent.session.events + .filter(event => event.type === 'approval/asked') + .map(event => [String(event.data.callId), event.data.id])) + expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a')) + expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b')) + // Answers route back to the right ask through the pairing. + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const askA = ctx.approval.request({ agent, toolName: 'alpha' }) + const askB = ctx.approval.request({ agent, toolName: 'beta' }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest + // Without a callId the pairing is heuristic, but never shared: the second + // dispatch skips the id the first pending entry already claimed. + expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the service + // Bypass ApprovalService: a log whose sole asked event already has its + // decided partner must not be re-claimed — the answerer delegates. + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' }) + session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) + + it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the audit path + // Bypass ApprovalService: dispatch the waterfall directly with a session + // that has no approval/asked event — the proxy answerer must call next(). + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) +}) diff --git a/packages/host/runtime/tests/api-proxy-permission.spec.ts b/packages/host/runtime/tests/api-proxy-permission.spec.ts new file mode 100644 index 0000000000..472a49a52b --- /dev/null +++ b/packages/host/runtime/tests/api-proxy-permission.spec.ts @@ -0,0 +1,152 @@ +/** + * Permission select over the proxy: permissions() projects the preset table + * plus the derived current value (custom shown only when derived), + * setPermission() validates against the table and anchors idle switches to + * the next prompted turn (the ACP bridge's pendingSwitches pattern), and a + * permission-less composition serves an empty select instead of an error. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import PermissionService from '@deepseek-ai/dsh-permission' +import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { createApiProxy } from '../src/api-proxy.ts' + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } +} + +async function harness(options: { permission?: boolean } = {}): Promise<{ ctx: Context; api: ApiProxy; sessionId: SessionId }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + if (options.permission !== false) { + // The permission service requires a confining executor fact + approval. + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission proxy tests do not execute bash') }, + run() { throw new Error('permission proxy tests do not execute bash') }, + start() { throw new Error('permission proxy tests do not execute bash') }, + }) + await ctx.plugin(ApprovalService) + await ctx.plugin(PermissionService, {}) + } + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + // No agent-loop in this harness: register a bare live agent directly (the + // proxy only reaches `.session`); api-proxy-view.spec.ts precedent. + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return { ctx, api, sessionId: session.id } +} + +function expectOk(response: { result: { ok: true; value: T } | { ok: false } }): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +describe('session.permissions', () => { + it('projects the preset table with the effective current value; custom is absent when a preset matches', async () => { + const { api, sessionId } = await harness() + const value = expectOk<{ options: { value: string }[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value.currentValue).toBe('workspace-write') + expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('serves an empty select (custom) on a permission-less composition', async () => { + const { api, sessionId } = await harness({ permission: false }) + const value = expectOk<{ options: unknown[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value).toEqual({ options: [], currentValue: 'custom' }) + }) + + it('appends the derived custom option when the knobs match no preset', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('sandbox/mode', { mode: 'read-only' }) + const value = expectOk<{ options: { value: string }[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value.currentValue).toBe('custom') + expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access', 'custom']) + }) + + it('propagates the agentFor error for a ghost session (persistence-less harness: internal)', async () => { + // The not-found/internal split is agentFor's documented gate and already + // covered by the history specs; here only the pass-through matters. + const { api } = await harness() + const response = await api.sessions.permissions(request({ sessionId: 'session-void' as SessionId })) + expect(response.result.ok).toBe(false) + }) +}) + +describe('session.setPermission', () => { + it('holds an idle switch pending (visible in permissions()) and flushes it into the next prompted turn', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + expect(agent).toBeDefined() + const switched = expectOk<{ currentValue: string }>( + await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) + expect(switched.currentValue).toBe('danger-full-access') + // No turn open: nothing appended yet; the pending value masks the fold. + expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) + const echoed = expectOk<{ currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(echoed.currentValue).toBe('danger-full-access') + + // The waterfall flush path: prompt-submit inside the new turn writes through. + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.waterfall('agent/prompt-submit', agent as never, [], { kind: 'user' } as never, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const })) + expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') + expect(agent?.session.events.map(e => e.type)).toContain('sandbox/mode') + expect(agent?.session.events.map(e => e.type)).toContain('approval/policy') + }) + + it('writes through immediately inside an open turn', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expectOk(await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) + expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') + }) + + it('acknowledges a current-value echo without recording a switch', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const echoed = expectOk<{ currentValue: string }>( + await api.sessions.setPermission(request({ sessionId, value: 'workspace-write' }))) + expect(echoed.currentValue).toBe('workspace-write') + expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) + }) + + it('propagates the agentFor error for a ghost session', async () => { + const { api } = await harness() + const response = await api.sessions.setPermission(request({ sessionId: 'session-void' as SessionId, value: 'workspace-write' })) + expect(response.result.ok).toBe(false) + }) + + it('rejects unknown values (custom included) and a permission-less composition as bad-request', async () => { + const { api, sessionId } = await harness() + for (const value of ['custom', 'nope']) { + const response = await api.sessions.setPermission(request({ sessionId, value })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + } + const bare = await harness({ permission: false }) + const response = await bare.api.sessions.setPermission(request({ sessionId: bare.sessionId, value: 'workspace-write' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + }) +}) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index aee28b5371..da3d934a17 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -48,16 +48,31 @@ "path": "../../session-persistence/session-persistence-jsonl" }, { - "path": "../../bash/bash-local" + "path": "../../bash/bash-sandbox" }, { "path": "../../bash/tool-bash" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-local" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../ui/user-approval" + }, + { + "path": "../../ui/permission" + }, { "path": "../../compact/compact-basic" }, { - "path": "../../fs/fs-local" + "path": "../../fs/fs-sandbox" }, { "path": "../../fs/fs-policy" diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8a731a1bf6..74127261bd 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -60,7 +60,7 @@ import { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-commands' import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference' -import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' +import { displayPromptContent, hasOpenTurn, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -708,17 +708,6 @@ export function apply(ctx: Context, config: AcpConfig): void { }] } - /** Whether the log has an open turn in which a config switch can be enclosed. */ - const isTurnOpen = (agent: Agent): boolean => { - const events = agent.session.events - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false - } - /** Anchor last-write-wins idle switches into a just-opened turn. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches @@ -1159,7 +1148,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (!presets.names.includes(params.value)) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value) + if (hasOpenTurn(rec.agent.session.events)) presets.set(rec.agent.session, params.value) else rec.pendingSwitches.preset = params.value break } diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 329fad33a7..45247b5d3f 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -11,6 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' +import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -138,22 +139,6 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. - * The audit pair must be turn-enclosed: the turn is the durable log's - * commit/replay boundary, so a bare event appended between turns is - * indistinguishable from a crash tail and silently dropped on reload. - */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eb750454..5083d449ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2091,18 +2091,18 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-bash-local': + '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ - version: link:../../bash/bash-local + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../compact/compact-basic - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../apiproxy @@ -2112,6 +2112,18 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2187,6 +2199,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction From 0e220ab566cab2577b3cd95be02df991db425254 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 21:19:02 +0800 Subject: [PATCH 02/23] docs: allow rewriting pushed PR history --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c987801c17..93299d01e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes; never squash, rebase, or rewrite pushed history. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Use incremental merge commits.** Split independent changes. Pushed PR history may be rewritten before merge. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From c6b552e81717e818d27386534858490b18e745b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:26 +0800 Subject: [PATCH 03/23] refactor(web): drop the permission RPC pair and turn-anchoring machinery The session.permissions/setPermission unary pair, the PermissionOption wire DTO, the client Session wrappers, and the fixture/fake mirrors all leave the wire: the read side moves to the 'permissions' session projection and the write side moves to the /permission command in follow-up commits, so the web protocol gains no permission methods at all. The pendingSwitches + prompt-submit flush + hasOpenTurn move also goes. Knob events no longer need turn enclosure: the persistence scanner keeps standalone events after the last turn/end as part of the preserved prefix (remove-synthetic-log-only-turns), none of the three knob invariants demand an open turn, and the setters append bare events. An idle switch commits immediately; hasOpenTurn stays a user-approval private fold (its audit pair is the one contract that still requires enclosure). The old PermissionSelect chip and its mount-time fetch die with the RPCs (the resident composer broke the mount-once assumption); the projection-fed replacement lands with the Access seat swap. --- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 26 --- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 8 - .../client/connection/tests/fixture.spec.ts | 19 --- packages/client/runtime/src/client/index.ts | 10 +- .../runtime/src/client/sessions/session.ts | 25 --- packages/client/runtime/tests/fake-api.ts | 8 - packages/client/runtime/tests/manager.spec.ts | 6 +- packages/client/runtime/tests/session.spec.ts | 24 --- .../skeleton/PermissionSelect.module.css | 49 ------ .../src/client/skeleton/PermissionSelect.tsx | 88 ---------- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../tests/coverage-tails.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 13 -- .../client/ui-trajectory/tests/views.spec.tsx | 2 - .../client/ui-workspace/tests/tree.spec.ts | 2 +- .../tests/workspace-browser.spec.tsx | 2 +- packages/core/session/src/index.ts | 19 --- packages/core/session/tests/session.spec.ts | 12 -- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 - .../host/apiproxy/src/api/sessions.schema.ts | 30 +--- packages/host/apiproxy/src/api/sessions.ts | 34 ---- packages/host/apiproxy/src/fetch/client.ts | 8 - packages/host/apiproxy/src/fetch/handler.ts | 4 - .../apiproxy/tests/api-proxy-approval.spec.ts | 2 +- .../tests/api-proxy-permission.spec.ts | 152 ------------------ .../apiproxy/tests/client-handler.spec.ts | 2 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 10 +- packages/ui/user-approval/src/index.ts | 17 +- 36 files changed, 37 insertions(+), 561 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css delete mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx delete mode 100644 packages/host/apiproxy/tests/api-proxy-permission.spec.ts diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 54468bce11..77f5445f6d 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8d81b07d76..56d8b90d34 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -483,12 +483,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true - /** Per-session permission preset (fixture mirror of the host permission select). */ - const permissionValues = new Map() - const PERMISSION_OPTIONS = [ - { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, - { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, - ] const fixtureQuestions: Extract['questions'] = [ { id: 'harness-profile', @@ -839,24 +833,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { accepted: true as const }) }, - permissions: (request) => { - const { sessionId: id } = request.payload - if (summaryOf(id) === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' }) - }, - setPermission: (request) => { - const { sessionId: id, value } = request.payload - if (summaryOf(id) === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (!PERMISSION_OPTIONS.some(option => option.value === value)) { - return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) - } - permissionValues.set(id, value) - return ok(request, { currentValue: value }) - }, }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), @@ -1134,8 +1110,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) - case 'session.permissions': return this.api.sessions.permissions(request) - case 'session.setPermission': return this.api.sessions.setPermission(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index af16fb540b..1ce30dbdc4 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -12,7 +12,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index f1e90bd77f..1dac997ae2 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -63,12 +63,6 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onPermissions: (payload: unknown) => - Promise> = - () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) - - onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = - payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = @@ -92,8 +86,6 @@ export class FakeApiClient implements IApiClient { this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), - setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 7ffd1f5095..7ba49dd5f5 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -345,23 +345,6 @@ describe('createFixtureApi', () => { expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) }) - it('permissions/setPermission mirror the host select: read, switch, validation', async () => { - const api = createFixtureApi() - const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) - expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } }) - const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' })) - expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) - const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) - expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) - // Validation: ghost session and unknown value. - const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') })) - expect(ghostRead.result.ok).toBe(false) - const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' })) - expect(ghostSwitch.result.ok).toBe(false) - const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' })) - expect(unknown.result.ok).toBe(false) - }) - it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -739,8 +722,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) const workspace = await client.workspace.create({ name: 'via-client' }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 63ffae4175..a7e9104b6c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -40,15 +40,7 @@ export type { PendingInteraction, PendingKind, PendingPayloads } from './session export type { ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** The permission select material as the object layer serves it to UI plugins. */ -export interface PermissionSelect { - /** Switchable presets plus (when derived) the current-only `custom`. */ - options: { value: string; name: string; description?: string }[] - /** The effective current value (`custom` when knobs match no preset). */ - currentValue: string -} +export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f354dd5305..00398d53af 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -248,31 +248,6 @@ export class Session implements ObservableSnapshot { return result } - /** - * Read the permission select (options + effective current value). - * @returns the select material, or the error branch on failure. - */ - async permissions(): Promise> { - try { - return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result - } catch (error) { - return transportError(error) - } - } - - /** - * Switch the permission preset. - * @param value - a preset value advertised by {@link Session.permissions} (never `custom`). - * @returns the confirmed current value, or the error branch on failure. - */ - async setPermission(value: string): Promise> { - try { - return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result - } catch (error) { - return transportError(error) - } - } - /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 930a9f89da..6d37f6705f 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -81,12 +81,6 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onPermissions: (payload: unknown) => - Promise> = - () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) - - onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = - payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -111,8 +105,6 @@ export class FakeApiClient implements IApiClient { this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), - setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 3aa0834f99..f2d136bef8 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -358,7 +358,7 @@ describe('connected generation', () => { describe('waiting-approval list bit', () => { it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) @@ -371,7 +371,7 @@ describe('waiting-approval list bit', () => { it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) @@ -386,7 +386,7 @@ describe('waiting-approval list bit', () => { it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) manager.handleConnected() // resolved-while-disconnected questions send no frame diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 76c7b51e99..ca9193eda1 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -816,27 +816,3 @@ describe('reference stability (the memo contract)', () => { expect(resolved.pending).toBe(after.pending) }) }) - -describe('permissions / setPermission', () => { - it('passes the select read and switch through with the session id', async () => { - const { api, session } = makeSession() - api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' })) - const read = await session.permissions() - expect(read.ok).toBe(true) - if (read.ok) expect(read.value.currentValue).toBe('workspace-write') - expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }]) - - const switched = await session.setPermission('danger-full-access') - expect(switched.ok).toBe(true) - if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access') - expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }]) - }) - - it('folds transport failures into the error branch', async () => { - const { api, session } = makeSession() - api.onPermissions = () => Promise.reject(new Error('down')) - api.onSetPermission = () => Promise.reject(new Error('down')) - expect((await session.permissions()).ok).toBe(false) - expect((await session.setPermission('x')).ok).toBe(false) - }) -}) diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css deleted file mode 100644 index dd5986992c..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ /dev/null @@ -1,49 +0,0 @@ -/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a - quiet text chip with a chevron; hover paints the standard interactive pill. - The native select is stretched invisibly over the chip so the platform - dropdown does the menu work — keyboard/AT semantics come free. */ - -.root { - position: relative; - display: inline-flex; - align-items: center; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 8px; - border-radius: 8px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 20px; - pointer-events: none; /* the overlaid select owns the interaction */ -} - -.root:hover .chip { - background: var(--dsw-alias-interactive-bg-hover); -} - -.chevron { - color: var(--dsw-alias-label-caption); -} - -/* Invisible native select stretched over the chip: real menu, zero drawing. */ -.select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - opacity: 0; - border: none; - cursor: pointer; -} - -.select:disabled { - cursor: default; -} - -.root:has(.select:disabled) .chip { - opacity: 0.5; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx deleted file mode 100644 index a32e2af037..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// PermissionSelect: the composer bottom-row permission chip (draft -// start.jpeg's `Read-only ∨` control). Options and the current value load on -// mount from the injected permissions() callback; empty options -// (permission-less host composition) render nothing. The visible chip is -// presentation only — an invisible native select stretched over it owns the -// menu and interaction. A switch disables the control until the host -// confirms, then adopts the confirmed value (`custom` is shown as the current -// value but never offered as a target — the host already omits it from -// switchable options; a stale-select failure restores the previous value). - -import { useEffect, useRef, useState } from 'react' -import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/client' -import css from './PermissionSelect.module.css' - -/** - * Display transform: kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`). Presentation-only — the wire - * vocabulary and the host's advertised names are untouched; a host-configured - * name that is not kebab-case (contains spaces or uppercase) passes through. - */ -function displayName(name: string): string { - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - -export interface PermissionSelectProps { - /** Read the select material; null hides the control. */ - permissions: () => Promise - /** Switch the preset; resolves the confirmed value, or null on failure. */ - setPermission: (value: string) => Promise -} - -export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) { - const [data, setData] = useState(null) - const [switching, setSwitching] = useState(false) - // Unmount guard: the load/switch promises outlive a session switch's remount. - const aliveRef = useRef(true) - useEffect(() => { - aliveRef.current = true - void permissions().then((loaded) => { - if (aliveRef.current) setData(loaded) - }) - return () => { - aliveRef.current = false - } - }, [permissions]) - - if (data === null) return null - - const onChange = (value: string): void => { - if (value === data.currentValue) return - setSwitching(true) - const previous = data - setData({ ...data, currentValue: value }) - void setPermission(value).then((confirmed) => { - if (!aliveRef.current) return - setSwitching(false) - if (confirmed === null) setData(previous) - else setData({ ...previous, currentValue: confirmed }) - }) - } - - const current = data.options.find(option => option.value === data.currentValue) - - return ( - - ) -} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6803e3e378..b93c225680 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -55,7 +55,7 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } }, + byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: ROOT, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index afafafa3dc..34237bfebf 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -26,8 +26,8 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 }, + [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, waitingApproval: false, blank: false, updatedAt: 1 }, + [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 2 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 39d2b8283a..1b4d1ee158 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 784c04c571..44cf87cc46 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -68,7 +68,7 @@ async function bench(nodes: ToolResultNode[]) { const session = createSnapshotStore(snapshotWith(nodes)) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 90628f64ee..7a9dfe957d 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -94,7 +94,7 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9d34608156..b6263929cf 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -64,8 +64,8 @@ function mount( const sessions = createSnapshotStore({ ids: [root, SID], byId: { - [root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }, - [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 }, + [root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }, + [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 }, }, current: SID, phase: 'ready', diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index ca125d6398..93a1f15a58 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired. +- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index fcffc975b1..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -94,17 +94,4 @@ describe('SidebarRoot shell', () => { expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) - - it('waiting-approval shows the amber warning dot and outranks the running ring', () => { - mount( - summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }), - summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }), - ) - act(() => { fireEvent.click(screen.getByText('p')) }) - const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')! - const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')! - expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy() - expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull() - expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() - }) }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6e1da8afaa..917beaccc6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -149,8 +149,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} bindDraftMirror={() => () => {}} open={vi.fn()} - permissions={() => Promise.resolve(null)} - setPermission={() => Promise.resolve(null)} />, ) } diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 4af5d5f70c..eb34f633d8 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -8,7 +8,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e8405c6bcd..7513342376 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() }) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides, }) const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b6ade3da7a..e42414503b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -51,25 +51,6 @@ export function findLastMessageTurnEnd( return latest } -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`). The turn is the durable log's commit/replay - * boundary: a bare event appended between turns is indistinguishable from a - * crash tail and silently dropped on reload, so writers of turn-enclosed - * events (approval audit pairs, permission/sandbox knob switches) gate on - * this fold and hold idle writes until the next turn opens. - * @param events - session events, or an owned suffix, to inspect. - * @returns true when the last turn boundary event is a `turn/start`. - */ -export function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2edb119668..7152dd5d41 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -3,7 +3,6 @@ import { Context } from 'cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { findLastMessageTurnEnd, - hasOpenTurn, SESSION_FORMAT_VERSION, Session, SessionEvent, @@ -108,17 +107,6 @@ describe('Session', () => { expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) }) - it('reports an open turn only between turn/start and its turn/end', () => { - const session = new Session(SessionId('open-turn')) - expect(hasOpenTurn(session.events)).toBe(false) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(hasOpenTurn(session.events)).toBe(true) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(hasOpenTurn(session.events)).toBe(true) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(hasOpenTurn(session.events)).toBe(false) - }) - it('round-trips the coarse aborted turn outcome', () => { const session = new Session(SessionId('aborted')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8fc418baa5..5f27f121c4 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -27,7 +27,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, PermissionOption, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index bf41810dff..7beabd2696 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,8 +24,6 @@ export interface RpcMethodMap { 'session.selectModel': SessionsApi['selectModel'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] - 'session.permissions': SessionsApi['permissions'] - 'session.setPermission': SessionsApi['setPermission'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.openPath': HostApi['openPath'] diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index b7448021ac..8e4cdb5c5b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, PermissionOption, SessionProjectionsBlock, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -207,31 +207,3 @@ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> -/** One permission select option (a preset table key, or the derived `custom`). */ -export const permissionOptionSchema = z.object({ - value: z.string(), - name: z.string(), - description: z.string().optional(), -}) satisfies z.ZodType> - -/** session.permissions request payload. */ -export const sessionPermissionsRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** session.permissions response value. */ -export const sessionPermissionsValueSchema = z.object({ - options: z.array(permissionOptionSchema), - currentValue: z.string(), -}) satisfies z.ZodType>> - -/** session.setPermission request payload. */ -export const sessionSetPermissionRequestSchema = z.object({ - sessionId: sessionIdSchema, - value: z.string(), -}) satisfies z.ZodType>> - -/** session.setPermission response value. */ -export const sessionSetPermissionValueSchema = z.object({ - currentValue: z.string(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index a0560308dc..1a41a17f00 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -145,21 +145,6 @@ export interface SessionSummary { cwd?: string } -/** - * One selectable permission preset (or the derived `custom` state) as the - * client renders it. Protocol-owned DTO (the ACP bridge precedent: each - * protocol owns its presentation shape); the host projects it from - * `ctx.permission` without exposing that service's types on the wire. - */ -export interface PermissionOption { - /** The machine value (`session.setPermission` vocabulary): a preset table key, or `custom`. */ - value: string - /** The display label. */ - name: string - /** One user-facing sentence on what the value means. */ - description?: string -} - /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -216,23 +201,4 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> - /** - * Reads the session's permission select: every switchable preset plus the - * effective current value (`custom` when the knobs match no preset — shown, - * never a switch target). A host composed without the permission service - * returns empty options and `custom`; clients hide the control. - */ - permissions(request: RpcRequest<{ sessionId: SessionId }>): - Promise> - - /** - * Switches the session's permission preset. Mirrors the ACP bridge's - * turn-anchoring: inside an open turn the knob events append immediately; - * idle switches are held last-write-wins and flushed into the next prompted - * turn (approval-policy and sandbox-mode events must stay turn-enclosed for - * durable replay). A current-value echo is acknowledged without recording. - * Unknown values and a permission-less composition are bad-request. - */ - setPermission(request: RpcRequest<{ sessionId: SessionId; value: string }>): - Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 25888d19d6..ceca9fee7e 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -22,10 +22,8 @@ import { sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, - sessionPermissionsValueSchema, sessionPromptValueSchema, sessionSelectModelValueSchema, - sessionSetPermissionValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -61,8 +59,6 @@ export interface IApiClient { selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> - permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise>> - setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -103,8 +99,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.selectModel', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), - permissions: (payload, signal) => this.callUnary('session.permissions', payload, signal), - setPermission: (payload, signal) => this.callUnary('session.setPermission', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index c7de7854eb..505239c084 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -20,10 +20,8 @@ import { sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, - sessionPermissionsRequestSchema, sessionPromptRequestSchema, sessionSelectModelRequestSchema, - sessionSetPermissionRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, @@ -62,8 +60,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, - 'session.permissions': { schema: sessionPermissionsRequestSchema, invoke: (api, r) => api.sessions.permissions(r) }, - 'session.setPermission': { schema: sessionSetPermissionRequestSchema, invoke: (api, r) => api.sessions.setPermission(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index e0cb0c71e5..a744224814 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, api } } diff --git a/packages/host/apiproxy/tests/api-proxy-permission.spec.ts b/packages/host/apiproxy/tests/api-proxy-permission.spec.ts deleted file mode 100644 index 472a49a52b..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-permission.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Permission select over the proxy: permissions() projects the preset table - * plus the derived current value (custom shown only when derived), - * setPermission() validates against the table and anchors idle switches to - * the next prompted turn (the ACP bridge's pendingSwitches pattern), and a - * permission-less composition serves an empty select instead of an error. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import ApprovalService from '@deepseek-ai/dsh-user-approval' -import PermissionService from '@deepseek-ai/dsh-permission' -import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { createApiProxy } from '../src/api-proxy.ts' - -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} - -async function harness(options: { permission?: boolean } = {}): Promise<{ ctx: Context; api: ApiProxy; sessionId: SessionId }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.permission !== false) { - // The permission service requires a confining executor fact + approval. - ctx.provide('bash', { - sandboxMode: 'workspace-write', - resolve() { throw new Error('permission proxy tests do not execute bash') }, - run() { throw new Error('permission proxy tests do not execute bash') }, - start() { throw new Error('permission proxy tests do not execute bash') }, - }) - await ctx.plugin(ApprovalService) - await ctx.plugin(PermissionService, {}) - } - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) - // No agent-loop in this harness: register a bare live agent directly (the - // proxy only reaches `.session`); api-proxy-view.spec.ts precedent. - const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - return { ctx, api, sessionId: session.id } -} - -function expectOk(response: { result: { ok: true; value: T } | { ok: false } }): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -describe('session.permissions', () => { - it('projects the preset table with the effective current value; custom is absent when a preset matches', async () => { - const { api, sessionId } = await harness() - const value = expectOk<{ options: { value: string }[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value.currentValue).toBe('workspace-write') - expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access']) - }) - - it('serves an empty select (custom) on a permission-less composition', async () => { - const { api, sessionId } = await harness({ permission: false }) - const value = expectOk<{ options: unknown[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value).toEqual({ options: [], currentValue: 'custom' }) - }) - - it('appends the derived custom option when the knobs match no preset', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('sandbox/mode', { mode: 'read-only' }) - const value = expectOk<{ options: { value: string }[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value.currentValue).toBe('custom') - expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access', 'custom']) - }) - - it('propagates the agentFor error for a ghost session (persistence-less harness: internal)', async () => { - // The not-found/internal split is agentFor's documented gate and already - // covered by the history specs; here only the pass-through matters. - const { api } = await harness() - const response = await api.sessions.permissions(request({ sessionId: 'session-void' as SessionId })) - expect(response.result.ok).toBe(false) - }) -}) - -describe('session.setPermission', () => { - it('holds an idle switch pending (visible in permissions()) and flushes it into the next prompted turn', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - expect(agent).toBeDefined() - const switched = expectOk<{ currentValue: string }>( - await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) - expect(switched.currentValue).toBe('danger-full-access') - // No turn open: nothing appended yet; the pending value masks the fold. - expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) - const echoed = expectOk<{ currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(echoed.currentValue).toBe('danger-full-access') - - // The waterfall flush path: prompt-submit inside the new turn writes through. - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await ctx.waterfall('agent/prompt-submit', agent as never, [], { kind: 'user' } as never, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const })) - expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') - expect(agent?.session.events.map(e => e.type)).toContain('sandbox/mode') - expect(agent?.session.events.map(e => e.type)).toContain('approval/policy') - }) - - it('writes through immediately inside an open turn', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expectOk(await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) - expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') - }) - - it('acknowledges a current-value echo without recording a switch', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const echoed = expectOk<{ currentValue: string }>( - await api.sessions.setPermission(request({ sessionId, value: 'workspace-write' }))) - expect(echoed.currentValue).toBe('workspace-write') - expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) - }) - - it('propagates the agentFor error for a ghost session', async () => { - const { api } = await harness() - const response = await api.sessions.setPermission(request({ sessionId: 'session-void' as SessionId, value: 'workspace-write' })) - expect(response.result.ok).toBe(false) - }) - - it('rejects unknown values (custom included) and a permission-less composition as bad-request', async () => { - const { api, sessionId } = await harness() - for (const value of ['custom', 'nope']) { - const response = await api.sessions.setPermission(request({ sessionId, value })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - } - const bare = await harness({ permission: false }) - const response = await bare.api.sessions.setPermission(request({ sessionId: bare.sessionId, value: 'workspace-write' })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index c963b55950..2794aa13d2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -45,8 +45,6 @@ function scriptedApi(overrides: { }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), - permissions: r => ok(r, { options: [], currentValue: 'custom' }), - setPermission: r => ok(r, { currentValue: r.payload.value }), ...overrides.sessions, }, host: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d631fbc125..6c62501c60 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -73,12 +73,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, - async permissions(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { options: [], currentValue: 'custom' } } } - }, - async setPermission(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { currentValue: request.payload.value } } } - }, }, host: { async describe(request) { @@ -184,7 +178,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/permissions/setPermission/describe passthrough', async () => { + it('covers create/prompt/cancel/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) @@ -206,8 +200,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.sessions.permissions({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.sessions.setPermission({ sessionId: 's' as never, value: 'workspace-write' })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 021eab7b31..da2eafcbc7 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -11,7 +11,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -139,6 +138,22 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } +/** + * Whether the log currently sits inside an open turn (a `turn/start` not yet + * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. + * The audit pair must be turn-enclosed: the turn is the durable log's + * commit/replay boundary, so a bare event appended between turns is + * indistinguishable from a crash tail and silently dropped on reload. + */ +function hasOpenTurn(events: readonly SessionEvent[]): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const type = (events[index] as SessionEvent).type + if (type === 'turn/start') return true + if (type === 'turn/end') return false + } + return false +} + /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. From 1c762864722784d01d3b73d9e3cdbe4d49827b36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:38:40 +0800 Subject: [PATCH 04/23] feat(host): approval pending registry on the api gateway The approval/request waterfall answerer fills the slot the gateway reserved ('registry absent in this minimal version'): an ask through ctx.approval becomes an answerable approval/requested mux frame with a stable rpcId, mux open replays still-pending frames (refresh recovery), respond routes approvals first by the echoed rpcId and cross-checks the payload's audit correlation, the ask's abort signal withdraws with a broadcast cancelled, and approval/resolved settles every subscriber. The answerer pairs each ask with its approval/asked audit event by scanning for the newest undecided, unclaimed id (callId-matched when the ask names a call); asks that bypassed the audit path delegate to the fail-closed default. The child activates only when ctx.approval is composed; the 275-line spec carries over verbatim at the gateway's new home. --- packages/host/apiproxy/src/api-proxy.ts | 127 +++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56f488857f..310243f17f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,12 @@ import type {} from '@deepseek-ai/dsh-session-projection' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +// Side-effect type import: resolves the `approval/request` waterfall and +// `ctx.get('approval')` without a value dependency on the seam (optional composition). +import type {} from '@deepseek-ai/dsh-user-approval' +import { approvalResponsePayloadSchema } from './api/approvals.schema.ts' import { questionResponsePayloadSchema } from './api/questions.schema.ts' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' import { RpcId } from './api/rpc.ts' @@ -123,9 +129,9 @@ class FrameQueue { } /** - * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids - * for answerable frames belong to the approval/question registry, absent in - * this minimal version). + * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable + * frames — approval/question requested — mint their stable id in their + * pending registries instead). */ function frame(payload: F): RpcRequest { return { rpcId: RpcId(randomUUID()), payload } @@ -194,6 +200,36 @@ export interface ApiProxyDefaults { /** The tool/call payload fields the presenter path reads. */ interface ToolCallData { callId: string; name: string; arguments: string } +/** + * One outstanding approval question: the stable server-request id, the frame + * material replayed to late mux subscribers, and the resolver that settles the + * answerer's promise back into `ctx.approval`. + */ +interface PendingApproval { + rpcId: RpcId + sessionId: SessionId + approvalId: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + resolve(outcome: ApprovalOutcome): void +} + +/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ +function requestedFrame(pending: PendingApproval): RpcRequest { + return { + rpcId: pending.rpcId, + payload: { + type: 'approval/requested', + sessionId: pending.sessionId, + approvalId: pending.approvalId, + toolName: pending.toolName, + ...pending.callId === undefined ? {} : { callId: pending.callId }, + ...pending.reason === undefined ? {} : { reason: pending.reason }, + }, + } +} + /** One host-owned question wait, addressed by the stable server-request id. */ interface PendingQuestion { rpcId: RpcId @@ -371,6 +407,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Serializes path ownership checks with record creation across spellings. */ let workspaceCreationChain = Promise.resolve() const pendingQuestions = new Map() + const pendingApprovals = new Map() const muxQueues = new Set>>() /** @@ -519,6 +556,73 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, 'api-proxy: user-interaction provider') + // --- Approval pending registry ------------------------------------------ + // The proxy is the approval channel for every agent this host owns: an ask + // through `ctx.approval` becomes an answerable server-request on the mux + // stream (stable rpcId), settled by POST /api/respond. The entry survives + // client disconnects — mux-open replays still-pending requested frames with + // the same rpcId (the refresh-recovery baseline) — and withdraws on the + // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. + if (ctx.get('approval') !== undefined) { + ctx.on('approval/request', (req, next) => { + // The audit pair `approval/asked` is already appended by the service + // before dispatch, but dispatch rides a microtask: parallel tool calls + // can append several asked events before any answerer runs. THIS + // request's event is therefore the newest asked event that is still + // undecided, unclaimed by another pending entry, and — when the ask + // names a call — carries the same callId. + const events = req.agent.session.events + const claimed = new Set() + for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) + const decided = new Set() + let approvalId: ApprovalRequestId | undefined + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i] as SessionEvent + if (event.type === 'approval/decided') { + decided.add(event.data.id) + } else if (event.type === 'approval/asked') { + if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + if (req.callId !== undefined && event.data.callId !== req.callId) continue + approvalId = event.data.id + break + } + } + // No asked event means the request bypassed the service's audit path — + // not this channel's question; delegate to the fail-closed default. + if (approvalId === undefined) return next() + const id = approvalId + return new Promise((resolve) => { + const settle = (outcome: ApprovalOutcome): void => { + /* v8 ignore next 3 -- defensive double-settle guard: respond() routes + through the pending table (a settled id is not-pending before it can + re-settle) and the first settle removes the abort listener, so no + reachable path settles twice; kept against future settle callers. */ + if (!pendingApprovals.delete(pending.rpcId)) return + req.signal?.removeEventListener('abort', onAbort) + broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) + // A cancelled ask was already settled by the service's own signal + // race, which discards this late resolution; resolving is a no-op + // there and keeps this promise from dangling forever. + resolve(outcome) + } + const onAbort = (): void => { settle('cancelled') } + const pending: PendingApproval = { + rpcId: RpcId(randomUUID()), + sessionId: req.agent.session.id, + approvalId: id, + toolName: req.toolName, + ...req.callId === undefined ? {} : { callId: req.callId }, + ...req.reason === undefined ? {} : { reason: req.reason }, + resolve: settle, + } + pendingApprovals.set(pending.rpcId, pending) + req.signal?.addEventListener('abort', onAbort, { once: true }) + const envelope = requestedFrame(pending) + for (const queue of muxQueues) queue.push(envelope) + }) + }) + } + /** * Gate the cold path on the store: an id absent from it, or naming a legacy * log without a cwd (pre-release stance: not served, no compatibility), is @@ -1150,6 +1254,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }) } + // Refresh recovery: still-pending approval questions replay with their + // stable rpcId so a reconnecting client can still answer them. + for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) // Queue snapshot baseline (pendingQuestions precedent): frames replayed // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. @@ -1267,6 +1374,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, respond(message: ClientResponse): Promise { + // Route by the echoed rpcId (the wire correlation): approvals first, + // then questions — the two registries share one id space of UUIDs. + const approval = pendingApprovals.get(message.rpcId) + if (approval !== undefined) { + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) + // The payload's audit correlation must match the entry the rpcId routed + // to — a mismatched answer is malformed, not merely late. + if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approval.resolve(parsed.data.outcome) + return Promise.resolve({ accepted: true }) + } const pending = pendingQuestions.get(message.rpcId) if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) if (!message.result.ok) { From 451b7b0446b4220671cf46776983dd0022a20eef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:23:39 +0800 Subject: [PATCH 05/23] feat(permission): permissions projection unit and /permission command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read side becomes the 'permissions' session projection: src/types.ts is the key declaration's one home (PermissionSelect = whole select: table options in declaration order plus a current-only 'custom'), served through ./types and the ./client re-export. The unit folds the three whole-value knob events (permission/preset, sandbox/mode, approval/policy) into a plain KnobState and views the select over the composition defaults the service already owns; current() shares the same derive step, so the fold exists once. The write side becomes the /permission command (the /plan registration shape): bare invocation reports the current preset and the table, a preset argument switches through set() immediately — no turn anchoring (knob events need no enclosure), no dedicated RPC. Both children activate only when their registry is composed. --- packages/ui/permission/README.md | 3 +- packages/ui/permission/package.json | 16 +- packages/ui/permission/src/client.ts | 10 ++ packages/ui/permission/src/index.ts | 151 ++++++++++++++++-- packages/ui/permission/src/types.ts | 44 +++++ .../ui/permission/tests/projection.spec.ts | 116 ++++++++++++++ packages/ui/permission/tsconfig.json | 6 + pnpm-lock.yaml | 9 ++ 8 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 packages/ui/permission/src/client.ts create mode 100644 packages/ui/permission/src/types.ts create mode 100644 packages/ui/permission/tests/projection.spec.ts diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 6a59ad9425..814085ed6f 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. + ## Model Experience Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. @@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch. - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index 3ec4cc7685..c5021af37b 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -15,12 +15,21 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -28,22 +37,27 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/client.ts b/packages/ui/permission/src/client.ts new file mode 100644 index 0000000000..d758cf5960 --- /dev/null +++ b/packages/ui/permission/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the permission domain: a pure re-export of + * the package's types outlet. Client code imports ONLY the client namespace + * (repo discipline), so `./client` projects the same single-source content + * `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-permission/client + */ + +export type * from './types.ts' diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d44dff3df4..597d17199a 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -3,13 +3,16 @@ * approval-policy knobs. A switch records the selected preset, then writes * changed knobs through their canonical setters. Execution, prompt narration, * and replay keep reading their knob folds. The preset event preserves user - * intent when two presets share a bundle. + * intent when two presets share a bundle. The read side ships as the + * `permissions` session projection; the write side ships as the + * `/permission` command — both optional children over the same service. * * @module dsh-permission */ import { Context, Service } from 'cordis' import z from 'schemastery' +import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -18,6 +21,16 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-commands' +import type { PermissionSelect, PresetOption } from './types.ts' + +// The `permissions` projection-key declaration lives in src/types.ts (its one +// home); this re-export projects the type face onto the package root AND +// keeps the module edge in the emitted index.d.ts, so aggregate programs +// consuming the declarations still receive the SessionProjectionMap merge. +export type * from './types.ts' declare module 'cordis' { interface Context { @@ -49,16 +62,6 @@ export interface PresetSpec { description?: string } -/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ -export interface PresetOption { - /** Stable option value: the table key, or `custom`. */ - value: string - /** The display label. */ - name: string - /** One user-facing sentence on what the value means. */ - description?: string -} - /** * Returned when effective knob values match no table entry. Clients may show * it as the current value, but it is never a switch target or event payload. @@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri return undefined } +/** + * The projection unit's state: the last seen value of each knob event, null + * before an override (composition defaults apply at view time). Plain JSON + * (persisted-cache precondition). + */ +export interface KnobState { + /** Last `permission/preset` payload, or null. */ + preset: string | null + /** Last `sandbox/mode` payload, or null. */ + sandbox: SandboxMode | null + /** Last `approval/policy` payload, or null. */ + approval: ApprovalPolicy | null +} + +/** State for the empty log: every knob at its composition default. */ +const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null } + +/** + * One-event knob transition (the projection unit's `apply`). Uninterested + * events return the same reference — the registry's change gate. + * @param state - the folded knob state before `event`. + * @param event - one committed session event. + * @returns the next state; the same reference when the event is not a knob. + */ +export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState { + switch (event.type) { + case 'permission/preset': + return { ...state, preset: event.data.preset } + case 'sandbox/mode': + return { ...state, sandbox: event.data.mode } + case 'approval/policy': + return { ...state, approval: event.data.policy } + default: + return state + } +} + +/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */ +function foldKnobs(events: readonly SessionEvent[]): KnobState { + let state = EMPTY_KNOBS + for (const event of events) state = applyKnobEvent(state, event) + return state +} + /** The {@link PermissionService} config: the deployment's preset table. */ export interface Config { /** @@ -128,6 +175,55 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + + // The permissions projection unit: fold the three whole-value knob + // events; view derives the select over the composition defaults this + // service already owns. The unit child activates only when a projection + // registry is composed (headless assemblies stay unaffected). + // zod `.optional()` types the key `string | undefined` while the domain + // says `description?: string`; on the JSON wire the two serialize + // identically (absent), so the cast records exactly that + // exactOptionalPropertyTypes widening (the Wire precedent). + const selectSchema = zod.object({ + options: zod.array(zod.object({ + value: zod.string().min(1), + name: zod.string().min(1), + description: zod.string().optional(), + })), + currentValue: zod.string().min(1), + }) as unknown as zod.ZodType + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'permissions', KnobState>({ + key: 'permissions', + schema: selectSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + view: state => this.selectFor(state), + stateVersion: 1, + }) + }) + + // The /permission command: the one write path a web client uses (the + // popup contribution submits the picked preset as this line). The child + // activates only when a command registry is composed. + ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'permission', + description: 'Switch the permission preset (sandbox mode + approval policy)', + input: { hint: '' }, + handler: ({ agent, rawInput }) => { + const name = rawInput.trim() + if (name === '') { + return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` } + } + if (!this.names.includes(name)) { + return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` } + } + this.set(agent.session, name) + return { kind: 'success', text: `Permission preset: ${name}.` } + }, + }) + }) } /** @@ -146,13 +242,17 @@ export class PermissionService extends Service { * @returns the effective preset name, or `custom` when nothing matches. */ current(events: readonly SessionEvent[]): string { - const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode - const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' + return this.derive(foldKnobs(events)) + } + + /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ + private derive(state: KnobState): string { + const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode + const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(events) - if (folded !== undefined) { - const spec = this.presets[folded] - if (spec !== undefined && matches(spec)) return folded + if (state.preset !== null) { + const spec = this.presets[state.preset] + if (spec !== undefined && matches(spec)) return state.preset } for (const [name, spec] of Object.entries(this.presets)) { if (matches(spec)) return name @@ -160,6 +260,23 @@ export class PermissionService extends Service { return CUSTOM_PRESET } + /** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ + selectFor(state: KnobState): PermissionSelect { + const currentValue = this.derive(state) + return { + options: [ + ...this.names.map(name => this.optionOf(name)), + ...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [], + ], + currentValue, + } + } + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. diff --git a/packages/ui/permission/src/types.ts b/packages/ui/permission/src/types.ts new file mode 100644 index 0000000000..607cc96837 --- /dev/null +++ b/packages/ui/permission/src/types.ts @@ -0,0 +1,44 @@ +/** + * Pure types of the permission domain: the ONE home of the `permissions` + * projection-key declaration plus its payload types, free of this package's + * host-side value imports (cordis, schemastery). Two namespace projections + * serve it — the package root re-export for host consumers, `./client` (the + * browser half-entry's re-export) for client aggregates — with zero content + * duplication. + * + * @module @deepseek-ai/dsh-permission/types + */ + +/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ +export interface PresetOption { + /** Stable option value: the table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means; omitted when not configured. */ + description?: string +} + +/** + * Whole `permissions` projection value: every switchable preset in table + * order (plus the derived current-only `custom` when the knobs match no + * entry) and the effective current value. + */ +export interface PermissionSelect { + /** Switchable presets, plus `custom` appended exactly while it is current. */ + options: PresetOption[] + /** The effective current value: a preset table key, or `custom`. */ + currentValue: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** + * The session's permission select, folded from the three whole-value + * knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) + * over the composition defaults. Key absence means no permission service + * is composed — clients hide the control. + */ + permissions: PermissionSelect + } +} diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts new file mode 100644 index 0000000000..2046adb6a1 --- /dev/null +++ b/packages/ui/permission/tests/projection.spec.ts @@ -0,0 +1,116 @@ +/** + * The `permissions` projection unit and the `/permission` command: mounting + * the permission service beside the projection registry serves the whole + * select (table options + effective current value, `custom` appended exactly + * while derived) folded from the three knob events over the composition + * defaults; the command child registers `/permission` whose handler switches + * through `permission.set` (bare invocation reports, unknown names error); + * compositions without either registry are unaffected; unmounting the + * service removes the key (HMR safety). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import CommandService from '@deepseek-ai/dsh-commands' +import PermissionService from '@deepseek-ai/dsh-permission' +import type { Config } from '@deepseek-ai/dsh-permission' + +async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(CommandService) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { config: { policy: 'ask' } }) + if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {}) + return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) } +} + +/** Mint a scoped agent over a live session (the command executor's addressing shape). */ +async function agentFor(ctx: Context, session: Session): Promise { + const agent = { id: session.id, session } as Agent + await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] })) + return agent +} + +describe('permissions projection unit', () => { + it('serves the composition-default select at zero events', async () => { + const { ctx, session } = await harness() + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value).toMatchObject({ currentValue: 'workspace-write' }) + expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('folds the knob events and notifies the change feed per knob append', async () => { + const { ctx, session } = await harness() + const changes: { key: string; value: unknown; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + changes.push({ key, value, seq }) + }) + ctx.permission.set(session, 'danger-full-access') + // set() appends preset + sandbox/mode + approval/policy: three knob transitions. + expect(changes).toHaveLength(3) + expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) + // Unrelated event: same-reference apply, no notification. + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(changes).toHaveLength(3) + }) + + it('appends custom as a current-only option when the knobs match no preset', async () => { + const { ctx, session } = await harness() + session.append('sandbox/mode', { mode: 'read-only' }) + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value?.currentValue).toBe('custom') + expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' }) + }) + + it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => { + const { ctx, session } = await harness({ withPermission: false }) + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = await ctx.plugin(PermissionService, {}) + expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' }) + await fiber.dispose() + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) +}) + +describe('/permission command', () => { + it('switches through permission.set and logs the lifecycle pair', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' }) + expect(ctx.permission.current(session.events)).toBe('danger-full-access') + const run = session.events.find(event => event.type === 'command/run') + expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) + }) + + it('reports the current preset and the table on bare invocation', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) + expect(execution?.result).toEqual({ + kind: 'success', + text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.', + }) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + }) + + it('rejects an unknown preset without touching the log', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) + expect(execution?.result).toMatchObject({ kind: 'error' }) + expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + }) +}) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 0971399f53..493fbf358e 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -34,6 +34,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../session-projection/session-projection" + }, + { + "path": "../commands" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..3d1760f9e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4569,10 +4569,16 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4585,6 +4591,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval From 25265b312c2e3cb1788e0c195cc4f8ba7b0ac0e5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:56:43 +0800 Subject: [PATCH 06/23] feat(web): projection-fed permission chip replaces the Access placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionSelect returns as the Access seat's wired occupant: options and the current value read from the 'permissions' projection through the standard-kit useProjection (no fetch, no mount timing — the resident composer's mount-once fetch bug dies with the fetch), key absence renders nothing (permission-less host, or a Draft with no session), and a pick submits the '/permission ' command line through the new ComposerBarInjected.command callback (Session.command = command.execute admission; the pushed projection frame lands the confirmed value). The READONLY_OPTIONS placeholder and its local state leave InputBar. The connection fixture mirrors the host: a permissions unit fold (three knob events over the fixture preset table), the projections block + baseline/push frames carry the key, and /permission joins the command catalog with the same switch-through-knob-events handler shape. --- .../client/connection/src/client/fixture.ts | 69 ++++++++++++++++ .../connection/tests/fixture-commands.spec.ts | 2 +- .../client/connection/tests/fixture.spec.ts | 25 ++++-- .../runtime/src/client/sessions/session.ts | 15 ++++ packages/client/ui-conversation/package.json | 5 +- .../ui-conversation/src/client/apply.ts | 6 ++ .../src/client/contract/slots.ts | 6 ++ .../src/client/skeleton/InputBar.tsx | 31 +++---- .../skeleton/PermissionSelect.module.css | 49 ++++++++++++ .../src/client/skeleton/PermissionSelect.tsx | 80 +++++++++++++++++++ .../ui-conversation/tests/input-bar.spec.tsx | 36 +++++++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-conversation/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 56d8b90d34..3139cd3770 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -303,6 +303,42 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi } /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ +/** Fixture preset table (the host PermissionService defaults). */ +const PERMISSION_PRESETS: Record = { + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' }, +} + +/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */ +function permissionSelectOf(log: readonly SessionEvent[]): { options: { value: string; name: string; description?: string }[]; currentValue: string } { + let preset: string | null = null + let sandbox = 'workspace-write' + let approval = 'ask' + for (const event of log) { + const item = event as { type: string; data: Record } + if (item.type === 'permission/preset') preset = item.data['preset'] as string + else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string + else if (item.type === 'approval/policy') approval = item.data['policy'] as string + } + const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval + let currentValue = 'custom' + const folded = preset === null ? undefined : PERMISSION_PRESETS[preset] + if (preset !== null && folded !== undefined && matches(folded)) { + currentValue = preset + } else { + for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) { + if (matches(spec)) { currentValue = name; break } + } + } + return { + options: [ + ...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })), + ...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [], + ], + currentValue, + } +} + function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') @@ -311,6 +347,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, ], }) }, @@ -953,6 +1002,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // /permission mirrors the host handler: switch through the knob + // events (each append pushes a permissions projection frame). + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + } else if (spec === undefined) { + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + } + return ok(request, { matched: true as const, commandId }) + } const outcomes: Record = { compact: 'fixture:已压缩(假动作)', echo: args.trim(), diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index bd66d124a4..f4d5a290f7 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'permission']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 7ba49dd5f5..1f2d9f4f4e 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,7 +71,17 @@ describe('createFixtureApi', () => { if (!empty.result.ok) throw new Error('empty failed') // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write. expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } }, + events: [], hasMore: false, projections: { asOfSeq: -1, values: { + todos: null, + // Permission unit composed: the composition-default select. + permissions: { + options: [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ], + currentValue: 'workspace-write', + }, + } }, }) }) @@ -206,7 +216,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 4) abort.abort() + if (envelopes.length >= 5) abort.abort() } return envelopes } @@ -214,13 +224,14 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos units). + // Projection baseline frames follow the subscribed frame (title + todos + permissions units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) + expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[5]?.rpcId).toBe(first[5]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 00398d53af..855d28afd8 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -248,6 +248,21 @@ export class Session implements ObservableSnapshot { return result } + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + async command(line: string): Promise> { + try { + return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result + } catch (error) { + return transportError(error) + } + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 0b184dcbdf..fdf8386b0a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,13 +49,14 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 75ed09e78e..55416accc3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -150,6 +150,12 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + command: async (line) => { + const session = sessions.binding(sessionId)?.session + if (session === undefined) return false + const result = await session.command(line) + return result.ok && result.value.matched + }, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bddfafd6c7..379c33a8c8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -250,6 +250,12 @@ export interface ComposerBarInjected { keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ stop: () => void + /** + * Submit one slash-command line against this session's agent (the chrome + * controls' write path — the permission chip submits `/permission `). + * Resolves admission: false = rejected/unmatched/transport failure. + */ + command: (line: string) => Promise /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */ hooks: { /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 26a65def6a..821e309d89 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,12 +6,13 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' +import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Prompt failure surface (derived from promptError). */ @@ -22,13 +23,8 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps -const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ - { id: 'readonly', label: 'Read-only' }, - { id: 'readwrite', label: 'Read-write' }, -] - export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -58,9 +54,9 @@ export function InputBar({ }, 10) } - // Placeholder chrome: Access selection stays local until its seam lands - // (plan/model are real seats now — the named single slots below). - const [readonlyId, setReadonlyId] = useState('readonly') + // The Access seat's data: the host-computed permissions projection + // (undefined = capability absent → the chip renders nothing). + const permissions = useProjection('permissions') // Queue cut 1: running input stays free; locked = session disabled only. // The transient machine locks (adjudicating pending / submitting) render @@ -223,19 +219,10 @@ export function InputBar({ if (!empty && !disabled && !machineBusy) inputActions.submit('queue') } - // Access placeholder select (the one remaining local-chrome control). + // The Access seat: the projection-fed permission chip (renders nothing + // while the permissions key is absent — permission-less host or Draft). const accessSelect: ReactNode = ( - + ) // Mirror-layer decorations: a visible backdrop with transparent text. The diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css new file mode 100644 index 0000000000..dd5986992c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -0,0 +1,49 @@ +/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a + quiet text chip with a chevron; hover paints the standard interactive pill. + The native select is stretched invisibly over the chip so the platform + dropdown does the menu work — keyboard/AT semantics come free. */ + +.root { + position: relative; + display: inline-flex; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + pointer-events: none; /* the overlaid select owns the interaction */ +} + +.root:hover .chip { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + color: var(--dsw-alias-label-caption); +} + +/* Invisible native select stretched over the chip: real menu, zero drawing. */ +.select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + cursor: pointer; +} + +.select:disabled { + cursor: default; +} + +.root:has(.select:disabled) .chip { + opacity: 0.5; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx new file mode 100644 index 0000000000..0622e64500 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -0,0 +1,80 @@ +// PermissionSelect: the composer bottom-row permission chip (draft +// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant. +// Options and the current value read from the host-computed `permissions` +// projection (baseline block + push frames — no fetch, no mount timing); +// key absence (a permission-less composition, or a Draft with no host +// session yet) renders nothing. The visible chip is presentation only — an +// invisible native select stretched over it owns the menu and interaction. +// A switch submits the `/permission ` command line (the one write +// path); the control shows the picked value optimistically and disables +// until the admission result, then re-follows the projection — the pushed +// frame confirms the switch, and a failed/unmatched submit falls back to +// the still-authoritative projection value (`custom` is shown as the +// current value but never offered as a target — the host omits it from +// switchable options). + +import { useState } from 'react' +import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' +import css from './PermissionSelect.module.css' + +/** + * Display transform: kebab-case machine names render as title-case labels + * (`workspace-write` → `Workspace Write`). Presentation-only — the wire + * vocabulary and the host's advertised names are untouched; a host-configured + * name that is not kebab-case (contains spaces or uppercase) passes through. + */ +function displayName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} + +export interface PermissionSelectProps { + /** The host-computed select, or undefined while the capability is absent. */ + value: PermissionSelectValue | undefined + /** Session-removed lock (the bar's chrome disable state). */ + locked: boolean + /** Submit one slash-command line; resolves admission (false = rejected/unmatched). */ + command: (line: string) => Promise +} + +export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { + // Optimistic pick, shown while the admission round-trip runs; null follows + // the projection (the pushed frame lands the confirmed value there). + const [pick, setPick] = useState(null) + if (value === undefined) return null + + const currentValue = pick ?? value.currentValue + const current = value.options.find(option => option.value === currentValue) + + const onChange = (next: string): void => { + if (next === value.currentValue) return + setPick(next) + void command(`/permission ${next}`) + .catch(() => false) + .then(() => { setPick(null) }) + } + + return ( + + ) +} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8b9fd3d67..0aea847bfe 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -33,6 +33,7 @@ interface BenchOptions { modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> + permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string } draft?: string running?: boolean disabled?: boolean @@ -88,13 +89,14 @@ function bench(over?: BenchOptions) { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), - useProjection: (() => undefined), + useProjection: ((key: string) => (key === 'permissions' ? over?.permissions : undefined)) as InputBarProps['useProjection'], useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, + command: () => Promise.resolve(true), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -352,16 +354,37 @@ describe('strips and variants', () => { }) describe('placeholder chrome and control seats', () => { - it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => { + it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('Add attachment')).toBeTruthy() - expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + // Capability absent (no projection value): the chip renders nothing. + expect(view.queryByLabelText('Access mode')).toBeNull() // Both seats dispatched, nothing rendered. expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions }) + const select = view.getByLabelText('Access mode') as HTMLSelectElement + expect(select.value).toBe('workspace-write') + // Title-case display is presentation only; the option values stay machine names. + expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) + fireEvent.change(select, { target: { value: 'danger-full-access' } }) + // Optimistic pick + disable until admission resolves (command stub resolves true). + expect(select.disabled).toBe(true) + await act(async () => {}) + expect(select.disabled).toBe(false) + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, @@ -377,12 +400,13 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access placeholder and attach control (running does not)', () => { - const { view } = bench({ disabled: true }) + it('disabled locks the Access chip and attach control (running does not)', () => { + const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } + const { view } = bench({ disabled: true, permissions }) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true) cleanup() - const live = bench({ running: true }) + const live = bench({ running: true, permissions }) expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false) }) }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 8a04dde4a5..bcdecc9a02 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 4464f4876c..9d9ace032c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b6263929cf..0d32e2edea 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -123,6 +123,7 @@ function mount( useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} + command={() => Promise.resolve(true)} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 32ba48e8fe..9df35ff30f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/permission" } ], "exclude": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d1760f9e1..5834d885fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -982,6 +982,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection From c0e7c008cfea8316c2f9b0ec7272f4e849cee5eb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:59:12 +0800 Subject: [PATCH 07/23] feat(web): sandboxed executor family on the web roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/cli/cordis.yml swaps bash-local/fs-local for the acp-agent composition (sandbox-local + sandbox-policy + bash-sandbox + user-approval + permission + fs-sandbox; fs-policy composes on top unchanged). The deployment default stays danger-full-access + never — byte-for-byte the old unconfined behavior, so the replay e2e lane and demos are unaffected — while DSH_PERMISSION_MODE opts a process into a confined default and the /permission command switches per session. The permission preset table ships the three product presets (read-only/ask, workspace-write/ask, danger-full-access/never) explicitly in the deployment config. --- apps/cli/cordis.yml | 49 ++++++++++++++++++++++++++++++++++++++----- apps/cli/package.json | 8 +++++-- pnpm-lock.yaml | 22 ++++++++++++++----- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 0dbd6c839e..5d7e5188ea 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -120,8 +120,45 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: bash-local - name: '@deepseek-ai/dsh-bash-local' +# The sandboxed product path (the acp-agent composition): per-platform +# runner provider, the shared policy home, the confined bash executor, and +# the approval seam its escalation asks through. The web deployment default +# is danger-full-access + never (same behavior as the former bash-local +# rows); DSH_PERMISSION_MODE opts a process into a confined default, and +# per-session switches ride the /permission command's knob events. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + workspaceRoot: !!js process.cwd() + +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" + +# Presets over the two knobs (requires the confining executor + approval): +# the web permission chip's table, served through the permissions projection +# and switched through /permission. +- id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' @@ -133,9 +170,11 @@ name: '@deepseek-ai/dsh-tool-tasks' # fs cwd stays the package default (process.cwd()) — the same value the -# gateway injects into session.cwd, so paths and sessions agree. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' +# gateway injects into session.cwd, so paths and sessions agree. The +# sandboxed backend rides the SAME policy as bash: write/edit fence by the +# effective mode, so read/write/edit stay available under every mode. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' - id: fs-policy name: '@deepseek-ai/dsh-fs-policy' diff --git a/apps/cli/package.json b/apps/cli/package.json index 7d984ee1b1..0555262e90 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,7 +20,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -45,15 +45,18 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -84,6 +87,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5834d885fe..f95d050030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-local': + '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ - version: link:../../packages/bash/bash-local + version: link:../../packages/bash/bash-sandbox '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -203,12 +203,12 @@ importers: '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../packages/fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy @@ -227,9 +227,18 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -320,6 +329,9 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction From ff0627277402ad9e2e7367ed6b7281717c589690 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:38:22 +0800 Subject: [PATCH 08/23] feat(web): /permission popup picker (hostBacked contribution) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare /permission now opens a flat popupSelect of presets (current value active, custom excluded) instead of returning a text report — the /model pattern on a single-level list. The new dsh-client-ui-permission package registers the contribution; a pick submits '/permission ' through Session.command, so the picker, the composer chip, and the argued line all write through the one host command and follow the one pushed projection frame. Options and availability read the 'permissions' projection. ui-command gains the hostBacked contribution mode: a same-named host command is cooperation, not a collision — the host keeps the catalog row, the argument claim (space and argued-enter fall through to the host path), and the lifecycle logging, while the contribution supplies only the bare-invocation popup. The /permission command handler keeps its bare-line text report for host surfaces without a popup layer (TUI, raw execute). --- apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + apps/cli/tsconfig.json | 3 + .../client/ui-command/src/client/contract.ts | 12 +- .../client/ui-command/src/client/service.ts | 18 ++- .../client/ui-command/tests/service.spec.ts | 30 +++++ .../client/ui-permission/README.i18n.yaml | 6 + packages/client/ui-permission/README.md | 19 +++ packages/client/ui-permission/README.zh.md | 19 +++ packages/client/ui-permission/package.json | 61 ++++++++++ .../client/ui-permission/src/client/index.ts | 72 ++++++++++++ packages/client/ui-permission/src/index.ts | 9 ++ .../client/ui-permission/src/invariant.ts | 31 +++++ .../tests/browser-plugin.spec.ts | 108 ++++++++++++++++++ packages/client/ui-permission/tsconfig.json | 30 +++++ .../client/ui-permission/tsdown.config.ts | 3 + pnpm-lock.yaml | 24 ++++ tsconfig.client.json | 1 + 18 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 packages/client/ui-permission/README.i18n.yaml create mode 100644 packages/client/ui-permission/README.md create mode 100644 packages/client/ui-permission/README.zh.md create mode 100644 packages/client/ui-permission/package.json create mode 100644 packages/client/ui-permission/src/client/index.ts create mode 100644 packages/client/ui-permission/src/index.ts create mode 100644 packages/client/ui-permission/src/invariant.ts create mode 100644 packages/client/ui-permission/tests/browser-plugin.spec.ts create mode 100644 packages/client/ui-permission/tsconfig.json create mode 100644 packages/client/ui-permission/tsdown.config.ts diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5d7e5188ea..59542a4416 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -359,6 +359,10 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' +# The /permission popup picker (hostBacked over the host /permission command). +- id: ui-permission + name: '@deepseek-ai/dsh-client-ui-permission' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0555262e90..a80daa1259 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 05947889b2..46316430b8 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../packages/client/ui-models" }, + { + "path": "../../packages/client/ui-permission" + }, { "path": "../../packages/client/locale" }, diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index a9a1116664..e85adfb1f2 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -30,13 +30,23 @@ export type CommandUiSpec = { * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host * catalog by name — a collision with a host command fails loud at candidate - * synthesis, never shadows. + * synthesis, never shadows — UNLESS the contribution declares `hostBacked`: + * then the same-named host command owns execution and the contribution only + * supplies the bare-invocation picker (menu row stays the host's; a bare + * pick/enter opens the popup; a line with arguments falls through to the + * host command's own path). */ export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string /** Menu row description. */ readonly description: string + /** + * Cooperate with the same-named host command instead of colliding: the + * popup is the bare-invocation UI, the host command is the executor (its + * catalog row, argument claim, and lifecycle logging stand unchanged). + */ + readonly hostBacked?: true /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior (this phase: popupSelect only). */ diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 22f4a911b3..9abb2df483 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -139,6 +139,9 @@ export class CommandService extends Service implements CommandServiceContract { for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { + // hostBacked cooperates: the host's catalog row stands, the + // contribution only supplies the bare-invocation popup. + if (contribution.hostBacked === true) continue throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`) } rows.push({ name: contribution.name, description: contribution.description }) @@ -170,7 +173,10 @@ export class CommandService extends Service implements CommandServiceContract { private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined const name = token.slice(1) - if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space + // Popup kinds never claim on space; a hostBacked popup defers to the + // host command's own claim (the popup serves only the bare invocation). + const spaceContribution = this.live.contributions.get(name) + if (spaceContribution !== undefined && spaceContribution.hostBacked !== true) return undefined const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined || desc.input === undefined) return undefined return { claim: this.leadingClaim(desc, session) } @@ -192,9 +198,13 @@ export class CommandService extends Service implements CommandServiceContract { if (name === '') return undefined const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { - if (!bare) return undefined - this.openPopup(contribution, session, { via: 'enter', token }) - return 'handled' + if (bare) { + this.openPopup(contribution, session, { via: 'enter', token }) + return 'handled' + } + // hostBacked + arguments: the host command owns the argued path + // (claim or detached run below); a pure contribution stays bare-only. + if (contribution.hostBacked !== true) return undefined } await this.directory.ensureReady(session.sessionId, signal) const desc = this.directory.resolve(session.sessionId, name) diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index d3e6b5d34c..6af54ec941 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -197,6 +197,36 @@ describe('candidates', () => { command.register(themeContribution({ name: 'plan' })) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') }) + + it('a hostBacked contribution cooperates: the host row stands, no duplicate, no throw', async () => { + const { command, source } = await bench() + command.register(themeContribution({ name: 'goal', hostBacked: true })) + const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) + expect(names).toEqual(['plan', 'goal']) + }) +}) + +describe('hostBacked enter/space columns', () => { + it('bare enter opens the popup; an argued line falls through to the host claim', async () => { + const { command, source, mint, warm } = await bench() + command.register(themeContribution({ name: 'goal', hostBacked: true })) + const scope = mint('s1') + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled') + expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal) + if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') + expect(argued.claim.token).toBe('/goal ') + }) + + it('space defers to the host claim instead of the popup', async () => { + const { command, source, warm } = await bench() + command.register(themeContribution({ name: 'goal', hostBacked: true })) + await warm(proj('s1')) + const outcome = source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim') + expect(outcome.claim.token).toBe('/goal ') + }) }) describe('dispatch (menu column)', () => { diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml new file mode 100644 index 0000000000..b817ea593e --- /dev/null +++ b/packages/client/ui-permission/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md +README.md: '0000000000000000000000000000000000000000' +README.zh.md: '0000000000000000000000000000000000000000' diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md new file mode 100644 index 0000000000..1782f89c09 --- /dev/null +++ b/packages/client/ui-permission/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-client-ui-permission + +English | [中文](README.zh.md) + +Permission preset selection plugin, browser half: the `/permission` popupSelect contribution (registered through `ctx.command`). The contribution is `hostBacked` — the host's `/permission` command owns the slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; this entry supplies only the bare-invocation picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The contribution is available exactly while the projection key is present; a permission-less composition shows no picker. + +The `/client` export surface is the plugin body (`apply`/`inject`). + +## Model Experience + +Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content. + +#### KV Cache effect + +No direct invalidation; the knob consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work. diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md new file mode 100644 index 0000000000..6c750329df --- /dev/null +++ b/packages/client/ui-permission/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-client-ui-permission + +[English](README.md) | 中文 + +权限预设选择插件(浏览器半侧):`/permission` popupSelect contribution(经 `ctx.command` 注册)。该 contribution 是 `hostBacked`(宿主背书)的——host 的 `/permission` 命令拥有斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;本入口只提供裸调用的选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。contribution 恰在投影 key 存在时可用;无权限组合不显示选择框。 + +`/client` 导出面为插件本体(`apply`/`inject`)。 + +## Model Experience + +间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`),决定后续工具调用解析到的沙箱模式与审批策略。选择框交互本身不添加任何提示词内容。 + +#### KV Cache effect + +无直接失效;请求前缀的变化由旋钮消费方自行承担。 + +## Known Limitations and Deferred Work + +- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐。 diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json new file mode 100644 index 0000000000..cee54f104c --- /dev/null +++ b/packages/client/ui-permission/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-permission", + "description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-command" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-permission": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts new file mode 100644 index 0000000000..6998239db6 --- /dev/null +++ b/packages/client/ui-permission/src/client/index.ts @@ -0,0 +1,72 @@ +/** + * Permission preset plugin, browser half — the `/permission` popupSelect + * (the bare-invocation picker the user asked for: one flat list of presets, + * current value marked active, a pick executes the switch). The contribution + * is hostBacked: the host's `/permission` command owns the catalog row, the + * argued path (`/permission ` still switches directly), and the + * lifecycle logging — this entry only opens the picker on a bare pick/enter. + * Options and the active mark read the session's `permissions` projection + * (the same host-computed select the composer chip renders); a pick submits + * the `/permission ` command line, so both surfaces write through + * one path and the pushed projection frame is the one confirmation. + */ +import type { ClientContext, Session } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' +import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' + +/** Required services (cordis fiber inject). */ +export const inject = ['command', 'sessions'] + +/** Read one session's current permissions projection value (undefined = capability absent). */ +function selectOf(session: Session | undefined): PermissionSelect | undefined { + return session?.projections.get('permissions') as PermissionSelect | undefined +} + +/** Flatten the projection select into popup rows; `custom` is display state, never a target. */ +function optionsOf(value: PermissionSelect): SelectOption[] { + return value.options + .filter(option => option.value !== 'custom') + .map(option => ({ + id: option.value, + label: option.name, + ...(option.description !== undefined ? { detail: option.description } : {}), + ...(option.value === value.currentValue ? { active: true } : {}), + })) +} + +/** + * Client plugin body: register the /permission popup picker over the + * permissions projection. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const command = ctx.get('command') as CommandServiceContract + const sessions = ctx.sessions + const sessionFor = (session: ClientSessionContext): Session | undefined => + sessions.binding(session.sessionId)?.session + ctx.effect(() => command.register({ + name: 'permission', + description: 'Switch the permission preset (sandbox mode + approval policy)', + hostBacked: true, + // The picker exists exactly while the projection does: a permission-less + // host serves no key and the bare invocation falls through to the host + // command (which is absent too — the line simply misses). + available: session => selectOf(sessionFor(session)) !== undefined, + ui: { + kind: 'popupSelect', + options: (session) => { + const value = selectOf(sessionFor(session)) + if (value === undefined) throw new Error('permission presets are not available on this host') + return Promise.resolve(optionsOf(value)) + }, + onSelect: async (option, session) => { + const live = sessionFor(session) + if (live === undefined) throw new Error('this session is not materialized yet') + const result = await live.command(`/permission ${option.id}`) + if (!result.ok) throw new Error(`permission switch failed: ${result.error.code}: ${result.error.message}`) + if (!result.value.matched) throw new Error('the host offers no /permission command') + }, + }, + }), 'ui-permission: /permission contribution') +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts new file mode 100644 index 0000000000..5359562972 --- /dev/null +++ b/packages/client/ui-permission/src/index.ts @@ -0,0 +1,9 @@ +/** + * Permission preset selection plugin, node half. Pure UI plugin: the empty + * apply exists so the plugin appears in the host cordis.yml / Loader; the + * browser half ships via exports["./client"], discovered through the + * package.json dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts new file mode 100644 index 0000000000..c0fd33a80b --- /dev/null +++ b/packages/client/ui-permission/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-permission`. + * @module @deepseek-ai/dsh-client-ui-permission/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-permission-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a single command contribution registration whose disposal is + * proven by the HMR-safety spec — it emits no cordis events and owns no + * cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..33fc94b3a0 --- /dev/null +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -0,0 +1,108 @@ +/** + * ui-permission browser half on a real cordis Context with fake command/ + * sessions faces: the plugin registers the hostBacked /permission popup + * contribution; options flatten the session's permissions projection with + * the current value active and `custom` excluded; availability follows the + * projection key's presence; a pick submits the /permission line through + * Session.command and surfaces rejection/unmatched as thrown errors; fiber + * disposal removes the contribution (HMR safety). + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandContribution } from '@deepseek-ai/dsh-client-ui-command/client' +import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { apply, inject } from '../src/client/index.ts' + +const sid = (k: string): SessionId => k as SessionId + +const SELECT: PermissionSelect = { + options: [ + { value: 'read-only', name: 'read-only', description: 'Reads only.' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', +} + +async function bench() { + const ctx = new Context() + let contribution: CommandContribution | undefined + ctx.provide('command', { + register(c: CommandContribution) { + contribution = c + return () => { contribution = undefined } + }, + }) + const values = new Map() + const commands: string[] = [] + let commandResult: { ok: boolean; matched?: boolean } = { ok: true, matched: true } + const session = (id: SessionId) => ({ + projections: { get: (key: string) => (key === 'permissions' ? values.get(id) : undefined) }, + command: (line: string) => { + commands.push(line) + return Promise.resolve(commandResult.ok + ? { ok: true as const, value: { matched: commandResult.matched ?? true } } + : { ok: false as const, error: { code: 'internal', message: 'boom' } }) + }, + }) + ctx.provide('sessions', { + binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), + }) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { + ctx, fiber, values, commands, + setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, + contribution: () => contribution, + } +} + +describe('ui-permission browser plugin', () => { + it('registers the hostBacked /permission popup contribution', async () => { + const b = await bench() + const c = b.contribution()! + expect(c.name).toBe('permission') + expect(c.hostBacked).toBe(true) + expect(c.ui.kind).toBe('popupSelect') + }) + + it('availability follows the projection key; options mark the current value active and exclude custom', async () => { + const b = await bench() + const c = b.contribution()! + const proj = { sessionId: sid('s1') } + expect(c.available(proj)).toBe(false) + b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' }) + expect(c.available(proj)).toBe(true) + const options = await c.ui.options(proj, new AbortController().signal) + expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access']) + expect(options.every(option => option.active !== true)).toBe(true) + b.values.set(sid('s1'), SELECT) + const again = await c.ui.options(proj, new AbortController().signal) + expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) + expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') + }) + + it('a pick submits the /permission line; rejection and unmatched throw', async () => { + const b = await bench() + const c = b.contribution()! + const proj = { sessionId: sid('s1') } + b.values.set(sid('s1'), SELECT) + await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj) + expect(b.commands).toEqual(['/permission danger-full-access']) + b.setResult({ ok: false }) + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/) + b.setResult({ ok: true, matched: false }) + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/) + // An unmaterialized session throws before any submit. + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') })) + .rejects.toThrow(/not materialized/) + }) + + it('disposal removes the contribution (HMR safety)', async () => { + const b = await bench() + expect(b.contribution()).toBeDefined() + await b.fiber.dispose() + expect(b.contribution()).toBeUndefined() + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json new file mode 100644 index 0000000000..b66ce746b2 --- /dev/null +++ b/packages/client/ui-permission/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-command" + }, + { + "path": "../ui-slash" + }, + { + "path": "../../ui/permission" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-permission/tsdown.config.ts b/packages/client/ui-permission/tsdown.config.ts new file mode 100644 index 0000000000..a98451eaa7 --- /dev/null +++ b/packages/client/ui-permission/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-permission', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f95d050030..60e787100b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question @@ -1106,6 +1109,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-permission: + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-primitives: dependencies: '@shikijs/langs': diff --git a/tsconfig.client.json b/tsconfig.client.json index 9f67ab4af7..467577855b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -43,6 +43,7 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-model" }, + { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, From db0292ffdb0d10e3c144d5f67a70c7661286cb6c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:55:00 +0800 Subject: [PATCH 09/23] docs: regenerate catalogs and sync bilingual READMEs for the permission surfaces gen-cordis-catalog/api, config/persistence catalogs, module graph, and doc graphs pick up the ui-permission package, the permissions projection key, and the /permission command; KnobState and PermissionSelect join the type-link exemptions (owned by the permission package's own docs), and ui-permission joins the sentence Model Experience allowlist (indirect via the host command). The ui-conversation and dsh-permission READMEs gain their zh halves for the approval/chip and projection/command paragraphs; all three touched pairs re-record. --- docs/config-catalog.md | 5 +++-- docs/cordis-catalog/services.md | 10 +++++++++- docs/event-producer-consumer.md | 6 +++--- docs/module-graph.md | 11 ++++++++++- docs/persistence-catalog.md | 8 ++++---- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 4 +++- packages/client/ui-permission/README.i18n.yaml | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 12 ++++++++++++ packages/ui/permission/README.i18n.yaml | 6 +++--- packages/ui/permission/README.zh.md | 3 ++- scripts/gen-cordis-catalog.ts | 2 ++ scripts/verify-package-readme-model-experience.ts | 1 + 14 files changed, 57 insertions(+), 21 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a611cd8439..8e3c23b6f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -832,7 +832,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-plan-mode` @@ -1971,7 +1971,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:183`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` @@ -2154,6 +2154,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac556aaf08..2197f2d0ad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -815,6 +815,14 @@ Owns the deployment's permission presets and their write path. Requires a confin */ current(events: readonly SessionEvent[]): string +/** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ +selectFor(state: KnobState): PermissionSelect + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. @@ -843,7 +851,7 @@ set(session: Session, name: string): void Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts) ## `ctx.planMode` — `PlanModeService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..0124aebea6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,7 +23,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -63,8 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `commands/changed` | `runtime` (`emit`) | - | -| `connection/reset` | `runtime` (`emit`) | - | +| `commands/changed` | `runtime` (`emit`) | `ui-command` | +| `connection/reset` | `runtime` (`emit`) | `ui-command` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 729745f7ad..95138f6142 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -146,6 +146,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] + pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -562,10 +563,12 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_permission --> pkg_bash + pkg_permission --> pkg_commands pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session + pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants @@ -712,6 +715,11 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1015,7 +1023,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | @@ -1040,6 +1048,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4f839e60f9..3c157fae97 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -105,7 +105,7 @@ Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -121,7 +121,7 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv } ``` -Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -137,7 +137,7 @@ Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approv 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -342,7 +342,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts) ### `plan/*` diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4d5a458d7d..b5761466eb 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 51ddecf93240c2196483d3fb2bcfaca4104da31a -README.zh.md: d98cbcc69b875d2f426d9bdd9f2fa81874ec614a +README.md: 9f96883ad34d52e75211b9bea16f7273ded370ae +README.zh.md: ee766a23378a7ff21ceae4015addc19900c7c2b9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0f86b6241e..9f96883ad3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission ` command line through the bar's injected `command` callback. Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d98cbcc69b..ee766a2337 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,6 +12,8 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。问题占位符仍以只读 PendingCard 形式留在消息流中,应答接管归 ui-question 所有。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 + todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 @@ -34,5 +36,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。 - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 -- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml index b817ea593e..0b2d5de3c5 100644 --- a/packages/client/ui-permission/README.i18n.yaml +++ b/packages/client/ui-permission/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md -README.md: '0000000000000000000000000000000000000000' -README.zh.md: '0000000000000000000000000000000000000000' +README.md: 1782f89c0909ea80f8554bb9b271947a39ae7f8f +README.zh.md: 6c750329df5bfb25f738869eeb82ea26fa1b8634 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 311f7fc592..a46aef6fb8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -410,6 +410,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'current(events: readonly SessionEvent[]): string', jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, + { + signature: 'selectFor(state: KnobState): PermissionSelect', + jsDoc: '/**\n * Build the whole select value for one folded knob state: every table\n * option in declaration order, `custom` appended exactly while derived.\n * @param state - the folded knob overrides.\n * @returns the `permissions` projection payload.\n */', + }, { signature: 'resolve(name: string): PresetSpec', jsDoc: '/**\n * Resolve a preset\'s knob bundle.\n * @param name - the preset name to resolve.\n * @returns the configured bundle.\n * @throws when `name` is not in the table.\n */', @@ -1767,6 +1771,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'KnobState', + declaration: 'export interface KnobState {\n preset: string | null;\n sandbox: SandboxMode | null;\n approval: ApprovalPolicy | null;\n}', + }, { name: 'KvTable', declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', @@ -1835,6 +1843,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, + { + name: 'PermissionSelect', + declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', + }, { name: 'PreparedLlmCall', declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index c29bf60910..f6f5f49a14 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa -README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e +# pnpm run verify-translation-pairing --write packages/ui/permission/README.md +README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 +README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 0e7db1bd41..36880d6b8c 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -8,6 +8,8 @@ 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 + ## 模型体验 间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 @@ -18,7 +20,6 @@ ## 已知限制与延期工作 -- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 - **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f35c5b4547..a22a9b1c37 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -249,6 +249,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', + KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', + PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 55108269a3..8f425312e1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -58,6 +58,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, + 'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, From 8c0006a8728ab63d6c479dcf25ebbe4b68311de0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:00:56 +0800 Subject: [PATCH 10/23] style: satisfy lint on the permission-line diff Two overlong lines wrap (fixture permissionSelectOf signature, apply.ts type-import list) and three test-side unnecessary assertions drop (eslint --fix). --- packages/client/connection/src/client/fixture.ts | 4 +++- packages/client/ui-conversation/src/client/apply.ts | 3 ++- .../client/ui-conversation/tests/chat-branch-tails.spec.tsx | 2 +- packages/client/ui-conversation/tests/chat-view.spec.tsx | 2 +- packages/client/ui-conversation/tests/input-bar.spec.tsx | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3139cd3770..658c2f9725 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -310,7 +310,9 @@ const PERMISSION_PRESETS: Record { describe('small branch tails', () => { it('PendingCard renders the question count', () => { const view = render( - ['payload'], vi.fn())} />, + , ) expect(view.getByText(/等待回答(1 题)/)).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 896320d9d2..775fd6a954 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -398,7 +398,7 @@ describe('ChatView', () => { new PendingWait('approval', RpcId('r1'), SID, { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), new PendingWait('question', RpcId('r2'), SID, - { questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn()), + { questions: [{ id: 'q1', question: '选择' }] }, vi.fn()), ], }) const view = render() diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 0aea847bfe..3e7077ca0b 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -89,7 +89,7 @@ function bench(over?: BenchOptions) { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), - useProjection: ((key: string) => (key === 'permissions' ? over?.permissions : undefined)) as InputBarProps['useProjection'], + useProjection: ((key: string) => (key === 'permissions' ? over?.permissions : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, From 728d28e7e4787c987dd45e2701e77b109829ea88 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:06:19 +0800 Subject: [PATCH 11/23] test: close the coverage gaps on the permission line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real branches gain assertions (effectivePermissionPreset's backward scan over non-preset events; the popup options() throw when the projection vanished between availability and open — a sync throw, not a rejection). The empty ui-permission node half joins the ui-model entry on the client-lane coverage debt list (same pure-UI plugin shape, same TODO). --- packages/client/ui-permission/tests/browser-plugin.spec.ts | 3 +++ packages/ui/permission/tests/permission.spec.ts | 3 +++ vitest.config.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 33fc94b3a0..b0beb2b9b9 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -81,6 +81,9 @@ describe('ui-permission browser plugin', () => { const again = await c.ui.options(proj, new AbortController().signal) expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') + // A projection that vanished between availability and open throws. + expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal)) + .toThrow(/not available on this host/) }) it('a pick submits the /permission line; rejection and unmatched throw', async () => { diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 864f25629d..05a747de8d 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -34,6 +34,9 @@ describe('effectivePermissionPreset', () => { session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) expect(effectivePermissionPreset(session.events)).toBe('workspace-write') + // The backward scan steps over non-preset events to the latest selection. + session.append('sandbox/mode', { mode: 'read-only' }) + expect(effectivePermissionPreset(session.events)).toBe('workspace-write') }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 16f28449a5..55f172416f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -138,6 +138,7 @@ export default defineConfig({ 'packages/client/ui-command/src/client/service.ts', 'packages/client/ui-command/src/client/PopupSelectView.tsx', 'packages/client/ui-model/src/index.ts', + 'packages/client/ui-permission/src/index.ts', 'packages/client/ui-model/src/client/ModelSelect.tsx', 'packages/client/ui-model/src/client/directory.ts', 'packages/client/ui-model/src/client/index.ts', From 667b0aff871e974b0f2b3d33a52e2bc4b9cf88be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:15:31 +0800 Subject: [PATCH 12/23] test(web): answer the fixture approval takeover; refresh aria goldens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembled fixture app now takes over the composer while fx-alpha's resident approval is pending — the feature under test, not chrome noise — so the session-title snapshot answers it (允许一次) before asserting the model seat, mirroring what a user sees. The eleven keyless ui.expected.md goldens re-record (DSH_SNAPSHOT=refresh, no key) for the Access placeholder → projection chip swap (Danger Full Access under the roster default) plus the master-side chrome drift the goldens had accumulated (copy/branch/edit buttons, tab rows) since their last refresh. --- apps/web/tests/session-title.snapshot.ts | 4 +++ .../snapshots/code-mode-round/ui.expected.md | 27 +++++++++++++------ .../cordis-tool-round/ui.expected.md | 17 +++++++++--- .../snapshots/fresh-round-trip/ui.expected.md | 22 ++++++++++----- .../lifecycle-chrome/hero.expected.md | 11 +++++--- .../lifecycle-chrome/reloaded.expected.md | 18 +++++++++---- .../live-interactions/cancel.expected.md | 17 ++++++++---- .../live-interactions/error-auth.expected.md | 17 ++++++++---- .../live-interactions/retry.expected.md | 18 +++++++++---- .../question-composer/answered.expected.md | 14 ++++++---- .../snapshots/steering/mid-steer.expected.md | 14 +++++++--- .../snapshots/steering/settled.expected.md | 20 ++++++++++---- 12 files changed, 144 insertions(+), 55 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 67881d003c..cc08da6cc0 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -119,6 +119,10 @@ it('projects titles and routes the next turn through the selected model in the b await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) }) const revised = titleSurfaces(revisedLabel) + // fx-alpha carries the fixture's resident answerable approval, so the + // approval panel has taken over the composer (the real takeover behavior); + // answer it to restore the composer chrome before asserting the model seat. + fireEvent.click(await screen.findByRole('button', { name: '允许一次' })) const modelTrigger = await screen.findByRole('button', { name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ea364a44db..4847924619 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -1,21 +1,30 @@ - banner: - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - 'button "Think The user wants me to write a single `run_code` program that:"': + - img - img - text: "Think The user wants me to write a single `run_code` program that:" - button: - img -- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK -- button -- text: Read missing.txt + - img +- text: Code Run bash echo and catch missing file read +- img +- text: Bash Echo CODE_ROUND_OK Read +- button "missing.txt" - button "Think The program ran successfully. Let me now reply DONE as instructed.": + - img - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE @@ -23,10 +32,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e2a1275f6c..e5e5626be3 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -1,7 +1,6 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -13,14 +12,16 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to:": + - img - img - text: "Think The user wants me to:" - button: - img + - img - text: Inspect temporary - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': + - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." - button [expanded]: @@ -29,12 +30,15 @@ - button "复制" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': + - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." - button: - img + - img - text: Unmount temporary Plugin dyn-1 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": + - img - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE @@ -42,7 +46,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index dc572023fd..6a827420c4 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,17 +1,25 @@ - banner: - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": + - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- text: Echo the test string +- img +- text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": + - img - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE @@ -19,10 +27,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 2f958fa552..ee6c1a7475 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -1,3 +1,4 @@ +- button "New session" - button "Collapse sidebar": - img - button "New session": @@ -27,11 +28,13 @@ - textbox "Describe what you want to build" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] - text: 详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 056bd71144..33d1f7e6bf 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -1,13 +1,19 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with the single word LIGHTHOUSE and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to reply with a single word. Let me comply.": + - img - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE @@ -15,10 +21,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 972062a3c5..3d092b17ec 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -1,21 +1,28 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - paragraph: partial - text: 已停止 0 tokens · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 64bdb9c39b..5272bcf2d1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -1,19 +1,26 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 9d41e87f41..5935872557 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -1,13 +1,19 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": + - img - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. @@ -15,10 +21,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 94179416a4..91ff2cdf88 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -1,7 +1,6 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -14,12 +13,15 @@ - button "编辑": - img - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. - button: - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}" - button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.": + - img - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE @@ -27,10 +29,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index a26bbb7bd8..8d33ea6283 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -1,19 +1,27 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button +- button: + - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)" - button "▸ 问题内容" -- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps +- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index a5735a09d5..f08fc518e8 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -1,19 +1,27 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. - button: - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": + - img - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! @@ -21,10 +29,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] From 33a6cd33688a224c7d795da26dad26a06e82c164 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:30:55 +0800 Subject: [PATCH 13/23] ci: retrigger checks for d4450d049 From a559c524f553018c9ca6e18a4f238aea77bc1877 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:54:02 +0800 Subject: [PATCH 14/23] test(test-runtime): cover the command fail-loud stub --- packages/client/test-runtime/tests/runtime.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index e63fcc3821..70274717b3 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -442,6 +442,7 @@ describe('fixture session face', () => { const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) + expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) await runtime.dispose() }) From efc7c14a7c2b12f7b78c071f61c21e8d6f9f9780 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:06:39 +0800 Subject: [PATCH 15/23] docs: regenerate the module graph over the merged dependency set --- docs/module-graph.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..a47aa4b9e6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -148,6 +148,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] + pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] @@ -576,10 +577,12 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_permission --> pkg_bash + pkg_permission --> pkg_commands pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session + pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_runtime @@ -734,6 +737,11 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1047,7 +1055,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1073,6 +1081,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From 32774d5ff367b34b57b11eb51f7cf0986ee7e707 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 09:29:43 +0800 Subject: [PATCH 16/23] Update AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 93299d01e4..1c0b76404d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. Pushed PR history may be rewritten before merge. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Use incremental merge commits.** Split independent changes. never squash, rebase, or rewrite pushed history unless it's an unmerged PR. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 9f5b639a084d0689d32892248aa6690c4aee5748 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:27:32 +0800 Subject: [PATCH 17/23] docs: prefer new commits after review --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1c0b76404d..8f183e6f8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. never squash, rebase, or rewrite pushed history unless it's an unmerged PR. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 62c1f155768dfd256b85383f8510c3fa9fa801d3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:33:44 +0800 Subject: [PATCH 18/23] fix(client): command lifecycle rows keep the composer blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a preset from the hero pushed the session into the conversation view: the /permission switch logs its command/run + command/done pair, the pair folds into flow nodes, and the composerPhase predicate counted ANY node as conversation — so the hero (composerPhase === 'blank') collapsed. The host-side blank bit was already correct (sessionBlank = no turn/start; knob events open no turn), but the client derives its phase from window content, and command rows are log-only records, not conversation. derivePhase's hasContent now excludes command nodes — the client mirror of the host predicate. The knob events themselves never fold (not surface-eligible), so the pair was the only leak. Covers /plan on the hero identically (same lifecycle pair, same predicate). Specs: the host blank spec pins the three knob events as standalone events; a session spec drives the /permission pair through the live path and asserts phase stays 'blank' while the command node renders. --- .../client/runtime/src/client/sessions/session.ts | 7 +++++-- packages/client/runtime/tests/session.spec.ts | 15 +++++++++++++++ .../host/apiproxy/tests/api-proxy-blank.spec.ts | 10 +++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 3549422f90..371c74d0a3 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -827,7 +827,10 @@ export class Session implements SessionFace { queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( - nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, + // Command lifecycle nodes are not conversation: running /permission + // or /plan on a fresh session keeps the hero (the client mirror of + // the host's no-turn sessionBlank predicate). + nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -848,7 +851,7 @@ export class Session implements SessionFace { * object: `hasContent` only grows within a window and `promptAttempted` is * sticky, so blank → engaging → active never steps back; a failed first * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits). + * @param hasContent - any conversation material exists (non-command nodes, partial, running turn, pending waits; command lifecycle rows alone keep the session blank). * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index ca9193eda1..c7be330d55 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -126,6 +126,21 @@ describe('live event path', () => { }) }) + it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { + // A fresh session whose only window content is a command pair (plus the + // knob events a /permission switch appends — not surface-eligible, so + // they never become nodes) stays phase 'blank': selecting a preset from + // the hero must not enter the conversation view. + const { session } = await opened([]) + expect(session.getSnapshot().composerPhase).toBe('blank') + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) + feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.')) + const snapshot = session.getSnapshot() + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(snapshot.composerPhase).toBe('blank') + }) + it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e5bd8bfbee..36d51a7542 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -1,7 +1,7 @@ /** * The summary blank bit means "conversation not started" (no turn has run), * not "log empty": standalone plugin events — command lifecycle records, - * plan/mode, session titles — never flip it, so running /plan or /goal on a + * plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a * fresh session keeps it list-hidden and reusable, while the first accepted * prompt's turn/start clears it. The host/session-added frame shares the * same predicate function (covered by the workspace spec's frame assertion). @@ -15,6 +15,10 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { CommandId } from '@deepseek-ai/dsh-commands/brand' +// Side-effect type imports: the knob-event SessionEventMap merges. +import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -48,6 +52,10 @@ function appendStandalone(session: Session): void { session.append('session/title', { title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' }, }) + // The three permission knob events (a /permission switch on a fresh session). + session.append('permission/preset', { preset: 'danger-full-access' }) + session.append('sandbox/mode', { mode: 'danger-full-access' }) + session.append('approval/policy', { policy: 'never' }) } async function listBlank(api: ApiProxy, id: string): Promise { From 79dbe4c6fb478818ed9a5dffe66c163dc16c7375 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:45:01 +0800 Subject: [PATCH 19/23] style: wrap the derivePhase JSDoc line --- packages/client/runtime/src/client/sessions/session.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 371c74d0a3..0f5d39ac8e 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -851,7 +851,9 @@ export class Session implements SessionFace { * object: `hasContent` only grows within a window and `promptAttempted` is * sticky, so blank → engaging → active never steps back; a failed first * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (non-command nodes, partial, running turn, pending waits; command lifecycle rows alone keep the session blank). + * @param hasContent - any conversation material exists (non-command nodes, + * partial, running turn, pending waits; command lifecycle rows alone keep + * the session blank). * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ From 83c2115de885e0fc4e8a2af9d0f01e15ca3d1bef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:01:36 +0800 Subject: [PATCH 20/23] refactor(client): command decorations replace the hostBacked contribution mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A popup on a host command is not a second command — it is what that command's BARE invocation does on this client. CommandContribution loses hostBacked (contributions are pure client commands again; a host-name collision fails loud, unchanged for /model), and the contract gains CommandDecoration + command.decorate(): key = the HOST command name, no catalog row, no claim participation. Dispatch consults decorations only on the bare paths (menu pick / bare enter) after the host row resolves; space and argued enter never see them — the two edges hostBacked had to guard explicitly hold by construction in the decoration model. A decorated name with no host row in the session's directory never fires (a decoration cannot manufacture a command). ui-permission switches register→decorate with zero behavior change (options still read the permissions projection; a pick still submits '/permission '). Specs rewrite to the decoration semantics: no catalog row, bare-enter popup vs argued-enter host claim, space host claim, no-host-row miss, unavailable fall-through, duplicate fail-loud. --- packages/client/ui-command/README.i18n.yaml | 6 +- packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/contract.ts | 35 +++++++--- .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/service.ts | 69 +++++++++++++------ .../client/ui-command/tests/service.spec.ts | 50 +++++++++++--- .../client/ui-permission/README.i18n.yaml | 4 +- packages/client/ui-permission/README.md | 2 +- packages/client/ui-permission/README.zh.md | 2 +- .../client/ui-permission/src/client/index.ts | 26 ++++--- .../tests/browser-plugin.spec.ts | 31 ++++----- 12 files changed, 150 insertions(+), 81 deletions(-) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index 6d15efd511..00aa8a1e43 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b -README.zh.md: 1291556409b993aa893e102386f75c45bb195adf +# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md +README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da +README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 17bc4edd7d..64d06f1d9b 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). -`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. +`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 1291556409..0cec3b8c8f 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -4,7 +4,7 @@ 客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 -`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 +`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index e85adfb1f2..61ab4de2e2 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -30,29 +30,37 @@ export type CommandUiSpec = { * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host * catalog by name — a collision with a host command fails loud at candidate - * synthesis, never shadows — UNLESS the contribution declares `hostBacked`: - * then the same-named host command owns execution and the contribution only - * supplies the bare-invocation picker (menu row stays the host's; a bare - * pick/enter opens the popup; a line with arguments falls through to the - * host command's own path). + * synthesis, never shadows. */ export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string /** Menu row description. */ readonly description: string - /** - * Cooperate with the same-named host command instead of colliding: the - * popup is the bare-invocation UI, the host command is the executor (its - * catalog row, argument claim, and lifecycle logging stand unchanged). - */ - readonly hostBacked?: true /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior (this phase: popupSelect only). */ readonly ui: CommandUiSpec } +/** + * A UI decoration hung on one HOST command: what its BARE invocation does on + * this client. Not a second command — the host command keeps its catalog + * row, its argument claim (space / argued enter), and its lifecycle logging; + * the decoration replaces only the bare menu-pick/enter with a popup whose + * onSelect typically submits a completed line back through command.execute. + * A decoration never manufactures a row: a name with no host catalog entry + * in the session's directory simply never reaches the decoration. + */ +export interface CommandDecoration { + /** The HOST command name this decorates (without the leading slash). */ + readonly name: string + /** Capability filter, called with a fresh projection per bare invocation. */ + available(session: ClientSessionContext): boolean + /** The bare-invocation UI (this phase: popupSelect only). */ + readonly ui: CommandUiSpec +} + /** The `ctx.command` service face visible to business packages. */ export interface CommandServiceContract { /** @@ -60,6 +68,11 @@ export interface CommandServiceContract { * names throw at registration. */ register(contribution: CommandContribution): () => void + /** + * Hang a bare-invocation decoration on one host command; effect disposer. + * Duplicate names throw at registration. + */ + decorate(decoration: CommandDecoration): () => void /** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */ popupFor(actx: ClientContext): unknown } diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index 4765dc7d86..f40078212e 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected } from './PopupSelectView.tsx' export type { - CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, } from './contract.ts' declare module 'cordis' { diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 00118ec14d..9da7b5dbe6 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -14,7 +14,7 @@ import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-slash/client' -import type { CommandContribution, CommandServiceContract } from './contract.ts' +import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' import { PopupSelectController } from './popup.ts' @@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts' /** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */ interface LiveState { readonly contributions: Map + readonly decorations: Map readonly popups: Map> } @@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract { static inject = ['slash', 'sessions', 'connection'] private readonly directory: CommandDirectory - private readonly live: LiveState = { contributions: new Map(), popups: new Map() } + private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } /** * @param ctx - owning root context (plugin fiber; the service registers @@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract { return () => { void dispose() } } + /** + * Hang a bare-invocation decoration on one host command; effect disposer + * (rides the caller's fiber). Duplicate names throw. + * @param decoration - host command name + availability + popup spec. + * @returns the disposer removing the registration. + */ + decorate(decoration: CommandDecoration): () => void { + const dispose = this.ctx.effect(() => { + const { decorations } = this.live + if (decorations.has(decoration.name)) { + throw new Error(`ui-command: duplicate decoration for /${decoration.name}`) + } + decorations.set(decoration.name, decoration) + return () => { decorations.delete(decoration.name) } + }, 'command.decorate()') + return () => { void dispose() } + } + /** * Resolve the per-session popup controller (lazy; dies with the session * scope). The controller's consume callback dispatches the scoped @@ -139,9 +158,6 @@ export class CommandService extends Service implements CommandServiceContract { for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { - // hostBacked cooperates: the host's catalog row stands, the - // contribution only supplies the bare-invocation popup. - if (contribution.hostBacked === true) continue throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`) } rows.push({ name: contribution.name, description: contribution.description }) @@ -151,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract { .filter(c => req.position === 'leading' || c.hint === undefined) } - /** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */ + /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ private dispatch(pick: SlashPick): PickOutcome { const name = pick.candidate.name const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(pick.session)) { - this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span }) + this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } const desc = this.directory.resolve(pick.session.sessionId, name) if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss + // A decoration replaces the HOST row's bare invocation with its popup; + // it decorates only a resolvable host command (checked above), never + // manufactures one, and never touches the argument claim below. + const decoration = this.live.decorations.get(name) + if (decoration !== undefined && decoration.available(pick.session)) { + this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) + return 'handled' + } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } // Menu-pick execute consumes the trigger span before the detached run // (scoped event; the input owns the CAS guard). @@ -173,10 +197,7 @@ export class CommandService extends Service implements CommandServiceContract { private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined const name = token.slice(1) - // Popup kinds never claim on space; a hostBacked popup defers to the - // host command's own claim (the popup serves only the bare invocation). - const spaceContribution = this.live.contributions.get(name) - if (spaceContribution !== undefined && spaceContribution.hostBacked !== true) return undefined + if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined || desc.input === undefined) return undefined return { claim: this.leadingClaim(desc, session) } @@ -198,17 +219,22 @@ export class CommandService extends Service implements CommandServiceContract { if (name === '') return undefined const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { - if (bare) { - this.openPopup(contribution, session, { via: 'enter', token }) - return 'handled' - } - // hostBacked + arguments: the host command owns the argued path - // (claim or detached run below); a pure contribution stays bare-only. - if (contribution.hostBacked !== true) return undefined + if (!bare) return undefined + this.openPopup(name, contribution.ui, session, { via: 'enter', token }) + return 'handled' } await this.directory.ensureReady(session.sessionId, signal) const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined) return undefined + // Bare enter on a decorated host command opens its popup; an argued line + // never consults the decoration (the claim/detached paths below own it). + if (bare) { + const decoration = this.live.decorations.get(name) + if (decoration !== undefined && decoration.available(session)) { + this.openPopup(name, decoration.ui, session, { via: 'enter', token }) + return 'handled' + } + } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) } if (!bare) return undefined this.consumeVia(session.sessionId, { via: 'enter', token }) @@ -216,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract { return 'handled' } - /** Open the session's popup for one contribution (menu pick / bare enter). */ + /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */ private openPopup( - contribution: CommandContribution, + name: string, + ui: CommandContribution['ui'], session: ClientSessionContext, segment: TokenSegment, ): void { const actx = this.scopeFor(session.sessionId) if (actx === undefined) return - this.popupFor(actx).open(contribution.name, contribution.ui, session, segment) + this.popupFor(actx).open(name, ui, session, segment) } /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 6af54ec941..1123c2c48c 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandService } from '../src/client/service.ts' @@ -198,18 +198,26 @@ describe('candidates', () => { await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') }) - it('a hostBacked contribution cooperates: the host row stands, no duplicate, no throw', async () => { +}) + +describe('decorations (bare-invocation UI on host commands)', () => { + const goalDecoration = (over: Partial = {}): CommandDecoration => ({ + name: 'goal', + available: () => true, + ui: themeUi(), + ...over, + }) + + it('adds no catalog row: the host row stands alone', async () => { const { command, source } = await bench() - command.register(themeContribution({ name: 'goal', hostBacked: true })) + command.decorate(goalDecoration()) const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) expect(names).toEqual(['plan', 'goal']) }) -}) -describe('hostBacked enter/space columns', () => { - it('bare enter opens the popup; an argued line falls through to the host claim', async () => { + it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => { const { command, source, mint, warm } = await bench() - command.register(themeContribution({ name: 'goal', hostBacked: true })) + command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled') @@ -219,14 +227,38 @@ describe('hostBacked enter/space columns', () => { expect(argued.claim.token).toBe('/goal ') }) - it('space defers to the host claim instead of the popup', async () => { + it('space never consults the decoration (host claim)', async () => { const { command, source, warm } = await bench() - command.register(themeContribution({ name: 'goal', hostBacked: true })) + command.decorate(goalDecoration()) await warm(proj('s1')) const outcome = source.matchSpace!(proj('s1'), '/goal') if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim') expect(outcome.claim.token).toBe('/goal ') }) + + it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => { + const { command, source, mint, warm } = await bench() + command.decorate(goalDecoration({ name: 'phantom' })) + const scope = mint('s1') + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined() + expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('an unavailable decoration falls through to the host bare path (detached execute)', async () => { + const { command, source, warm, executeCalls } = await bench() + command.decorate(goalDecoration({ name: 'plan', available: () => false })) + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled') + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + }) + + it('duplicate decoration names fail loud', async () => { + const { command } = await bench() + command.decorate(goalDecoration()) + expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal') + }) }) describe('dispatch (menu column)', () => { diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml index 0b2d5de3c5..f963bb9dda 100644 --- a/packages/client/ui-permission/README.i18n.yaml +++ b/packages/client/ui-permission/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md -README.md: 1782f89c0909ea80f8554bb9b271947a39ae7f8f -README.zh.md: 6c750329df5bfb25f738869eeb82ea26fa1b8634 +README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93 +README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md index 1782f89c09..0cd8e7f878 100644 --- a/packages/client/ui-permission/README.md +++ b/packages/client/ui-permission/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Permission preset selection plugin, browser half: the `/permission` popupSelect contribution (registered through `ctx.command`). The contribution is `hostBacked` — the host's `/permission` command owns the slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; this entry supplies only the bare-invocation picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The contribution is available exactly while the projection key is present; a permission-less composition shows no picker. +Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row). The `/client` export surface is the plugin body (`apply`/`inject`). diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md index 6c750329df..6bc299529c 100644 --- a/packages/client/ui-permission/README.zh.md +++ b/packages/client/ui-permission/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -权限预设选择插件(浏览器半侧):`/permission` popupSelect contribution(经 `ctx.command` 注册)。该 contribution 是 `hostBacked`(宿主背书)的——host 的 `/permission` 命令拥有斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;本入口只提供裸调用的选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。contribution 恰在投影 key 存在时可用;无权限组合不显示选择框。 +权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。 `/client` 导出面为插件本体(`apply`/`inject`)。 diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index ff290fda0a..42043ee008 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -1,14 +1,14 @@ /** - * Permission preset plugin, browser half — the `/permission` popupSelect - * (the bare-invocation picker the user asked for: one flat list of presets, - * current value marked active, a pick executes the switch). The contribution - * is hostBacked: the host's `/permission` command owns the catalog row, the - * argued path (`/permission ` still switches directly), and the - * lifecycle logging — this entry only opens the picker on a bare pick/enter. - * Options and the active mark read the session's `permissions` projection - * (the same host-computed select the composer chip renders); a pick submits - * the `/permission ` command line, so both surfaces write through - * one path and the pushed projection frame is the one confirmation. + * Permission preset plugin, browser half — a popupSelect DECORATION hung on + * the host `/permission` command: one flat list of presets, current value + * marked active, a pick executes the switch. The decoration owns only the + * bare invocation; the host command keeps its catalog row, the argued path + * (`/permission ` still switches directly), and the lifecycle + * logging. Options and the active mark read the session's `permissions` + * projection (the same host-computed select the composer chip renders); a + * pick submits the `/permission ` command line, so both surfaces + * write through one path and the pushed projection frame is the one + * confirmation. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' @@ -45,10 +45,8 @@ export function apply(ctx: ClientContext): void { const sessions = ctx.sessions const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session - ctx.effect(() => command.register({ + ctx.effect(() => command.decorate({ name: 'permission', - description: 'Switch the permission preset (sandbox mode + approval policy)', - hostBacked: true, // The picker exists exactly while the projection does: a permission-less // host serves no key and the bare invocation falls through to the host // command (which is absent too — the line simply misses). @@ -68,5 +66,5 @@ export function apply(ctx: ClientContext): void { if (!result.value.matched) throw new Error('the host offers no /permission command') }, }, - }), 'ui-permission: /permission contribution') + }), 'ui-permission: /permission decoration') } diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index b9767c732d..167cf17362 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -1,7 +1,7 @@ /** * ui-permission browser half on a real cordis Context with fake command/ - * sessions faces: the plugin registers the hostBacked /permission popup - * contribution; options flatten the session's permissions projection with + * sessions faces: the plugin hangs the /permission popup decoration on the + * host command; options flatten the session's permissions projection with * the current value active and `custom` excluded; availability follows the * projection key's presence; a pick submits the /permission line through * Session.command and surfaces rejection/unmatched as thrown errors; fiber @@ -10,7 +10,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { CommandContribution } from '@deepseek-ai/dsh-client-ui-command/client' +import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' import { apply, inject } from '../src/client/index.ts' @@ -27,11 +27,11 @@ const SELECT: PermissionSelect = { async function bench() { const ctx = new Context() - let contribution: CommandContribution | undefined + let decoration: CommandDecoration | undefined ctx.provide('command', { - register(c: CommandContribution) { - contribution = c - return () => { contribution = undefined } + decorate(c: CommandDecoration) { + decoration = c + return () => { decoration = undefined } }, }) const values = new Map() @@ -59,22 +59,21 @@ async function bench() { return { ctx, fiber, values, commands, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, - contribution: () => contribution, + decoration: () => decoration, } } describe('ui-permission browser plugin', () => { - it('registers the hostBacked /permission popup contribution', async () => { + it('hangs the /permission popup decoration on the host command', async () => { const b = await bench() - const c = b.contribution()! + const c = b.decoration()! expect(c.name).toBe('permission') - expect(c.hostBacked).toBe(true) expect(c.ui.kind).toBe('popupSelect') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { const b = await bench() - const c = b.contribution()! + const c = b.decoration()! const proj = { sessionId: sid('s1') } expect(c.available(proj)).toBe(false) b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' }) @@ -93,7 +92,7 @@ describe('ui-permission browser plugin', () => { it('a pick submits the /permission line; rejection and unmatched throw', async () => { const b = await bench() - const c = b.contribution()! + const c = b.decoration()! const proj = { sessionId: sid('s1') } b.values.set(sid('s1'), SELECT) await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj) @@ -107,10 +106,10 @@ describe('ui-permission browser plugin', () => { .rejects.toThrow(/not materialized/) }) - it('disposal removes the contribution (HMR safety)', async () => { + it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() - expect(b.contribution()).toBeDefined() + expect(b.decoration()).toBeDefined() await b.fiber.dispose() - expect(b.contribution()).toBeUndefined() + expect(b.decoration()).toBeUndefined() }) }) From 79b1ec2eaed5fd771dc8594a2bd135dfebb6334f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:11:00 +0800 Subject: [PATCH 21/23] fix(host): settle pending approvals as cancelled on gateway teardown Disposability parity with the question provider: a gateway disposed while approvals are pending settles every registry entry as 'cancelled' (the service's fail-closed vocabulary), so no ctx.approval ask dangles past the proxy's lifetime and mux subscribers see the withdrawal. Spec mounts the proxy on its own fiber and drives dispose with a live ask. Addresses the ds-review-bot suggestion on PR #851. --- packages/host/apiproxy/src/api-proxy.ts | 7 ++++++ .../apiproxy/tests/api-proxy-approval.spec.ts | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 20b630dc77..0315349fff 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -609,6 +609,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the same rpcId (the refresh-recovery baseline) — and withdraws on the // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. if (ctx.get('approval') !== undefined) { + // Teardown parity with the question provider above: a gateway disposed + // while approvals are pending settles every entry as 'cancelled' (the + // service's fail-closed vocabulary), so no ask promise dangles past the + // proxy's lifetime and subscribers see the withdrawal. + ctx.effect(() => () => { + for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled') + }, 'api-proxy: approval registry teardown') ctx.on('approval/request', (req, next) => { // The audit pair `approval/asked` is already appended by the service // before dispatch, but dispatch rides a microtask: parallel tool calls diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index a744224814..632fa898de 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -176,6 +176,31 @@ describe('approval pending registry', () => { abort.abort() }) + it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => { + // Mount the proxy on its own fiber so disposal exercises the teardown + // effect while an ask is still pending. + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ApprovalService) + let api!: ApiProxy + const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) + await fiber.await() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + await fiber.dispose() + await expect(asked).resolves.toBe('cancelled') + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) + abort.abort() + }) + it('carries callId on the frame and ignores a late abort after the answer settled', async () => { const { ctx, api } = await harness() const abort = new AbortController() From 99b1a7e895bd3eb8327b6a3a05b6b9e38b00d116 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:03:26 +0800 Subject: [PATCH 22/23] fix(host): settle pre-aborted asks at registration; make audit pairing callId-symmetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two races from the #572 review, still live in the ported registry: An ask whose signal aborted between the service's own check and the microtask-deferred waterfall dispatch would register its abort listener AFTER the signal fired — never invoked, entry pending forever, zombie frame on every mux replay. The answerer now settles 'cancelled' synchronously before publishing anything. The audit back-scan let a callId-less ask claim the newest unclaimed asked record even when that record carried another call's id. Pairing is now shape-symmetric: callId-bearing asks take exactly their call's record, callId-less asks take only callId-less records — neither can steal under parallel asks. --- packages/host/apiproxy/src/api-proxy.ts | 12 +++++++- .../apiproxy/tests/api-proxy-approval.spec.ts | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0315349fff..f178bfefd0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -617,6 +617,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled') }, 'api-proxy: approval registry teardown') ctx.on('approval/request', (req, next) => { + // Dispatch rides a microtask behind the service's own signal check: an + // abort landing in that window would register the abort listener AFTER + // the signal fired — never invoked, entry pending forever, zombie frame + // on every mux replay. Settle synchronously instead of publishing. + if (req.signal?.aborted === true) return Promise.resolve('cancelled') // The audit pair `approval/asked` is already appended by the service // before dispatch, but dispatch rides a microtask: parallel tool calls // can append several asked events before any answerer runs. THIS @@ -634,7 +639,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro decided.add(event.data.id) } else if (event.type === 'approval/asked') { if (decided.has(event.data.id) || claimed.has(event.data.id)) continue - if (req.callId !== undefined && event.data.callId !== req.callId) continue + // Symmetric pairing: a callId-bearing ask only takes its own call's + // record, and a callId-less ask only takes a callId-less record — + // so neither shape can steal the other's audit id under parallel + // asks. (Today every producer — the tool executor — passes callId; + // the callId-less arm guards any future non-tool asker.) + if ((req.callId ?? null) !== (event.data.callId ?? null)) continue approvalId = event.data.id break } diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 632fa898de..f9a00cf9ef 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -176,6 +176,36 @@ describe('approval pending registry', () => { abort.abort() }) + it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => { + // The service checks the signal, then dispatch rides a microtask: an + // abort in that window must not register a dead listener and strand the + // entry (zombie frame on every replay). Drive the waterfall directly + // with a pre-aborted signal to hit the answerer's register-path guard. + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' }) + const agent = { session } as unknown as Agent + const cancelled = new AbortController() + cancelled.abort() + const outcome = await ctx.waterfall( + 'approval/request', + { agent, toolName: 'bash', signal: cancelled.signal }, + () => Promise.resolve('unavailable' as const), + ) + expect(outcome).toBe('cancelled') + // Nothing was published: a fresh mux open replays no approval frame. + const abort2 = new AbortController() + const mux2 = openMux(api, abort2) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false) + abort2.abort() + abort.abort() + void mux + }) + it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => { // Mount the proxy on its own fiber so disposal exercises the teardown // effect while an ask is still pending. From 9e10ba0f15a9cd5693222afd7c270d0a5ed801cd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:03:26 +0800 Subject: [PATCH 23/23] fix(client): drop generation-scoped interaction state at generation death MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing waitingApprovals in handleConnected raced the reconnect replay: mux frames flow from stream open while onConnected waits for the readiness handshake, so a replayed approval/requested could land first and be wiped — amber dot and answerable card lost until the next generation. The sweep moves to generation death (onStateChange 'reconnecting'), before any next-generation frame can exist, and now also drops buffered answerable frames (approval/question pairs) whose dead-generation rpcIds could never be answered — a session instantiated later no longer replays zombie takeover cards. session/queued buffering already re-baselines per generation; this closes the same window for the interaction frames. --- packages/client/runtime/src/client/index.ts | 6 +++++ .../runtime/src/client/sessions/manager.ts | 26 ++++++++++++++++--- .../runtime/src/client/sessions/service.ts | 5 ++++ packages/client/runtime/tests/manager.spec.ts | 24 +++++++++++++++-- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index f697fd3f1a..3b8e02b5a9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -154,6 +154,12 @@ export function apply(ctx: Context): void { workspaces.handleConnected() ctx.emit('connection/reset') }, + onStateChange: (state) => { + // Generation death fires before any next-generation frame can arrive + // (reconnect replays flow from stream open, ahead of onConnected): + // the only safe moment to drop generation-scoped interaction state. + if (state === 'reconnecting') sessions.handleDisconnected() + }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') } diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 62a0241df5..396c0be6e7 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -443,14 +443,32 @@ export class SessionManager { } } - /** After each connection generation: refresh the session baseline and rebuild opened windows. */ - handleConnected(): void { - // Approvals resolved while disconnected send no frame: drop the bits and - // let the mux-open replay re-add every still-pending question. + /** + * The moment a connection generation dies (before any next-generation frame + * can arrive — onConnected waits for the readiness handshake while replayed + * frames flow from stream open, so clearing there would race the replay): + * drop generation-scoped live state. Approvals resolved while disconnected + * send no frame, so the stale bits and the buffered answerable frames must + * not survive into the next generation — the mux-open replay re-adds every + * still-pending question with its live rpcId. + */ + handleDisconnected(): void { if (this.waitingApprovals.size > 0) { this.waitingApprovals.clear() this.notifier.markDirty() } + for (const [sessionId, buffer] of [...this.pendingBuffers]) { + const kept = buffer.filter(item => + item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved' + && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved') + if (kept.length === buffer.length) continue + if (kept.length === 0) this.pendingBuffers.delete(sessionId) + else this.pendingBuffers.set(sessionId, kept) + } + } + + /** After each connection generation: refresh the session baseline and rebuild opened windows. */ + handleConnected(): void { void this.refreshList() for (const session of this.sessions.values()) void session.resync() } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 27965c2902..71067d6330 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -294,6 +294,11 @@ export class SessionsService implements ISessions { this.manager.handleConnected() } + /** Drop generation-scoped live interaction state the moment a connection generation dies. */ + handleDisconnected(): void { + this.manager.handleDisconnected() + } + /** * Create a session on the host. Resolution guarantee: by the time the * promise resolves, the created session is in the list store and diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 90ab196059..33b46538d9 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -406,12 +406,32 @@ describe('waiting-approval list bit', () => { expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => { + it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) - manager.handleConnected() // resolved-while-disconnected questions send no frame + // Generation death clears (resolved-while-disconnected questions send no frame)… + manager.handleDisconnected() expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + // …and a replayed frame arriving before onConnected (stream open precedes + // the readiness handshake) survives the later handleConnected untouched. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleConnected() + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + }) + + it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + // Buffered pre-instantiation: an approval pair and a queued row. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) + manager.handleDisconnected() + // Instantiate after the death sweep: no zombie interaction replays (the + // pendingBuffers held only dead-generation rpcIds), so the session mints + // no pending waits. + const session = manager.get(S1) + expect(session.getSnapshot().pending).toEqual([]) }) })