From f0410d592d1b32f810437cd82d93b96a2581ae71 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:39:00 +0800 Subject: [PATCH 001/117] 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 628c1bffe0c35d46baef1f492fdaba9e53ae77e7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 15:47:44 +0800 Subject: [PATCH 002/117] feat(ui): add trajectory inspection ledger --- ...-27-trajectory-inspection-ledger.i18n.yaml | 6 + ...2026-07-27-trajectory-inspection-ledger.md | 36 + ...6-07-27-trajectory-inspection-ledger.zh.md | 36 + apps/web/tests/navigation-panes.e2e.ts | 19 +- .../navigation-panes/trajectory.expected.md | 58 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 16 +- .../src/client/sessions/fold-adapter.ts | 50 +- .../runtime/src/client/sessions/session.ts | 33 +- .../ui-conversation/src/client/apply.ts | 8 +- .../src/client/contract/slots.ts | 2 + .../src/client/skeleton/ConversationRoot.tsx | 14 +- .../client/skeleton/ConversationSession.tsx | 15 +- .../ui-layout/src/client/AppFrame.module.css | 2 +- packages/client/ui-primitives/package.json | 4 + .../ui-primitives/src/JsonTree.module.css | 221 +++ .../client/ui-primitives/src/JsonTree.tsx | 390 +++++ .../client/ui-primitives/src/Menu.module.css | 31 + packages/client/ui-primitives/src/Menu.tsx | 8 +- packages/client/ui-primitives/src/index.ts | 4 + .../ui-primitives/src/markdown/plain-text.ts | 126 ++ .../tests/markdown-plain-text.spec.ts | 42 + .../client/ui-trajectory/README.i18n.yaml | 6 +- packages/client/ui-trajectory/README.md | 4 +- packages/client/ui-trajectory/README.zh.md | 4 +- packages/client/ui-trajectory/package.json | 4 +- .../src/client/TrajectoryCell.tsx | 63 +- .../src/client/TrajectoryTable.module.css | 1067 ++++++++++++++ .../src/client/TrajectoryTable.tsx | 1301 +++++++++++++++++ .../src/client/TrajectoryToolbar.module.css | 85 ++ .../src/client/TrajectoryToolbar.tsx | 66 + .../src/client/TrajectoryView.tsx | 115 +- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 294 +++- .../src/client/trajectory-record.ts | 81 + .../ui-trajectory/src/client/views.module.css | 17 +- .../ui-trajectory/tests/layout.spec.tsx | 15 +- .../client/ui-trajectory/tests/table.spec.tsx | 88 ++ .../client/ui-trajectory/tests/views.spec.tsx | 42 +- patches/react-json-view-lite@2.5.0.patch | 319 ++++ pnpm-lock.yaml | 28 + pnpm-workspace.yaml | 3 + 42 files changed, 4549 insertions(+), 178 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md create mode 100644 packages/client/ui-primitives/src/JsonTree.module.css create mode 100644 packages/client/ui-primitives/src/JsonTree.tsx create mode 100644 packages/client/ui-primitives/src/markdown/plain-text.ts create mode 100644 packages/client/ui-primitives/tests/markdown-plain-text.spec.ts create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTable.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTable.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx create mode 100644 packages/client/ui-trajectory/src/client/trajectory-record.ts create mode 100644 packages/client/ui-trajectory/tests/table.spec.tsx create mode 100644 patches/react-json-view-lite@2.5.0.patch diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml new file mode 100644 index 0000000000..035cb14e90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 +2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md new file mode 100644 index 0000000000..30d1a2b0b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -0,0 +1,36 @@ +# Agent Note: Trajectory inspection ledger + +Status: implemented + +English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md) + +## Problem + +Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state. + +## Decision + +**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** + +- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. +- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. +- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. +- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. +- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. +- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. + +## Alternatives considered + +**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers. + +**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. + +**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. + +**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. + +**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. + +## Consequences + +Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md new file mode 100644 index 0000000000..2c8724160c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -0,0 +1,36 @@ +# Agent Note:轨迹检查记录表 + +Status: implemented + +[English](2026-07-27-trajectory-inspection-ledger.md) | 中文 + +## 问题 + +轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。 + +## 决策 + +**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** + +- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 +- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 +- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 +- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 +- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 +- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 + +## 曾考虑的替代方案 + +**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。 + +**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 + +**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 + +**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 + +**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 + +## 后果 + +轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a4abc5cecd 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -124,17 +124,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { + it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() - // Two sticky turn sections; turn 1's step group summarizes its tool mix - // (bash + the two parallel reads collapse to 'bash read×2'). - await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + // Thick row rules mark the two turns; compact inline markers identify + // each step without introducing dedicated group rows. + await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) + await expect.poll(() => page.locator('[aria-label^="Step "]').count(), { timeout: 10_000 }).toBe(3) + await expect.poll(() => page.getByRole('columnheader', { name: 'Tokens' }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('columnheader', { name: '耗时' }).count(), { timeout: 10_000 }).toBe(0) + await page.locator('tr[data-kind="tool"]').first().click() + await expect.poll(() => page.getByRole('complementary', { name: '记录详情' }).count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('tab', { name: '输出' }).click() + await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + await page.getByRole('complementary', { name: '记录详情' }) + .getByRole('button', { name: '关闭详情' }).click() }, 60_000) it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 80d6f161ca..4224bce857 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -1 +1,57 @@ -- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}" +- toolbar "轨迹工具栏": + - text: 轨迹 + - strong: "8" + - text: 条记录 + - strong: "2" + - text: 轮 + - strong: "3" + - text: 次工具 + - button "收起记录" +- table: + - rowgroup: + - row "# 事件 内容": + - columnheader "#" + - columnheader "事件" + - columnheader "内容" + - rowgroup: + - 'row "记录 1,USER,NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': + - cell "#1" + - cell "USER" + - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' + - row "记录 2,ASSISTANT,请求调用 bash、read×2": + - cell "#2" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - cell "请求调用 bash、read×2" + - 'row "记录 3,TOOL,bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]': + - cell "#3" + - cell "TOOL" + - 'cell "bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}→NAVIGATION_OK"' + - 'row "记录 4,TOOL,read · {\"file_path\": \"nav-a.md\"}"': + - cell "#4" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-a.md\"}→{{cwd}}/nav-a.md file 1: # alpha nav (End of file - total 1 lines) "' + - 'row "记录 5,TOOL,read · {\"file_path\": \"nav-b.md\"}"': + - cell "#5" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-b.md\"}→{{cwd}}/nav-b.md file 1: # beta nav (End of file - total 1 lines) "' + - row "记录 6,ASSISTANT,FIRST_DONE": + - cell "#6" + - cell "Step 2 ASSISTANT": S2 ASSISTANT + - cell "FIRST_DONE" + - 'row "记录 7,USER,Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': + - cell "#7" + - cell "USER" + - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' + - 'row "记录 8,ASSISTANT,## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"': + - cell "#8" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - 'cell "## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"' +- complementary "记录详情": + - text: "TOOL 记录 #3 Turn 1 · Step 1" + - button "关闭详情" + - tablist "记录详情": + - tab "概览" + - tab "输入" + - tab "输出" [selected] + - tab "计时" + - tabpanel "输出": NAVIGATION_OK diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..15b3beebb8 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,7 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, + AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..609c2830bc 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -3,7 +3,7 @@ // substructures keep their references (the React.memo premise). callId/approvalId stay plain // string here (narrow to real brands when convenient). -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' @@ -50,6 +50,16 @@ export interface UserMessageNode { source: unknown } +/** Recorded boundaries used to derive assistant latency and throughput. */ +export interface AssistantTiming { + /** Matching step/start timestamp, or null when it is outside the current event window. */ + stepStartTime: number | null + /** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */ + firstTokenTime: number | null + /** Final assistant/message timestamp. */ + completedTime: number +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -60,6 +70,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + /** Timing derived from the recorded step/chunk/message event sequence. */ + timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ interrupted?: true @@ -216,6 +228,8 @@ export interface ConversationSnapshot { * unrelated snapshot swaps (memo premise, same regime as `nodes`). */ codeDispatches: ReadonlyMap + /** Model-visible tool schema captured for each recorded call id. */ + callSchemas?: ReadonlyMap pending: readonly PendingInteraction[] /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ queue: readonly QueuedMessage[] diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..9fc302398a 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationNode } from './conversation.ts' +import type { AssistantTiming, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ @@ -37,6 +37,7 @@ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap, resultView: ToolResultView | null, + assistantTiming?: AssistantTiming, ): ConversationNode { switch (event.type) { case 'user/message': @@ -58,6 +59,7 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, + ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': return { @@ -177,7 +179,12 @@ export class FoldAdapter { const event = this.padded[seq] /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */ if (event === undefined) continue - const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null) + const node = materializeNode( + event, + this.callIdx, + this.resultViews.get(seq) ?? null, + event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + ) this.nodeCache.set(seq, node) out.push(node) } @@ -196,6 +203,33 @@ export class FoldAdapter { return seqs } + private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { + let stepStartTime: number | null = null + let firstTokenTime: number | null = null + for (let i = this.baseSeq; i < this.padded.length; i++) { + const candidate = this.padded[i] + if (candidate === undefined || candidate.seq > event.seq) break + if ( + candidate.type === 'step/start' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + ) { + stepStartTime = candidate.time + continue + } + if ( + firstTokenTime === null + && candidate.type === 'assistant/chunk' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + && isTokenDelta(candidate.data.chunk) + ) { + firstTokenTime = candidate.time + } + } + return { stepStartTime, firstTokenTime, completedTime: event.time } + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -211,3 +245,15 @@ export class FoldAdapter { // (window order puts the call before its result; cannot happen on the normal path). } } + +function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..493ca6baa7 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,7 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { Context } from 'cordis' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, @@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot { private codeDispatches = new Map() private dispatchesRev = 0 private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null + /** Schemas in force for the next tool/call, updated by request/header. */ + private activeToolSchemas = new Map() + /** Call-time schema snapshots keyed by native or code-dispatch call id. */ + private callSchemas = new Map() + private callSchemasRev = 0 + private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot { argsRaw: JSON.stringify(data.arguments), turn: 0, step: 0, time: event.time, callView: null, } + this.captureCallSchema(data.subCallId, data.name) const siblings = this.codeDispatches.get(data.parentCallId) ?? [] this.codeDispatches.set(data.parentCallId, [...siblings, running]) this.dispatchesRev++ @@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot { content: ContentBlock[] } const siblings = this.codeDispatches.get(data.parentCallId) ?? [] + this.captureCallSchema(data.subCallId, data.name) const at = siblings.findIndex(sub => sub.callId === data.subCallId) const started = at === -1 ? undefined : siblings[at] const settled: CodeSubCall = { @@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot { return } switch (event.type) { + case 'request/header': { + this.activeToolSchemas = new Map( + (event.data.header.tools ?? []).map(schema => [schema.name, schema]), + ) + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot { return } case 'tool/call': { + this.captureCallSchema(String(event.data.callId), event.data.name) this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, time: event.time, @@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot { } } + /** Preserve the schema active when one call starts. */ + private captureCallSchema(callId: string, name: string): void { + if (this.callSchemas.has(callId)) return + const schema = this.activeToolSchemas.get(name) + if (schema === undefined) return + this.callSchemas.set(callId, schema) + this.callSchemasRev++ + } + /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ @@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot { this.frozenRev++ this.codeDispatches = new Map() this.dispatchesRev++ + this.activeToolSchemas = new Map() + this.callSchemas = new Map() + this.callSchemasRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot { if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } + if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) { + this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) } + } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } @@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot { runningCalls: this.callsCache.value, pending: this.pendingCache.value, codeDispatches: this.dispatchesCache.value, + callSchemas: this.callSchemasCache.value, queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..76ac8a8db3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -72,8 +72,8 @@ export function apply(ctx: Context): void { }, }), 'ui-conversation: input standard-kit provider') - // Resident current-session-optional shell. It owns the stable Hero/composer - // frame while strict session slots fill only their session-bound regions. + // Resident current-session-optional shell. It constructs the stable + // composer frame; the strict session child decides whether Chat mounts it. slots.register({ name: 'conversation', children: { @@ -106,8 +106,8 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session subtree owns only per-session store and view content; - // the resident parent keeps Hero and composer layout identity stable. + // The strict session subtree owns the per-session store and view content; + // the resident parent supplies the composer node through owner props. slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..b2b193bc40 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -107,6 +107,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { + /** Composer chain assembled by the resident parent; only Chat mounts it. */ + composer: ReactNode } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..51e5fe9d33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -74,6 +74,11 @@ export function ConversationRoot({ {inputBar}

) + const composer = renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + ) return (
@@ -81,12 +86,9 @@ export function ConversationRoot({ renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined && renderSlot('conversation.session', {})} - {renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, - )} + {sessionId !== undefined + ? renderSlot('conversation.session', { composer }) + : composer}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 515bfe1f93..26615905a1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { useEffect, useSyncExternalStore } from 'react' +import { Fragment, useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, + renderSlot, views, bindDraftMirror, open, composer, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -45,11 +45,11 @@ export function ConversationSession({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputActions]) - if (blank && composerPhase === 'blank') return null + const blankHero = blank && composerPhase === 'blank' return ( <> -
+ {!blankHero &&
)} -
-
+
} + {!blankHero &&
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ } + {(blankHero || active?.id === 'chat') && {composer}} ) } diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..fef81b6e9f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -54,12 +54,12 @@ Handle component; the hit strip stays wider than the pill. */ .handle { position: absolute; + z-index: 6; top: 0; bottom: 0; width: 8px; margin-left: -4px; cursor: col-resize; - z-index: 2; touch-action: none; /* Rides the same curve as the tracks so the pill stays on the moving border during collapse/expand; paused while dragging (frame rule). */ diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 9ce2bc8676..dfb9fa8739 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,8 +22,12 @@ "dependencies": { "@shikijs/langs": "^4.3.1", "clsx": "^2.0.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "micromark-extension-gfm": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shiki": "^4.3.1" diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css new file mode 100644 index 0000000000..34b690a0f0 --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -0,0 +1,221 @@ +.root { + --json-tree-property: #881391; + --json-tree-string: #c41a16; + --json-tree-number: #1c00cf; + --json-tree-keyword: #1c00cf; + --json-tree-punctuation: #202124; + --json-tree-icon: #5f6368; + --json-tree-hover: rgb(60 64 67 / 4%); + + min-width: 0; + overflow: auto; + position: relative; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); + font: 12px/16px var(--ds-font-family-code); + overscroll-behavior: contain; +} + +:global(body[data-ds-dark-theme]) .root { + --json-tree-property: #5db0d7; + --json-tree-string: #f28b82; + --json-tree-number: #99c8ff; + --json-tree-keyword: #99c8ff; + --json-tree-punctuation: #e8eaed; + --json-tree-icon: #9aa0a6; + --json-tree-hover: rgb(232 234 237 / 5%); +} + +.container { + box-sizing: border-box; + width: max-content; + min-width: 100%; + margin: 0; + padding: 6px 8px 8px; + white-space: pre; +} + +.expandedTopLevel { + box-sizing: border-box; + width: max-content; + min-width: 100%; + padding: 6px 8px 8px 14px; +} + +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover), +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) { + background: var(--json-tree-hover); +} + +.expandedTopLevelContainer { + padding: 0 0 0 calc(2ch - 12px); +} + +.row.topLevelBracket { + margin-left: 0; + padding-left: 0; +} + +.children { + margin: 0; + padding: 0 0 0 4px; + list-style: none; +} + +.row { + position: relative; + box-sizing: border-box; + min-width: 100%; + min-height: 16px; + margin: 0; + padding: 0 0 0 12px; + list-style: none; +} + +.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, +.row:not(.topLevelBracket)[data-json-copy-active]::after, +.row:has(> .expander:focus-visible)::after { + position: absolute; + z-index: 0; + top: 0; + right: -100vw; + left: -100vw; + height: 16px; + background: var(--json-tree-hover); + content: ''; + pointer-events: none; +} + +.row > span:not(.expander) { + position: relative; + z-index: 1; +} + +.label { + margin-right: 3px; + color: var(--json-tree-property); + font-weight: 400; +} + +.clickableLabel { + cursor: pointer; +} + +.stringValue { + color: var(--json-tree-string); +} + +.numberValue { + color: var(--json-tree-number); +} + +.keywordValue { + color: var(--json-tree-keyword); +} + +.otherValue { + color: var(--dsw-alias-label-secondary); +} + +.punctuation { + color: var(--json-tree-punctuation); +} + +.preview { + color: var(--json-tree-punctuation); +} + +.previewProperty { + color: var(--json-tree-punctuation); +} + +.previewEllipsis { + color: var(--dsw-alias-label-tertiary); +} + +.copyAnchor { + position: absolute; + z-index: 3; + display: inline-flex; +} + +.copyButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 16px; + margin: 0; + padding: 0; + border: 0; + border-radius: 3px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); + box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + cursor: pointer; +} + +.copyButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.copyButton:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.copyButton[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.expander { + position: absolute; + z-index: 2; + top: 0; + left: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 8px; + height: 16px; + margin: 0; + color: var(--json-tree-icon); + cursor: pointer; + user-select: none; +} + +.expander::before { + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentColor; + content: ''; + transform: scale(0.75); + transform-origin: center; +} + +.collapseIcon::before { + transform: rotate(90deg) scale(0.75); +} + +.expander:hover { + color: var(--dsw-alias-label-primary); +} + +.expander:focus-visible { + outline: none; +} + +.collapsedContent { + margin: 0 1px; + color: var(--json-tree-punctuation); + cursor: pointer; +} + +.collapsedContent::after { + content: '…'; +} diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx new file mode 100644 index 0000000000..324c7abf1a --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -0,0 +1,390 @@ +import clsx from 'clsx' +import { collapseAllNested, JsonView } from 'react-json-view-lite' +import { useEffect, useRef, useState } from 'react' +import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react' +import type { Props as LiteJsonViewProps } from 'react-json-view-lite' +import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { Menu } from './Menu.tsx' +import type { MenuEntry } from './Menu.tsx' +import css from './JsonTree.module.css' + +const OBJECT_PREVIEW_LIMIT = 4 +const ARRAY_PREVIEW_LIMIT = 5 +const PREVIEW_DEPTH_LIMIT = 2 +const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'value', label: 'Copy value' }, + { id: 'json', label: 'Copy JSON' }, + { id: 'path', label: 'Copy property path' }, +] +const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'prettyJson', label: 'Copy pretty JSON' }, + { id: 'json', label: 'Copy compact JSON' }, + { id: 'path', label: 'Copy property path' }, +] + +const TREE_STYLES: NonNullable = { + container: css.container!, + childFieldsContainer: css.children!, + basicChildStyle: css.row!, + label: css.label!, + clickableLabel: `${css.label!} ${css.clickableLabel!}`, + nullValue: css.keywordValue!, + undefinedValue: css.keywordValue!, + numberValue: css.numberValue!, + stringValue: css.stringValue!, + booleanValue: css.keywordValue!, + otherValue: css.otherValue!, + punctuation: css.punctuation!, + expandIcon: `${css.expander!} ${css.expandIcon!}`, + collapseIcon: `${css.expander!} ${css.collapseIcon!}`, + collapsedContent: css.collapsedContent!, + noQuotesForStringValues: false, + quotesForFieldNames: false, + stringifyStringValues: true, + ariaLables: { + collapseJson: 'Collapse JSON node', + expandJson: 'Expand JSON node', + }, +} + +const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable = { + ...TREE_STYLES, + container: `${css.container!} ${css.expandedTopLevelContainer!}`, +} + +function previewPrimitive(value: unknown): ReactNode { + if (value === null) return null + if (typeof value === 'string') { + return {JSON.stringify(value)} + } + if (typeof value === 'number') { + return {String(value)} + } + if (typeof value === 'boolean') { + return {String(value)} + } + return {String(value)} +} + +function previewValue(value: unknown, depth: number): ReactNode { + if (typeof value !== 'object' || value === null) return previewPrimitive(value) + + const array = Array.isArray(value) + const entries = array + ? value.map((item, index) => [String(index), item] as const) + : Object.entries(value) + const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT + const visible = entries.slice(0, limit) + const open = array ? '[' : '{' + const close = array ? ']' : '}' + + return ( + <> + {open} + {depth >= PREVIEW_DEPTH_LIMIT + ? + : visible.map(([key, item], index) => ( + + {index > 0 && , } + {!array && ( + <> + {key} + : + + )} + {previewValue(item, depth + 1)} + + ))} + {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && ( + {visible.length > 0 ? ', …' : '…'} + )} + {close} + + ) +} + +function renderExpandableValue(value: object): ReactNode { + return {previewValue(value, 0)} +} + +interface CopyTarget { + left: number + path: readonly (number | string)[] + side: 'bottom' | 'top' + top: number + value: unknown +} + +function fieldOf(row: HTMLElement): string | undefined { + const label = Array.from(row.children).find( + child => child instanceof HTMLElement && child.classList.contains(css.label!), + ) + const text = label?.textContent + return text === undefined || text === null ? undefined : text.slice(0, -1) +} + +function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): { + path: readonly (number | string)[] + value: unknown +} | undefined { + if (row.hasAttribute('data-json-root-row')) return { path: [], value: data } + + const lineage: HTMLElement[] = [] + let cursor: HTMLElement | null = row + while (cursor !== null) { + lineage.unshift(cursor) + const group: HTMLElement | null = cursor.parentElement + const parentRow: Element | null = group?.getAttribute('role') === 'group' + ? group.parentElement?.closest('[role="treeitem"]') ?? null + : null + cursor = parentRow instanceof HTMLElement ? parentRow : null + } + + let value: unknown = data + const path: (number | string)[] = [] + for (const item of expandTopLevel ? lineage : lineage.slice(1)) { + const field = fieldOf(item) + if (field === undefined) return undefined + if (Array.isArray(value)) { + const index = Number(field) + if (!Number.isInteger(index)) return undefined + path.push(index) + value = value[index] + } else if (typeof value === 'object' && value !== null) { + path.push(field) + value = (value as Record)[field] + } else { + return undefined + } + } + return { path, value } +} + +function formattedPath(path: readonly (number | string)[]): string { + return path.reduce((result, part) => { + if (typeof part === 'number') return `${result}[${String(part)}]` + return /^[A-Za-z_$][\w$]*$/.test(part) + ? `${result}.${part}` + : `${result}[${JSON.stringify(part)}]` + }, '$') +} + +function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { + if (mode === 'path') return formattedPath(target.path) + if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) + if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value) + if (typeof target.value === 'string') return target.value + if (typeof target.value === 'object' && target.value !== null) { + return JSON.stringify(target.value, null, 2) + } + return JSON.stringify(target.value) ?? String(target.value) +} + +/** Props for the read-only, token-themed JSON tree. */ +export interface JsonTreeProps { + /** Parsed JSON object or array. */ + data: object | unknown[] + /** Accessible label for the tree. */ + label?: string + /** Optional positioning class owned by the caller. */ + className?: string | undefined + /** Whether JSON rows expose copy actions. */ + copyable?: boolean + /** Whether the top-level object or array is always expanded. */ + expandTopLevel?: boolean +} + +/** + * Render parsed JSON as a compact, keyboard-accessible inspector tree. + * @param props - Parsed data, accessible label, and display options. + * @returns A read-only JSON tree with an optionally fixed-open top level. + */ +export function JsonTree({ + data, + label = 'JSON', + className, + copyable = true, + expandTopLevel = true, +}: JsonTreeProps) { + const rootRef = useRef(null) + const activeRowRef = useRef() + const copyButtonRef = useRef(null) + const copyMenuOpenRef = useRef(false) + const resetTimer = useRef>() + const [copyTarget, setCopyTarget] = useState() + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') + const [copyMenuOpen, setCopyMenuOpen] = useState(false) + + useEffect(() => () => { + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + activeRowRef.current?.removeAttribute('data-json-copy-active') + }, []) + + const setActiveRow = (row: HTMLElement | undefined) => { + activeRowRef.current?.removeAttribute('data-json-copy-active') + activeRowRef.current = row + row?.setAttribute('data-json-copy-active', '') + } + + const positionCopyButton = (row: HTMLElement, target: { + path: readonly (number | string)[] + value: unknown + }) => { + const root = rootRef.current + if (root === null) return + const rootRect = root.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + setCopyTarget({ + left: root.scrollLeft + root.clientWidth - 26, + path: target.path, + side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', + top: root.scrollTop + rowRect.top - rootRect.top, + value: target.value, + }) + } + + const clearCopyTarget = () => { + setActiveRow(undefined) + setCopyTarget(undefined) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + } + + const handleMouseOver = (event: ReactMouseEvent) => { + if (!copyable || !(event.target instanceof Element)) return + if (copyMenuOpenRef.current) return + if (!event.currentTarget.contains(event.target)) return + if (event.target.closest('[data-json-copy-button]') !== null) return + const row = event.target.closest('[data-json-root-row], [role="treeitem"]') + if (row === null) { + clearCopyTarget() + return + } + if (activeRowRef.current === row) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved === undefined) return + setActiveRow(row) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + positionCopyButton(row, resolved) + } + + const handleScroll = (event: ReactUIEvent) => { + if (event.currentTarget !== event.target) return + const row = activeRowRef.current + if (row === undefined) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved !== undefined) positionCopyButton(row, resolved) + } + + const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { + if (copyTarget === undefined) return + try { + await navigator.clipboard.writeText(copyText(copyTarget, mode)) + setCopyState('copied') + } catch { + setCopyState('failed') + } + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500) + } + + const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null + const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' + const copyTitle = copyState === 'copied' + ? 'Copied' + : copyState === 'failed' + ? 'Copy failed' + : copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value' + + return ( +
{ + if (!copyMenuOpenRef.current) clearCopyTarget() + }} + onScroll={handleScroll} + > + {expandTopLevel + ? ( +
+
+ {Array.isArray(data) ? '[' : '{'} +
+ +
+ {Array.isArray(data) ? ']' : '}'} +
+
+ ) + : ( + + )} + {copyTarget !== undefined && ( + + void copy(defaultCopyMode)} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {copyState === 'copied' + ? + : } + + )} + items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS} + onSelect={(id) => { + if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') { + void copy(id) + } + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null} + /> + + )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6825731275..e6a59ea84a 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -77,6 +77,37 @@ background: var(--dsw-alias-interactive-bg-hover); } +.list.compactList, +.submenu.compactList { + min-width: 164px; + padding: 2px; + border-radius: 7px; +} + +.compactList .item { + min-height: 26px; + gap: 6px; + padding: 3px 7px; + border-radius: 5px; + font-size: 12px; + line-height: 18px; +} + +.compactList .itemIcon { + width: 14px; + height: 14px; +} + +.compactList .separator { + margin: 2px; +} + +.compactList .label { + padding: 4px 7px; + font-size: 11px; + line-height: 16px; +} + .item:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 9abb6a3bb2..e99722875b 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * keeps the pure-CSS in-place behavior. * @param props.closeOnPointerLeave - close the list when the pointer leaves * it (default false keeps it open until outside click/Escape/selection). + * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align side?: 'bottom' | 'top' portal?: boolean closeOnPointerLeave?: boolean + compact?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (!portal || fixedPos !== null) && (
{ onClose() } : undefined} @@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align {entry.id === selectedId && } {subOpen && entry.submenu !== undefined && ( -
+
{entry.submenu.map(sub => ( + ) + : ( +
+ + {`Block #${index + 1} ${block.type}`} + +
+ )} + {block.imageSrc !== undefined + ? + :
{block.content}
} + + ))} +
+ ) +} + +function PanelImage({ + block, + preview = false, +}: { + block: TrajectorySourceBlock + preview?: boolean +}) { + if (block.imageSrc === undefined) return null + return ( + + {block.imageAlt + + ) +} + +function MessageImages({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean +}) { + const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + if (images.length === 0) return null + return ( +
+ {images.map((block, index) => )} +
+ ) +} + +function AssistantToolCalls({ + blocks, + preview, + onOpenCall, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean + onOpenCall(callId: string): void +}) { + const calls = blocks?.filter(block => block.type === 'tool-call') ?? [] + if (calls.length === 0) return null + return ( +
    + {calls.map((call, index) => ( +
  • + +
  • + ))} +
+ ) +} + +function ToolOutputBlocks({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] + preview: boolean +}) { + return ( +
+ {blocks.map((block, index) => ( + block.imageSrc !== undefined + ? + : block.content !== '' + ?
{block.content}
+ : null + ))} +
+ ) +} + +function MarkdownRecordContent({ + record, + rendered, + preview = false, + thinkingExpanded, + onThinkingExpandedChange, + onOpenCall, +}: { + record: TableRecord + rendered: boolean + preview?: boolean + thinkingExpanded: boolean + onThinkingExpandedChange(expanded: boolean): void + onOpenCall(callId: string): void +}) { + if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { + return + } + if (record.cell.kind === 'message' && record.cell.thinkingDetail) { + if (!rendered) { + const source = [ + record.cell.thinkingDetail, + record.cell.outputDetail, + ].filter((value): value is string => value !== undefined && value !== '').join('\n\n') + return + } + return ( +
+
+ + {thinkingExpanded && ( + + )} +
+ {record.cell.outputDetail && ( +
+ +
+ )} + + +
+ ) + } + const source = markdownSource(record) + const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasToolCalls = record.cell.kind === 'message' + && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true + if (!source && !hasImages && !hasToolCalls) { + const emptyLabel = isToolCallOnly(record.cell) + ? 'Tool call only' + : record.cell.text || 'No content' + return

{emptyLabel}

+ } + if (!rendered || (!hasImages && !hasToolCalls)) { + return + } + return ( +
+ {source && } + {record.cell.kind === 'message' && ( + + )} + +
+ ) +} + +function RecordTiming({ record }: { record: TableRecord }) { + return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined + ? + : ( +
+
Started
+
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
+
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
+
+ ) +} + +function RecordPayload({ + record, + direction, + preview = false, +}: { + record: TableRecord + direction: 'input' | 'output' + preview?: boolean +}) { + const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail + const missing = direction === 'input' + ? 'No payload captured' + : 'No result captured' + if (!value) return

{missing}

+ + if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) { + return ( + + ) + } + + const markdown = ( + direction === 'input' && record.cell.kind === 'user' + ) || ( + direction === 'output' && record.cell.kind === 'message' + ) + if (markdown) { + return ( +
+ +
+ ) + } + const json = parseJsonContainer(value) + if (json !== undefined) { + return ( + + ) + } + return ( +
 value !== undefined).join(' ')}
+    >
+      {value}
+    
+ ) +} + +function RecordSchema({ + record, + preview = false, +}: { + record: TableRecord + preview?: boolean +}) { + if (!record.cell.schemaDetail) { + return

Schema unavailable

+ } + const schema = parseToolSchema(record.cell.schemaDetail) + if (schema !== undefined) { + return ( +
+
+

{schema.name}

+

{schema.description}

+
+
+

Parameters

+ +
+
+ ) + } + return ( +
+      {record.cell.schemaDetail}
+    
+ ) +} + +interface ParsedToolSchema { + name: string + description: string + parameters: object +} + +function parseToolSchema(value: string): ParsedToolSchema | undefined { + try { + const parsed: unknown = JSON.parse(value) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined + const schema = parsed as Record + if ( + typeof schema.name !== 'string' + || typeof schema.description !== 'string' + || typeof schema.parameters !== 'object' + || schema.parameters === null + || Array.isArray(schema.parameters) + ) return undefined + return { + name: schema.name, + description: schema.description, + parameters: schema.parameters, + } + } catch { + return undefined + } +} + +function parseJsonContainer(value: string): object | undefined { + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === 'object' && parsed !== null ? parsed : undefined + } catch { + return undefined + } +} + +function OverviewSection({ + label, + onOpen, + children, +}: { + label: string + onOpen(): void + children: ReactNode +}) { + return ( +
+

+ +

+
{children}
+
+ ) +} + +/** + * Render trajectory events as a dense ledger with turn and step separators. + * @param props - Grouped trajectory data and whole-ledger fold state. + * @returns The ledger and an optional local record inspector. + */ +export function TrajectoryTable({ + turns, + collapsedTurns, + onToggleTurn, + collapsedAssistants, + onToggleAssistant, +}: TrajectoryTableProps) { + const [selectedIndex, setSelectedIndex] = useState(null) + const [activeTab, setActiveTab] = useState('overview') + const [thinkingExpanded, setThinkingExpanded] = useState(true) + const [detailsWidth, setDetailsWidth] = useState(null) + const [toolRequestOffset, setToolRequestOffset] = useState(null) + const detailsResizeDrag = useRef(null) + const tabHistory = useRef>(new Set(['overview'])) + const allRecords = flattenRecords(turns) + const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) + const records = collapseAssistantRecords(turnRecords, collapsedAssistants) + const selected = allRecords.find(record => record.cell.index === selectedIndex) + const selectedState = selected === undefined ? undefined : stateOf(selected) + const selectedTabs = selected === undefined ? [] : detailTabs(selected) + const selectedParents: ParentRecords = selected === undefined + ? {} + : parentRecords(allRecords, selected) + const hasSelectedParents = selectedParents.message !== undefined + || selectedParents.tool !== undefined + const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null + ? undefined + : { + '--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`, + } + + const activateTab = (tab: DetailTab) => { + tabHistory.current.delete(tab) + tabHistory.current.add(tab) + setActiveTab(tab) + } + + const selectRecord = (index: number) => { + const record = allRecords.find(candidate => candidate.cell.index === index) + setSelectedIndex(index) + if (record === undefined) return + const available = new Set(detailTabs(record).map(tab => tab.id)) + const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) + setActiveTab(recent ?? 'overview') + } + + const openRecordSummary = (target: TableRecord) => { + const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) + if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) + if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') { + for (let i = targetAt - 1; i >= 0; i--) { + const candidate = allRecords[i] + if (candidate === undefined || candidate.turn !== target.turn) break + if (candidate.cell.kind !== 'message') continue + if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index) + break + } + } + setSelectedIndex(target.cell.index) + activateTab('overview') + } + + const openCallSummary = (callId: string) => { + const target = allRecords.find(record => record.cell.callId === callId) + if (target !== undefined) openRecordSummary(target) + } + + return ( +
+
+ + + + + + + {records.map((record) => { + const displayText = recordDisplayText(record.cell) + const isCollapsedSummary = record.collapsedSummary !== undefined + return ( + { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + } + : () => { selectRecord(record.cell.index) }} + onDoubleClick={(event) => { + if (isCollapsedSummary) return + if (collapsedTurns.has(record.turn)) { + event.preventDefault() + onToggleTurn(record.turn) + return + } + if ( + record.cell.kind === 'message' + && assistantToolCalls(allRecords, record.cell.index).length > 0 + ) { + event.preventDefault() + onToggleAssistant(record.cell.index) + return + } + if (!record.turnStart) return + if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return + event.preventDefault() + onToggleTurn(record.turn) + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (isCollapsedSummary) { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + return + } + selectRecord(record.cell.index) + }} + > + + + + ) + })} + +
+ {selected?.turn === record.turn && ( + + {record.collapsedSummary !== undefined + ? ( + + + {record.collapsedSummary} + + ) + : ( + + + {isToolCallOnly(record.cell) ? null : displayText || '—'} + + {record.cell.result !== undefined && ( + + + {record.cell.result} + + )} + + )} +
+
+ {selected !== undefined && selectedState !== undefined && ( + + )} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css new file mode 100644 index 0000000000..d5d81521c2 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -0,0 +1,85 @@ +.root { + position: sticky; + top: 0; + z-index: 4; + box-sizing: border-box; + width: 100%; + height: var(--dsh-trajectory-toolbar-height); + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 0 14px 0 16px; +} + +.summary { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.title { + flex: none; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-strong-13); +} + +.actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; +} + +.action { + display: inline-flex; + flex: none; + align-items: center; + box-sizing: border-box; + height: 26px; + padding: 0 7px; + gap: 6px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-label-tertiary); + background: transparent; + cursor: pointer; + font: var(--dsw-font-xs-13); + transition: + color 120ms var(--ds-ease-in-out), + background-color 120ms var(--ds-ease-in-out); +} + +.action:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.action:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: not-allowed; +} + +.actionIcon { + color: var(--dsw-alias-label-tertiary); + font: 13px/13px var(--ds-font-family-code); +} + +@media (max-width: 720px) { + .summary { + gap: 7px; + } +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx new file mode 100644 index 0000000000..f063377a00 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -0,0 +1,66 @@ +/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */ + +import css from './TrajectoryToolbar.module.css' + +export interface TrajectoryToolbarProps { + /** Number of turns containing more than one row. */ + collapsibleTurns: number + /** Whether every collapsible turn is currently folded. */ + allTurnsCollapsed: boolean + /** Fold or expand every collapsible turn. */ + onToggleAllTurns(): void + /** Number of assistant messages followed by tool calls. */ + collapsibleAssistants: number + /** Whether every collapsible assistant's tool calls are currently folded. */ + allAssistantsCollapsed: boolean + /** Fold or expand tool calls under every collapsible assistant. */ + onToggleAllAssistants(): void +} + +/** + * Render the sticky trajectory toolbar. + * @param props - rendered counts and whole-list fold state. + * @returns the toolbar element. + */ +export function TrajectoryToolbar({ + collapsibleTurns, + allTurnsCollapsed, + onToggleAllTurns, + collapsibleAssistants, + allAssistantsCollapsed, + onToggleAllAssistants, +}: TrajectoryToolbarProps) { + return ( +
+
+
+ Trajectory +
+
+ + +
+
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 3d417b085e..ea1bb8a1e7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,41 +1,110 @@ -// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. +/** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { TrajectoryCell } from './TrajectoryCell.tsx' -import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' -import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { TrajectoryTable } from './TrajectoryTable.tsx' +import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { + const [collapsedTurns, setCollapsedTurns] = useState>(() => new Set()) + const [collapsedAssistants, setCollapsedAssistants] = useState>(() => new Set()) const nodes = useSession((s) => s.nodes) const partial = useSession((s) => s.partial) const runningCalls = useSession((s) => s.runningCalls) + const callSchemas = useSession((s) => s.callSchemas) const codeDispatches = useSession((s) => s.codeDispatches) const turns = useMemo( - () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }), - [nodes, partial, runningCalls, codeDispatches], + () => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }), + [nodes, partial, runningCalls, callSchemas, codeDispatches], ) - if (turns.length === 0) { - return

暂无轨迹数据

+ const collapsibleTurnIds = useMemo( + () => turns + .filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1) + .map(turn => turn.turn), + [turns], + ) + const allTurnsCollapsed = collapsibleTurnIds.length > 0 + && collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) + const collapsibleAssistantIds = useMemo(() => { + const ids: number[] = [] + for (const turn of turns) { + const cells = turn.groups.flatMap(group => group.cells) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell?.kind !== 'message') continue + const next = cells[i + 1] + if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index) + } + } + return ids + }, [turns]) + const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 + && collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) + + const toggleTurn = (turn: number) => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (next.has(turn)) next.delete(turn) + else next.add(turn) + return next + }) } + + const toggleAllTurns = () => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (allTurnsCollapsed) { + for (const turn of collapsibleTurnIds) next.delete(turn) + } else { + for (const turn of collapsibleTurnIds) next.add(turn) + } + return next + }) + } + + const toggleAssistant = (index: number) => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (next.has(index)) next.delete(index) + else next.add(index) + return next + }) + } + + const toggleAllAssistants = () => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (allAssistantsCollapsed) { + for (const index of collapsibleAssistantIds) next.delete(index) + } else { + for (const index of collapsibleAssistantIds) next.add(index) + } + return next + }) + } + return (
- {turns.map((turn) => ( - - {turn.groups.flatMap((group) => [ - , - ...group.cells.map((cell) => ( - - )), - ])} - - ))} + + {turns.length === 0 &&

No trajectory events

} + {turns.length > 0 && ( + + )}
) } diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index ad81845fb9..6776680882 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -28,7 +28,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa const codeDispatches = useSession((s) => s.codeDispatches) const spans = useMemo(() => deriveSpans(nodes), [nodes]) const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches]) - if (spans.length === 0) return

暂无瀑布数据

+ if (spans.length === 0) return

No timing data

return ( <> diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 37c86f6eb4..54d025ab8f 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -3,12 +3,16 @@ * own-duration times, in-flight partial/runningCalls, and group descriptions. */ import type { + AssistantBlock, AssistantMessageNode, CodeSubCall, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import type { TrajectoryCellProps } from './TrajectoryCell.tsx' +import type { + TrajectoryCellProps, + TrajectorySourceBlock, +} from './trajectory-record.ts' /** One Message or Step group inside a turn. */ export interface TrajectoryGroupModel { @@ -28,6 +32,7 @@ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] + callSchemas?: ConversationSnapshot['callSchemas'] /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */ codeDispatches: ConversationSnapshot['codeDispatches'] } @@ -52,8 +57,17 @@ interface LaidCell { * @returns turns ordered by first appearance. */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { - const { nodes, partial, runningCalls, codeDispatches } = input + const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input const resultByCall = indexResults(nodes) + const callStartById = new Map() + for (const result of resultByCall.values()) { + const startedAt = finiteTime(result.callTime) + if (startedAt !== null) callStartById.set(result.callId, startedAt) + } + for (const call of runningCalls) { + const startedAt = finiteTime(call.time) + if (startedAt !== null) callStartById.set(call.callId, startedAt) + } const turns = new Map }>() let index = 0 let prevAbsTime: number | null = null @@ -92,14 +106,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T absTime: finiteTime(node.time), cell: { index: ++index, kind: 'user', text: summarizeContent(node.content), + opensTurn: node.kind === 'user', + inputDetail: detailContent(node.content), + sourceBlocks: node.content.map(block => sourceBlock(block)), timeSeconds: 0, + startedAt: finiteTime(node.time), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'assistant') { - const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches) + const laidList = withSubCalls( + expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), + codeDispatches, + ) for (const laid of laidList) { if (node.step > 0) pushStep(node.turn, node.step, laid) else pushMessage(node.turn, laid) @@ -128,7 +149,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T text: node.call !== null ? summarizeCall(node.call.name, node.call.argsRaw) : summarizeResult(node), + ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), + outputDetail: detailResult(node), + outputBlocks: node.content.map(block => sourceBlock(block)), + result: summarizeResult(node), + callId: node.callId, + isError: node.isError, timeSeconds: durationSeconds(node.time, node.callTime), + startedAt: finiteTime(node.callTime), }, }) for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { @@ -145,7 +173,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, turn: partial.turn, step: partial.step, blocks: partial.blocks, } - const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + const laidList = expandAssistant( + fake, + index + 1, + prevAbsTime, + resultByCall, + callStartById, + { streaming: true }, + ) for (const laid of laidList) { if (partial.step > 0) pushStep(partial.turn, partial.step, laid) else pushMessage(partial.turn, laid) @@ -165,7 +200,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'tool', text: summarizeCall(call.name, call.argsRaw), + inputDetail: call.argsRaw, + callId: call.callId, timeSeconds: null, + startedAt: finiteTime(call.time), }, }) for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { @@ -191,11 +229,28 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T turns.set(1, first) } + for (const entry of turns.values()) { + for (const laid of entry.message) attachToolSchema(laid, callSchemas) + for (const laid of entry.steps.values()) { + for (const cell of laid) attachToolSchema(cell, callSchemas) + } + } + return [...turns.entries()] .sort(([a], [b]) => a - b) .map(([turn, entry]) => toTurnModel(turn, entry)) } +function attachToolSchema( + laid: LaidCell, + callSchemas: ConversationSnapshot['callSchemas'], +): void { + if (laid.callId === undefined || callSchemas === undefined) return + const schema = callSchemas.get(laid.callId) + if (schema === undefined) return + laid.cell.schemaDetail = JSON.stringify(schema, null, 2) +} + function toTurnModel( turn: number, entry: { message: LaidCell[]; steps: Map }, @@ -267,8 +322,8 @@ function durationSeconds(later: number, earlier: number | null): number | null { } /** Epoch-ms usable as an absolute time, else null. */ -function finiteTime(time: number): number | null { - return Number.isFinite(time) ? time : null +function finiteTime(time: number | null | undefined): number | null { + return typeof time === 'number' && Number.isFinite(time) ? time : null } function expandAssistant( @@ -276,6 +331,7 @@ function expandAssistant( startIndex: number, prevAbsTime: number | null, results: Map, + callStarts: ReadonlyMap, opts?: { streaming?: boolean }, ): LaidCell[] { const out: LaidCell[] = [] @@ -284,58 +340,155 @@ function expandAssistant( const streaming = opts?.streaming === true const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) const nodeAbs = streaming ? null : finiteTime(node.time) - let usageAttached = false + const messageText = node.blocks + .filter(block => block.kind === 'text' && (!streaming || block.text !== '')) + .map(block => block.kind === 'text' ? block.text : '') + .join('\n\n') + const thinkingText = node.blocks + .filter(block => block.kind === 'reasoning' && (!streaming || block.text !== '')) + .map(block => block.kind === 'reasoning' ? block.text : '') + .join('\n\n') + const message: TrajectoryCellProps = { + index: ++index, + kind: 'message', + text: messageText !== '' + ? summarizeText(messageText) + : thinkingText !== '' + ? summarizeText(thinkingText) + : summarizeAssistantActivity(node.blocks), + ...(messageText !== '' ? { outputDetail: messageText } : {}), + ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), + sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), + timeSeconds: messageDuration, + startedAt: finiteTime(node.timing?.stepStartTime), + } + attachUsage(message, usage) + message.assistantMetrics = { + timingRecorded: node.timing !== undefined, + stepStartTime: node.timing?.stepStartTime ?? null, + firstTokenTime: node.timing?.firstTokenTime ?? null, + completedTime: streaming ? null : finiteTime(node.time), + usageProvided: usage !== undefined, + outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null, + } + out.push({ absTime: nodeAbs, cell: message }) for (const block of node.blocks) { - // Reasoning blocks are skipped: no block-level clock, so no Think cell. - if (block.kind === 'reasoning') continue - if (block.kind === 'text') { - if (block.text === '' && streaming) continue - const cell: TrajectoryCellProps = { - index: ++index, kind: 'message', text: summarizeText(block.text), - timeSeconds: messageDuration, - } - if (!usageAttached) { - attachUsage(cell, usage) - usageAttached = usage !== undefined - } - out.push({ absTime: nodeAbs, cell }) - continue - } - if (block.kind === 'tool-call') { - const result = results.get(block.callId) - const toolDuration = streaming || result === undefined - ? null - : durationSeconds(result.time, result.callTime) - const callAbs = streaming - ? null - : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) - ? result.callTime - : nodeAbs) - out.push({ - absTime: callAbs, - toolName: block.name, + // Text and reasoning belong to the one Assistant record emitted above. + if (block.kind !== 'tool-call') continue + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = finiteTime(callStarts.get(block.callId)) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + inputDetail: block.argsRaw, callId: block.callId, - cell: { - index: ++index, kind: 'tool', - text: summarizeCall(block.name, block.argsRaw), - timeSeconds: toolDuration, - }, - }) - } - } - - if (out.length === 0 && !streaming) { - // Reasoning-only / empty success still owns provider usage on the Message row. - const cell: TrajectoryCellProps = { - index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, - } - attachUsage(cell, usage) - out.push({ absTime: nodeAbs, cell }) + ...(result !== undefined + ? { + outputDetail: detailResult(result), + outputBlocks: result.content.map(block => sourceBlock(block)), + result: summarizeResult(result), + isError: result.isError, + } + : {}), + timeSeconds: toolDuration, + startedAt: callAbs, + }, + }) } return out } +function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string { + const tools = new Map() + for (const block of blocks) { + if (block.kind !== 'tool-call') continue + tools.set(block.name, (tools.get(block.name) ?? 0) + 1) + } + if (tools.size > 0) { + return 'Tool call only' + } + return '' +} + +function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { + switch (block.kind) { + case 'text': return { type: 'text', content: block.text } + case 'reasoning': return { type: 'thinking', content: block.text } + case 'tool-call': return { + type: 'tool-call', + content: block.argsRaw, + callId: block.callId, + toolName: block.name, + } + case 'other': return sourceBlock(block.block) + } +} + +function sourceBlock(value: unknown): TrajectorySourceBlock { + if (typeof value !== 'object' || value === null) { + return { type: 'unknown', content: stringifySourceValue(value) } + } + const block = value as Record + const type = typeof block.type === 'string' ? block.type : 'unknown' + if (typeof block.text === 'string') { + return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } + } + const imageSrc = sourceImage(block) + const imageAlt = typeof block.alt === 'string' ? block.alt : undefined + return { + type, + content: imageSrc === undefined ? stringifySourceValue(value) : '', + ...(imageSrc !== undefined ? { imageSrc } : {}), + ...(imageAlt !== undefined ? { imageAlt } : {}), + } +} + +function sourceImage(block: Record): string | undefined { + if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined + for (const candidate of [block.url, block.image_url]) { + if (typeof candidate === 'string') return safeImageSource(candidate) + } + if (typeof block.data === 'string') { + const mediaType = [block.mimeType, block.mediaType, block.media_type] + .find((candidate): candidate is string => typeof candidate === 'string') + ?? 'image/png' + return safeImageSource( + block.data.startsWith('data:') + ? block.data + : `data:${mediaType};base64,${block.data}`, + ) + } + if (typeof block.source !== 'object' || block.source === null) return undefined + const source = block.source as Record + if (typeof source.url === 'string') return safeImageSource(source.url) + if (typeof source.data !== 'string') return undefined + const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' + return safeImageSource(`data:${mediaType};base64,${source.data}`) +} + +function safeImageSource(value: string): string | undefined { + if (value.startsWith('data:image/') || value.startsWith('blob:')) return value + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + +function stringifySourceValue(value: unknown): string { + const json = JSON.stringify(value, null, 2) + return json ?? String(value) +} + /** * Turn that encloses a user/message: next assistant/steering turn, else the * in-flight partial, else the turn after the last finalized assistant (or 1). @@ -433,12 +586,27 @@ function expandSubCalls( cell: { index: ++index, kind: 'subtool', + callId: sub.callId, text: settled ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) : summarizeCall(sub.name, sub.argsRaw), + ...(settled + ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) + : { inputDetail: sub.argsRaw }), + ...(settled + ? { + outputDetail: detailResult(sub), + outputBlocks: sub.content.map(block => sourceBlock(block)), + result: summarizeResult(sub), + isError: sub.isError, + } + : {}), // PR3's start/settle pair carries per-sub-call wall time; a running // (unsettled) or pre-pair log entry shows the em dash. timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null, + startedAt: settled + ? finiteTime(sub.callTime) + : finiteTime(sub.time), }, }) } @@ -448,8 +616,7 @@ function expandSubCalls( function summarizeCall(name: string, argsRaw: string): string { const args = argsRaw.replace(/\s+/g, ' ').trim() if (args === '') return name - const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args - return `${name} · ${clipped}` + return `${name} · ${args}` } function summarizeResult(node: ToolResultNode): string { @@ -464,6 +631,27 @@ function summarizeResult(node: ToolResultNode): string { return node.call?.name ?? node.callId } +function detailResult(node: ToolResultNode): string { + if (node.isError) { + return node.error === undefined + ? 'error' + : `${node.error.name}: ${node.error.code}` + } + const text = node.content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text !== '') return text + return JSON.stringify(node.content, null, 2) +} + +function detailContent(content: readonly { type: string; text?: string }[]): string { + return content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.text ?? '') + .join('\n') +} + function summarizeContent(content: readonly { type: string; text?: string }[]): string { for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts new file mode 100644 index 0000000000..9b2a835de9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -0,0 +1,81 @@ +/** Shared trajectory record data and formatting contracts. */ + +import type { HTMLAttributes } from 'react' + +/** Closed set of trajectory record kinds. */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool' + +/** Recorded inputs needed to derive assistant TTFT and decode throughput. */ +export interface AssistantMetricDetail { + timingRecorded: boolean + stepStartTime: number | null + firstTokenTime: number | null + completedTime: number | null + usageProvided: boolean + outputTokens: number | null +} + +/** One source content block preserved in model order for the details panel. */ +export interface TrajectorySourceBlock { + type: string + content: string + imageSrc?: string + imageAlt?: string + callId?: string + toolName?: string +} + +/** Data and optional presentation attributes for one trajectory record. */ +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based record index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** Whether this user record opens a new model turn. */ + opensTurn?: boolean + /** Full request/message content for the details panel. */ + inputDetail?: string + /** Full assistant/tool result content for the details panel. */ + outputDetail?: string + /** Full assistant reasoning content for the details panel. */ + thinkingDetail?: string + /** Original message blocks in source order for the details panel. */ + sourceBlocks?: readonly TrajectorySourceBlock[] + /** Original tool result blocks in source order for the details panel. */ + outputBlocks?: readonly TrajectorySourceBlock[] + /** Call-time model-visible tool schema for the details panel. */ + schemaDetail?: string + /** Assistant-only timing and token facts for the details panel. */ + assistantMetrics?: AssistantMetricDetail + /** Tool-only result summary paired with the call in the same record. */ + result?: string + /** Tool call id used to link message source blocks to tool records. */ + callId?: string + /** Tool-only result failure state. */ + isError?: boolean + /** Own duration in seconds, or `null` when no duration is known. */ + timeSeconds: number | null + /** Unix epoch milliseconds when this operation actually started, when known. */ + startedAt?: number | null + /** Message-only prompt token count. */ + input?: number + /** Message-only completion token count. */ + output?: number + /** Message-only reasoning token count. */ + think?: number + /** Whether the legacy standalone cell renders its selection treatment. */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column. + * @param seconds - Duration seconds, or `null` when absent. + * @returns `—` when unknown, otherwise a signed seconds label. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 16a853c441..4f378e7fc3 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,17 +1,24 @@ -/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; - * cell content width is capped on the turn body (max 880). */ +/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */ .root { - overflow-y: auto; + --dsh-trajectory-toolbar-height: 48px; + + display: flex; + flex-direction: column; + overflow: hidden; height: 100%; min-height: 0; width: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); - background: var(--dsw-specific-sidebar-fill); + background: var(--dsw-alias-bg-layer-1); } .empty { - padding: 16px; + display: grid; + flex: 1; + margin: 0; + padding: 24px; + place-items: center; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index b74782a4c4..a78ca1ba42 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => { }, ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + expect(turns[0]?.groups[0]?.description).toBe('3s bash×2') }) it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { @@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => { expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) }) - it('keeps usage on the fallback Message row when assistant has no text block', () => { + it('keeps usage and a meaningful summary when assistant has no text block', () => { const nodes = [ { kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, @@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') expect(message).toMatchObject({ - text: '', input: 11, output: 22, think: 3, + text: '仅推理输出', input: 11, output: 22, think: 3, }) }) @@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => { ]]]) as unknown as ConversationSnapshot['codeDispatches'] const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const cells = turns[0]!.groups.flatMap((g) => g.cells) - expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool']) + expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool']) + expect(cells[0]?.text).toBe('请求调用 run_code') // Sequential indexes across the interleave; durations from the pair times. - expect(cells.map((c) => c.index)).toEqual([1, 2, 3]) - expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) - expect(cells[2]).toMatchObject({ timeSeconds: 0.5 }) + expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4]) + expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx new file mode 100644 index 0000000000..3b096a1955 --- /dev/null +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +/** Trajectory ledger selection, details, status, and fold behavior. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryTurnModel } from '../src/client/layout.ts' + +afterEach(cleanup) + +const TURNS: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + description: '1.5s bash×2', + cells: [ + { + index: 1, + kind: 'message', + text: 'Checking files', + outputDetail: 'Checking files', + input: 10, + output: 20, + think: 5, + timeSeconds: 1.5, + assistantMetrics: { + timingRecorded: true, + stepStartTime: 1_000, + firstTokenTime: 1_500, + completedTime: 2_500, + usageProvided: true, + outputTokens: 20, + }, + }, + { + index: 2, + kind: 'tool', + text: 'bash · {"command":"pwd"}', + inputDetail: '{"command":"pwd"}', + timeSeconds: null, + }, + { + index: 3, + kind: 'tool', + text: 'bash · {"command":"false"}', + inputDetail: '{"command":"false"}', + outputDetail: 'ToolError: non_zero_exit', + result: 'non_zero_exit', + isError: true, + timeSeconds: 0.2, + }, + ], + }], +}] + +describe('TrajectoryTable', () => { + it('shows assistant timing facts after keyboard selection', () => { + render() + fireEvent.keyDown(screen.getByRole('row', { name: /记录 1,ASSISTANT/ }), { key: 'Enter' }) + fireEvent.click(screen.getByRole('tab', { name: '计时' })) + + expect(screen.getByText('500 ms')).toBeTruthy() + expect(screen.getByText('1.00 s')).toBeTruthy() + expect(screen.getByText('20.0 tok/s')).toBeTruthy() + }) + + it('keeps running and failure semantics distinct from record roles', () => { + const view = render() + expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() + expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy() + + fireEvent.click(screen.getByRole('row', { name: /记录 2,TOOL/ })) + expect(screen.getByText('进行中')).toBeTruthy() + fireEvent.click(screen.getByRole('row', { name: /记录 3,TOOL/ })) + expect(screen.getByText('失败')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy() + }) + + it('retains the ledger header and record count when collapsed', () => { + render() + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByText('3 条记录已收起')).toBeTruthy() + expect(screen.queryByRole('row', { name: /记录 1,ASSISTANT/ })).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index cbc8760dae..b755fe8b5f 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -4,7 +4,7 @@ * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab * projection apply uses) without collapsing chat, trajectory renders the - * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' @@ -169,22 +169,48 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { + it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() - mount(b.slots) + const view = mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.queryByText(/turns ·/)).toBeNull() - expect(screen.getByText('Turn 1')).toBeTruthy() - expect(screen.getByText('Turn 2')).toBeTruthy() - expect(screen.getAllByText('Message').length).toBeGreaterThan(0) - expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) - expect(screen.getAllByText('Input').length).toBeGreaterThan(0) + expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) + expect(screen.getAllByLabelText('Step 1')).toHaveLength(2) + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录') + fireEvent.click(screen.getByRole('button', { name: '收起记录' })) + expect(screen.getByText('4 条记录已收起')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开记录' })) + expect(screen.getByRole('row', { name: /记录 1,USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { + const b = await bench() + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + + fireEvent.keyDown(screen.getByRole('row', { name: /记录 3,TOOL/ }), { key: 'Enter' }) + expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy() + expect(screen.getByText('记录 #3')).toBeTruthy() + expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() + expect(screen.getByText('完成')).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: '输入' })) + expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('[]')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) + expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull() + }) + it('waterfall renders bars and switching back to chat does not collapse it', async () => { const b = await bench() mount(b.slots) diff --git a/patches/react-json-view-lite@2.5.0.patch b/patches/react-json-view-lite@2.5.0.patch new file mode 100644 index 0000000000..b7b4b7425a --- /dev/null +++ b/patches/react-json-view-lite@2.5.0.patch @@ -0,0 +1,319 @@ +diff --git a/dist/DataRenderer.d.ts b/dist/DataRenderer.d.ts +index 6838d1c3aa8cea1e77534801bf02b9c11ddad1de..3dc8c6568cc4f174126dad32c33a64b0dc25f198 100644 +--- a/dist/DataRenderer.d.ts ++++ b/dist/DataRenderer.d.ts +@@ -30,6 +30,8 @@ interface CommonRenderProps { + clickToExpandNode: boolean; + outerRef: React.RefObject; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + } + export interface JsonRenderProps extends CommonRenderProps { + field?: string; +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 23e9bca4586b7ea71e7d016230e33eb444cd1304..c49677664f0f0a10e67be688a0157ef9f0d93d6d 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -16,10 +16,12 @@ export interface Props extends React.AriaAttributes { + shouldExpandNode?: (level: number, value: any, field?: string) => boolean; + clickToExpandNode?: boolean; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + compactTopLevel?: boolean; + } + export declare const defaultStyles: StyleProps; + export declare const darkStyles: StyleProps; + export declare const allExpanded: () => boolean; + export declare const collapseAllNested: (level: number) => boolean; +-export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; ++export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, renderExpandableValue, renderStringValue, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; +diff --git a/dist/index.js b/dist/index.js +index ab8b98c992bd34e00e7c3a0257d6778d7928dde8..669304fbba450c789bf4a4264f0627a8eec9d178 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = React.useRef(false); + const [expanded, setExpanded] = React.useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/React.createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + React.createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/React.createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/React.createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/React.createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/React.createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/React.createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + +diff --git a/dist/index.modern.js b/dist/index.modern.js +index e25aea1bc8936b69275b9fbe7011d0466480f99c..f4c509e4459f70080b90695c00f8a17e8f4aac4e 100644 +--- a/dist/index.modern.js ++++ b/dist/index.modern.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = useRef(false); + const [expanded, setExpanded] = useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..3491486b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + react-json-view-lite@2.5.0: 8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e + importers: .: @@ -994,12 +997,24 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-json-view-lite: + specifier: ^2.5.0 + version: 2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.31)(react@18.3.1) @@ -1279,6 +1294,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -9283,6 +9301,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@2.5.0: + resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -14434,6 +14458,10 @@ snapshots: react-is@17.0.2: {} + react-json-view-lite@2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1): + dependencies: + react: 18.3.1 + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..ed1bca64f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.81.1' + +patchedDependencies: + react-json-view-lite@2.5.0: patches/react-json-view-lite@2.5.0.patch From 714090bb4d7ba380502f01bc61984fb997a2802b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 15:58:06 +0800 Subject: [PATCH 003/117] feat(ui): add trajectory context generations --- packages/client/runtime/src/client/index.ts | 4 +- .../src/client/sessions/conversation.ts | 21 +++ .../src/client/sessions/fold-adapter.ts | 79 ++++++++++- .../runtime/src/client/sessions/session.ts | 2 + .../src/client/ContextsPanel.module.css | 116 ++++++++++++++++ .../src/client/ContextsPanel.tsx | 84 ++++++++++++ .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 2 + .../src/client/TrajectoryTable.module.css | 6 + .../src/client/TrajectoryTable.tsx | 18 ++- .../src/client/TrajectoryToolbar.module.css | 30 ++++ .../src/client/TrajectoryToolbar.tsx | 15 ++ .../src/client/TrajectoryView.tsx | 129 ++++++++++++++---- .../client/ui-trajectory/src/client/layout.ts | 14 +- .../src/client/trajectory-record.ts | 2 +- .../ui-trajectory/src/client/views.module.css | 16 +++ packages/core/session/src/surface.ts | 42 +++++- 17 files changed, 542 insertions(+), 43 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 15b3beebb8..5ee2b3e492 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,8 +28,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, QueuedMessage, RunningToolCall, + AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, + ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 609c2830bc..b888e8668f 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -206,6 +206,25 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' */ export type ComposerPhase = 'blank' | 'engaging' | 'active' +/** Operation that started a new append-only model context. */ +export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' + +/** One immutable model-context generation reconstructed from surface replacements. */ +export interface ConversationContext { + /** Zero-based generation within the session; stable across later appends. */ + id: number + /** Previous generation in this session; absent for the initial context. */ + parentId?: number + /** Why this generation exists; absent for the initial context. */ + origin?: ConversationContextOriginKind + /** Event seq of the replacement that created this generation. */ + originSeq?: number + /** Unix epoch ms of the replacement that created this generation. */ + createdAt?: number + /** Final frozen nodes for historical generations, or current folded nodes for the tail. */ + nodes: readonly ConversationNode[] +} + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' @@ -217,6 +236,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Surface fold product (finalized conversation nodes in surface order). */ nodes: readonly ConversationNode[] + /** Append-only context generations split at every model-surface replacement. */ + contexts?: readonly ConversationContext[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 9fc302398a..ef715854f3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -7,9 +7,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // Subpath export (package.json exports "./surface", alias added for this): all value imports // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. -import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import { + SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, +} from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { AssistantTiming, ConversationNode } from './conversation.ts' +import type { + AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, +} from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ @@ -107,6 +111,9 @@ export class FoldAdapter { * reference-stability contract (§A.9.4) starts here. */ private rev = 0 private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null + /** Revision of the model-visible surface only; log-only chunks do not rebuild context generations. */ + private surfaceRev = 0 + private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ get callIndex(): ReadonlyMap { @@ -122,6 +129,7 @@ export class FoldAdapter { */ reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void { this.rev++ + this.surfaceRev++ this.baseSeq = baseSeq this.padded = [] for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i)) @@ -146,6 +154,7 @@ export class FoldAdapter { */ append(event: SessionEvent, view?: ToolEventView): void { this.rev++ + if (isSurfaceEvent(event)) this.surfaceRev++ this.padded.push(event) this.indexCall(event, view) } @@ -193,6 +202,41 @@ export class FoldAdapter { return value } + /** + * Append-only context generations reconstructed from canonical surface replacements. + * @returns Frozen historical contexts followed by the current context. + */ + contexts(): readonly ConversationContext[] { + if (this.contextsResult !== null && this.contextsResult.rev === this.surfaceRev) { + return this.contextsResult.value + } + const current = this.nodes() + if (current.degraded) { + const value: readonly ConversationContext[] = [{ id: 0, nodes: current.nodes }] + this.contextsResult = { rev: this.surfaceRev, value } + return value + } + const value = this.surface.contexts.map((context): ConversationContext => { + const nodes: ConversationNode[] = [] + for (const seq of context.nodes) { + const node = this.materialize(seq) + if (node !== undefined) nodes.push(node) + } + if (context.origin === undefined) return { id: context.generation, nodes } + const originEvent = this.padded[context.origin.seq] + return { + id: context.generation, + parentId: context.generation - 1, + origin: contextOriginKind(originEvent), + originSeq: context.origin.seq, + ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), + nodes, + } + }) + this.contextsResult = { rev: this.surfaceRev, value } + return value + } + /** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */ private degradedSeqs(): number[] { const seqs: number[] = [] @@ -203,6 +247,21 @@ export class FoldAdapter { return seqs } + private materialize(seq: number): ConversationNode | undefined { + const cached = this.nodeCache.get(seq) + if (cached !== undefined) return cached + const event = this.padded[seq] + if (event === undefined) return + const node = materializeNode( + event, + this.callIdx, + this.resultViews.get(seq) ?? null, + event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + ) + this.nodeCache.set(seq, node) + return node + } + private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { let stepStartTime: number | null = null let firstTokenTime: number | null = null @@ -246,6 +305,22 @@ export class FoldAdapter { } } +function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { + if (event?.type !== 'user/message') return 'rewrite' + const source = event.data.source + if ( + typeof source === 'object' + && source !== null + && 'kind' in source + && 'plugin' in source + && source.kind === 'plugin' + ) { + if (source.plugin === 'compact') return 'compaction' + if (source.plugin === 'rewind') return 'rewind' + } + return 'rewrite' +} + function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { switch (chunk.type) { case 'text-delta': diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 493ca6baa7..3e1eed36bd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot { private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() + const contexts = this.foldAdapter.contexts() // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its // reference across snapshot swaps (§A.9.4). @@ -803,6 +804,7 @@ export class Session implements ObservableSnapshot { return { sessionId: this.sessionId, nodes, + contexts, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.module.css b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css new file mode 100644 index 0000000000..e358fe4055 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css @@ -0,0 +1,116 @@ +.root { + display: flex; + flex: none; + width: 218px; + min-width: 176px; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + border-right: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); +} + +.header { + display: flex; + flex: none; + height: 34px; + align-items: center; + box-sizing: border-box; + padding: 0 12px; + border-bottom: 1px solid var(--dsw-alias-border-l1); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-strong-13); + user-select: none; +} + +.list { + min-height: 0; + padding: 4px; + overflow: auto; +} + +.item { + display: flex; + width: 100%; + min-width: 0; + min-height: 42px; + align-items: center; + box-sizing: border-box; + padding: 4px 7px; + gap: 7px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: pointer; + text-align: left; +} + +.item:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.item:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.itemSelected { + background: var(--dsw-alias-interactive-bg-active); + box-shadow: inset 2px 0 var(--dsw-alias-state-business-primary); +} + +.icon { + flex: none; + color: var(--dsw-alias-label-caption); +} + +.itemSelected .icon { + color: var(--dsw-alias-state-business-primary); +} + +.itemBody { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.itemTitle, +.itemMeta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemTitle { + font: var(--dsw-font-xs-13); +} + +.itemMeta { + color: var(--dsw-alias-label-tertiary); + font: 11px/16px var(--ds-font-family-code); +} + +.current, +.frozen { + flex: none; + align-self: flex-start; + padding-top: 1px; + font: 10px/16px var(--ds-font-family-code); + user-select: none; +} + +.current { + color: var(--dsw-alias-state-business-primary); +} + +.frozen { + color: var(--dsw-alias-label-caption); +} + +@media (max-width: 820px) { + .root { + width: 184px; + } +} diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.tsx b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx new file mode 100644 index 0000000000..aa58789beb --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx @@ -0,0 +1,84 @@ +/** Context-generation selector for a trajectory session. */ + +import { IconBranchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + ConversationContext, ConversationContextOriginKind, +} from '@deepseek-ai/dsh-client-runtime/client' +import css from './ContextsPanel.module.css' + +export interface ContextsPanelProps { + contexts: readonly ConversationContext[] + selectedId: number + currentId: number + onSelect(id: number): void +} + +function formatTime(timestamp: number | undefined): string | undefined { + if (timestamp === undefined || !Number.isFinite(timestamp)) return + const date = new Date(timestamp) + const two = (value: number) => String(value).padStart(2, '0') + return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}` +} + +function originLabel(origin: ConversationContextOriginKind | undefined): string { + if (origin === 'compaction') return 'Compaction' + if (origin === 'rewind') return 'Rewind' + if (origin === 'rewrite') return 'Context rewrite' + return 'Initial context' +} + +/** Human-facing context title without exposing internal generation ids. */ +export function contextLabel(context: ConversationContext): string { + const label = originLabel(context.origin) + const time = formatTime(context.createdAt) + return time === undefined ? label : `${label} · ${time}` +} + +/** + * Render every append-only context generation in creation order. + * @param props - Contexts and the selected/current identities. + * @returns The context navigation panel. + */ +export function ContextsPanel({ + contexts, + selectedId, + currentId, + onSelect, +}: ContextsPanelProps) { + return ( + + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css index c5efc232d1..496a75c566 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -51,6 +51,11 @@ background: var(--dsw-alias-state-success-tertiary); } +.tagContext { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-3); +} + .tagMessage { color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); background: var(--dsw-specific-bubble); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index 384d0e8b30..211b1dc3d4 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -17,6 +17,7 @@ export type { /** Display label per kind (matches the design tags). */ const KIND_LABEL: Record = { user: 'User', + context: 'Context', message: 'Message', tool: 'Tool', subtool: 'Sub', @@ -24,6 +25,7 @@ const KIND_LABEL: Record = { const TAG_CLASS: Record = { user: css.tagUser!, + context: css.tagContext!, message: css.tagMessage!, tool: css.tagTool!, subtool: css.tagSubtool!, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index e936d2eb48..f7098796a2 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -230,6 +230,12 @@ background: var(--dsw-alias-state-business-tertiary); } +.context { + border-color: var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); +} + .message { border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index f50ec809a5..db153276cd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,6 +14,7 @@ import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { user: 'USER', + context: 'CONTEXT', message: 'ASSISTANT', tool: 'TOOL', subtool: 'SUBTOOL', @@ -331,7 +332,9 @@ function tokenSummary(cell: TrajectoryCellProps): string { } function isMarkdownRecord(record: TableRecord): boolean { - return record.cell.kind === 'user' || record.cell.kind === 'message' + return record.cell.kind === 'user' + || record.cell.kind === 'context' + || record.cell.kind === 'message' } function parentRecords( @@ -369,7 +372,9 @@ function parentRecords( } function markdownSource(record: TableRecord): string | undefined { - if (record.cell.kind === 'user') return record.cell.inputDetail + if (record.cell.kind === 'user' || record.cell.kind === 'context') { + return record.cell.inputDetail + } if (record.cell.kind === 'message') return record.cell.outputDetail return undefined } @@ -394,7 +399,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' - const markdown = cell.kind === 'user' + const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail : cell.kind === 'message' ? cell.outputDetail ?? cell.thinkingDetail @@ -735,7 +740,8 @@ function RecordPayload({ } const markdown = ( - direction === 'input' && record.cell.kind === 'user' + direction === 'input' + && (record.cell.kind === 'user' || record.cell.kind === 'context') ) || ( direction === 'output' && record.cell.kind === 'message' ) @@ -1015,7 +1021,9 @@ export function TrajectoryTable({ {!isCollapsedSummary && (
Trajectory + {contextLabel !== undefined && ( + <> + / + {contextLabel} + + {contextCurrent ? 'Current' : 'Frozen'} + + + )}
- {selected !== undefined && selectedState !== undefined && ( + {(promptSelected || (selected !== undefined && selectedState !== undefined)) && (
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 828d8e61a0..12bc9841f0 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -161,17 +161,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) { /> )}
- {turns.length === 0 &&

No trajectory events

} - {turns.length > 0 && ( - - )} +
From ffd2da6986edeb06393f07d084595fa72735c9cc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 17:54:53 +0800 Subject: [PATCH 005/117] feat(ui): add request trajectory details --- .../src/client/TrajectoryTable.module.css | 93 ++++++- .../src/client/TrajectoryTable.tsx | 236 +++++++++++++++++- .../client/ui-trajectory/src/client/layout.ts | 6 +- .../src/client/trajectory-record.ts | 4 + 4 files changed, 327 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 7ef67a9d38..1997faaa6f 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -32,7 +32,7 @@ } .eventColumn { - width: 96px; + width: 104px; } .contentColumn { @@ -91,6 +91,78 @@ background: var(--dsw-alias-interactive-bg-active); } +.requestBoundaryControl { + position: absolute; + z-index: 6; + top: -8px; + left: 6px; + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.requestBoundaryControl::before { + position: absolute; + top: 6px; + left: 6px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--dsw-alias-label-caption); + box-shadow: + 0 0 0 2px var(--dsw-alias-bg-layer-1), + 0 0 0 3px transparent; + content: ''; + transition: + background 120ms var(--ds-ease-in-out), + transform 120ms var(--ds-ease-in-out); +} + +.requestBoundaryControl::after { + position: absolute; + top: 2px; + left: 17px; + width: max-content; + padding: 0 4px; + border-radius: 2px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); + content: attr(data-label); + font: 9px/12px var(--ds-font-family-code); + opacity: 0; + pointer-events: none; + transform: translateX(-2px); + transition: + opacity 120ms var(--ds-ease-in-out), + transform 120ms var(--ds-ease-in-out); + user-select: none; + white-space: nowrap; +} + +.requestBoundaryControl:hover::before, +.requestBoundaryControl:focus-visible::before, +.requestBoundaryControl[aria-pressed='true']::before { + background: var(--dsw-alias-label-primary); + transform: scale(1.25); +} + +.requestBoundaryControl:hover::after, +.requestBoundaryControl:focus-visible::after { + opacity: 1; + transform: translateX(0); +} + +.requestBoundaryControl:focus-visible { + outline: none; +} + +.table tbody tr:has(.requestBoundaryControl:hover):not([data-selected='true']) { + background: transparent; +} + .event { position: relative; } @@ -143,7 +215,7 @@ .event { overflow: visible !important; padding-right: 4px !important; - padding-left: 10px !important; + padding-left: 18px !important; } .turnLabel { @@ -475,6 +547,19 @@ color: var(--dsw-alias-label-primary); } +.requestDetailsDot { + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--dsw-alias-label-secondary); +} + +.requestDetailsName { + flex: none; + font: 500 12px/16px var(--ds-font-family-code); +} + .detailsLocation { min-width: 0; overflow: hidden; @@ -610,6 +695,10 @@ align-items: center; } +.overview > .requestTokenDetail dt { + padding-left: 12px; +} + .overview dt { color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 2cd39ab5a8..8f24b1de78 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -59,6 +59,12 @@ interface ToolCallTextParts { args?: string } +interface SelectedRequest { + turn: number + number: number + group: string +} + interface DetailsResizeDrag { pointerId: number startX: number @@ -81,6 +87,10 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ { id: 'system-prompt', label: 'System Prompt' }, { id: 'tools', label: 'Tools' }, ] +const REQUEST_TABS: readonly DetailTabItem[] = [ + { id: 'overview', label: 'Summary' }, + { id: 'timing', label: 'Timing' }, +] type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string @@ -213,6 +223,12 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } +function requestNumber(group: string): number | undefined { + if (!group.startsWith('Step ')) return undefined + const value = Number(group.slice('Step '.length)) + return Number.isInteger(value) && value > 0 ? value : undefined +} + function summarizeTurn(records: readonly TableRecord[]): string { const userText = records .filter(record => record.cell.kind === 'user') @@ -811,6 +827,25 @@ function RecordTiming({ record }: { record: TableRecord }) { ) } +function RequestTiming({ + assistant, + anchor, +}: { + assistant: TableRecord | undefined + anchor: TableRecord | undefined +}) { + if (assistant !== undefined) return + return ( +
+
+
Started
+ +
+
Duration
+
+ ) +} + function RecordPayload({ record, direction, @@ -983,6 +1018,7 @@ export function TrajectoryTable({ onToggleAssistant, }: TrajectoryTableProps) { const [selectedIndex, setSelectedIndex] = useState(null) + const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [thinkingExpanded, setThinkingExpanded] = useState(true) const [detailsWidth, setDetailsWidth] = useState(null) @@ -1000,9 +1036,46 @@ export function TrajectoryTable({ const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX const selected = allRecords.find(record => record.cell.index === selectedIndex) const selectedState = selected === undefined ? undefined : stateOf(selected) - const selectedTabs = promptSelected - ? SYSTEM_PROMPT_TABS - : selected === undefined ? [] : detailTabs(selected) + const selectedRequestRecords = selectedRequest === null + ? [] + : allRecords.filter(record => + record.turn === selectedRequest.turn + && record.group === selectedRequest.group, + ) + const selectedRequestAssistant = selectedRequestRecords.find( + record => record.cell.kind === 'message', + ) + const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] + const selectedRequestState: RecordState | undefined = selectedRequest === null + ? undefined + : selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null + ? 'running' + : selectedRequestAssistant === undefined + && selectedRequestRecords.some(record => stateOf(record) === 'running') + ? 'running' + : 'complete' + const selectedRequestToolCalls = selectedRequestRecords.filter( + record => record.cell.kind === 'tool', + ).length + const selectedRequestSubtoolCalls = selectedRequestRecords.filter( + record => record.cell.kind === 'subtool', + ).length + const selectedRequestInputTotal = selectedRequestAssistant !== undefined + && ( + selectedRequestAssistant.cell.input !== undefined + || selectedRequestAssistant.cell.cacheRead !== undefined + || selectedRequestAssistant.cell.cacheWrite !== undefined + ) + ? (selectedRequestAssistant.cell.input ?? 0) + + (selectedRequestAssistant.cell.cacheRead ?? 0) + + (selectedRequestAssistant.cell.cacheWrite ?? 0) + : undefined + const activeTurn = selectedRequest?.turn ?? selected?.turn + const selectedTabs = selectedRequest !== null + ? REQUEST_TABS + : promptSelected + ? SYSTEM_PROMPT_TABS + : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined ? {} : parentRecords(allRecords, selected) @@ -1022,6 +1095,7 @@ export function TrajectoryTable({ const selectRecord = (index: number) => { const record = allRecords.find(candidate => candidate.cell.index === index) + setSelectedRequest(null) setSelectedIndex(index) if (record === undefined) return const available = new Set(detailTabs(record).map(tab => tab.id)) @@ -1030,10 +1104,17 @@ export function TrajectoryTable({ } const selectSystemPrompt = () => { + setSelectedRequest(null) setSelectedIndex(SYSTEM_PROMPT_INDEX) activateTab('system-prompt') } + const selectRequest = (request: SelectedRequest) => { + setSelectedIndex(null) + setSelectedRequest(request) + activateTab('overview') + } + const openRecordSummary = (target: TableRecord) => { const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) @@ -1046,6 +1127,7 @@ export function TrajectoryTable({ break } } + setSelectedRequest(null) setSelectedIndex(target.cell.index) activateTab('overview') } @@ -1101,13 +1183,18 @@ export function TrajectoryTable({ ? displayText : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') const isCollapsedSummary = record.collapsedSummary !== undefined + const request = record.groupStart + && !isCollapsedSummary + && !collapsedTurns.has(record.turn) + ? requestNumber(record.group) + : undefined return ( - {selected?.turn === record.turn && ( + {request !== undefined && ( + @@ -1384,6 +1507,101 @@ export function TrajectoryTable({ role="tabpanel" aria-labelledby={`trajectory-detail-${activeTab}`} > + {selectedRequest !== null + && selectedRequestState !== undefined + && activeTab === 'overview' && ( + <> +
+
+
Status
+
{statusLabel(selectedRequestState)}
+
+
+
Started
+ +
+
+
Duration
+
+ {formatElapsedSeconds( + selectedRequestAssistant?.cell.timeSeconds ?? null, + )} +
+
+ {selectedRequestInputTotal !== undefined && ( +
+
Input
+
{selectedRequestInputTotal} tok
+
+ )} + {selectedRequestAssistant?.cell.cacheRead !== undefined && ( +
+
Cached
+
{selectedRequestAssistant.cell.cacheRead} tok
+
+ )} + {selectedRequestAssistant?.cell.cacheWrite !== undefined && ( +
+
Cache created
+
{selectedRequestAssistant.cell.cacheWrite} tok
+
+ )} + {selectedRequestAssistant?.cell.input !== undefined && ( +
+
Other
+
{selectedRequestAssistant.cell.input} tok
+
+ )} + {selectedRequestAssistant?.cell.output !== undefined && ( +
+
Output
+
{selectedRequestAssistant.cell.output} tok
+
+ )} +
+
Tool calls
+
{selectedRequestToolCalls}
+
+ {selectedRequestSubtoolCalls > 0 && ( +
+
Subtool calls
+
{selectedRequestSubtoolCalls}
+
+ )} +
+
+ {selectedRequestAssistant !== undefined && ( + { openRecordSummary(selectedRequestAssistant) }} + > + + + )} + { activateTab('timing') }}> + + +
+ + )} + {selectedRequest !== null && activeTab === 'timing' && ( + + )} {promptSelected && activeTab === 'system-prompt' && ( prompt === undefined ?

Request header not recorded

diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index b217c67bd8..fcf92429ff 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -39,6 +39,8 @@ export interface TrajectoryLayoutInput { interface UsageLike { inputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number outputTokens?: number reasoningTokens?: number } @@ -498,7 +500,7 @@ function safeImageSource(value: string): string | undefined { function stringifySourceValue(value: unknown): string { const json = JSON.stringify(value, null, 2) - return json ?? String(value) + return json || String(value) } /** @@ -526,6 +528,8 @@ function enclosingUserTurn( function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { if (usage === undefined) return if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens + if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens if (usage.outputTokens !== undefined) cell.output = usage.outputTokens if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 1469adc77a..140ae8fd53 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -60,6 +60,10 @@ export interface TrajectoryCellProps extends HTMLAttributes { startedAt?: number | null /** Message-only prompt token count. */ input?: number + /** Message-only input tokens served from a provider cache. */ + cacheRead?: number + /** Message-only input tokens written into a provider cache. */ + cacheWrite?: number /** Message-only completion token count. */ output?: number /** Message-only reasoning token count. */ From c5be25781cf4ac151457a966dd533c9420f5c4e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 18:40:30 +0800 Subject: [PATCH 006/117] feat(ui): expand request trajectory inspection --- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 21 + .../src/client/sessions/fold-adapter.ts | 22 +- .../src/client/TrajectoryGroupHeader.tsx | 2 +- .../src/client/TrajectoryTable.module.css | 20 + .../src/client/TrajectoryTable.tsx | 363 ++++++++++++++---- .../src/client/TrajectoryView.tsx | 121 +++++- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 115 +++--- .../src/client/trajectory-record.ts | 4 +- 10 files changed, 522 insertions(+), 151 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index c5fef9cc2e..a47d20c73c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index bd75f83201..942d40ab0d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -60,6 +60,23 @@ export interface AssistantTiming { completedTime: number } +/** Request configuration recorded in the effective header for one assistant response. */ +export interface AssistantRequestConfig { + provider: string + model: string + thinking?: string + reasoningEffort?: string + temperature?: number + maxTokens?: number + stop?: readonly string[] +} + +/** Stable provider/model identity attached to one assistant response. */ +export interface AssistantProvenanceView { + provider: string + model: string +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -70,6 +87,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig /** Timing derived from the recorded step/chunk/message event sequence. */ timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. @@ -211,6 +230,8 @@ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' /** Latest complete model request header in force within one context generation. */ export interface ConversationPromptSnapshot { + /** Provider/model and sampling configuration from the latest effective request header. */ + config?: AssistantRequestConfig /** Rendered system prompt text; empty when the request had no system prompt. */ system: string /** Complete tool catalog sent with the request, including tools that were never called. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index c7ac094017..6f88c678e7 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -12,7 +12,7 @@ import { } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { - AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, + AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -43,6 +43,7 @@ function materializeNode( callIndex: ReadonlyMap, resultView: ToolResultView | null, assistantTiming?: AssistantTiming, + requestConfig?: AssistantRequestConfig, ): ConversationNode { switch (event.type) { case 'user/message': @@ -64,6 +65,11 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, + provenance: { + provider: event.data.provenance.provider, + model: event.data.provenance.model, + }, + ...(requestConfig === undefined ? {} : { requestConfig }), ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': @@ -204,6 +210,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) out.push(node) @@ -280,6 +287,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) return node @@ -312,6 +320,17 @@ export class FoldAdapter { return { stepStartTime, firstTokenTime, completedTime: event.time } } + private assistantRequestConfig( + event: SessionEvent<'assistant/message'>, + ): AssistantRequestConfig | undefined { + for (let i = event.seq; i >= this.baseSeq; i--) { + const candidate = this.padded[i] + if (candidate?.type !== 'request/header') continue + return candidate.data.header.config + } + return undefined + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -336,6 +355,7 @@ export class FoldAdapter { } if (event.type !== 'request/header') return this.activePrompt = { + config: event.data.header.config, system: event.data.header.system ?? '', tools: event.data.header.tools ?? [], } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx index 90252ce373..1b7b03e78e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css' export interface TrajectoryGroupHeaderProps { /** Group title (`Message`, `Step 1`, …). */ title: string - /** Secondary summary (`49s`, `2.2s skill`, …). */ + /** Secondary summary (`49 s`, `2.2 s skill`, …). */ description?: string } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 1997faaa6f..820f7c482b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -699,6 +699,26 @@ padding-left: 12px; } +.usagePanel { + padding: 4px 0 10px; +} + +.usageGroup + .usageGroup { + margin-top: 8px; +} + +.usageHeading { + margin: 0; + padding: 4px 14px 1px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-strong-13); + user-select: none; +} + +.usageGroup .overview { + padding: 0; +} + .overview dt { color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 8f24b1de78..251adb0d0a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,7 +5,9 @@ import type { CSSProperties, ReactNode } from 'react' import { extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantRequestConfig, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' @@ -41,6 +43,8 @@ type DetailTab = | 'input' | 'output' | 'schema' + | 'options' + | 'usage' | 'timing' type RecordState = 'complete' | 'running' | 'error' @@ -89,6 +93,8 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ ] const REQUEST_TABS: readonly DetailTabItem[] = [ { id: 'overview', label: 'Summary' }, + { id: 'options', label: 'Options' }, + { id: 'usage', label: 'Usage' }, { id: 'timing', label: 'Timing' }, ] @@ -172,14 +178,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
TTFT
{ttft(metrics)}
Generation
{generationTime(metrics)}
Throughput
{throughput(metrics)}
-
-
Output tokens
-
- {!metrics.usageProvided - ? 'Usage unavailable' - : metrics.outputTokens ?? 'Output tokens unavailable'} -
-
) } @@ -188,6 +186,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) { export interface TrajectoryTableProps { /** Latest model request header in force for the selected context. */ prompt?: ConversationPromptSnapshot + /** Session-global request numbers for the request groups visible in this context. */ + requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ turns: readonly TrajectoryTurnModel[] /** Turn ids whose rows after the first are folded into a summary. */ @@ -200,6 +200,27 @@ export interface TrajectoryTableProps { onToggleAssistant(index: number): void } +/** One context-local request identity paired with its session-global number. */ +export interface TrajectoryRequestNumber { + turn: number + step: number + number: number + provider?: string + model?: string + requestConfig?: AssistantRequestConfig + usage?: TrajectoryUsage + cumulativeUsage?: TrajectoryUsage +} + +/** Disjoint provider token buckets for one request or a session prefix. */ +export interface TrajectoryUsage { + input?: number + cacheRead?: number + cacheWrite?: number + output?: number + reasoning?: number +} + function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { return turns.flatMap((turn) => { let firstInTurn = true @@ -223,12 +244,35 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } -function requestNumber(group: string): number | undefined { +function requestStep(group: string): number | undefined { if (!group.startsWith('Step ')) return undefined const value = Number(group.slice('Step '.length)) return Number.isInteger(value) && value > 0 ? value : undefined } +function requestKey(turn: number, group: string): string { + return `${turn}\u0000${group}` +} + +function indexRequestNumbers( + records: readonly TableRecord[], + sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, +): ReadonlyMap { + const numbers = new Map() + for (const request of sessionNumbers ?? []) { + numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number) + } + let next = Math.max(0, ...numbers.values()) + 1 + const boundaries = records + .filter(record => record.groupStart && requestStep(record.group) !== undefined) + .sort((left, right) => left.cell.index - right.cell.index) + for (const record of boundaries) { + const key = requestKey(record.turn, record.group) + if (!numbers.has(key)) numbers.set(key, next++) + } + return numbers +} + function summarizeTurn(records: readonly TableRecord[]): string { const userText = records .filter(record => record.cell.kind === 'user') @@ -381,6 +425,94 @@ function tokenSummary(cell: TrajectoryCellProps): ReactNode { ) } +function inputTotal(usage: TrajectoryUsage): number | undefined { + if ( + usage.input === undefined + && usage.cacheRead === undefined + && usage.cacheWrite === undefined + ) return undefined + return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) +} + +function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { + if (usage === undefined) return

Usage not reported

+ const totalInput = inputTotal(usage) + return ( +
+ {totalInput !== undefined && ( +
Input
{totalInput} tok
+ )} + {usage.cacheRead !== undefined && ( +
+
Cached
+
{usage.cacheRead} tok
+
+ )} + {usage.cacheWrite !== undefined && ( +
+
Cache created
+
{usage.cacheWrite} tok
+
+ )} + {usage.input !== undefined && ( +
+
Other
+
{usage.input} tok
+
+ )} + {usage.output !== undefined && ( +
Output
{usage.output} tok
+ )} + {usage.reasoning !== undefined && ( +
+
Reasoning
+
{usage.reasoning} tok
+
+ )} +
+ ) +} + +function RequestUsagePanel({ + usage, + cumulative, +}: { + usage: TrajectoryUsage | undefined + cumulative: TrajectoryUsage | undefined +}) { + return ( +
+
+

This request

+ +
+
+

Session cumulative

+ +
+
+ ) +} + +function RequestOptions({ + options, + preview = false, +}: { + options: AssistantRequestConfig | undefined + preview?: boolean +}) { + if (options === undefined) { + return

Options not recorded

+ } + return ( + + ) +} + function isMarkdownRecord(record: TableRecord): boolean { return record.cell.kind === 'user' || record.cell.kind === 'context' @@ -435,7 +567,6 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, { id: 'source', label: 'Source' }, - { id: 'timing', label: 'Timing' }, ] } return [ @@ -1011,6 +1142,7 @@ function OverviewSection({ */ export function TrajectoryTable({ prompt, + requestNumbers: sessionRequestNumbers, turns, collapsedTurns, onToggleTurn, @@ -1026,6 +1158,7 @@ export function TrajectoryTable({ const detailsResizeDrag = useRef(null) const tabHistory = useRef>(new Set(['overview'])) const allRecords = flattenRecords(turns) + const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const records = collapseAssistantRecords(turnRecords, collapsedAssistants) const systemPromptPreview = prompt === undefined @@ -1060,26 +1193,55 @@ export function TrajectoryTable({ const selectedRequestSubtoolCalls = selectedRequestRecords.filter( record => record.cell.kind === 'subtool', ).length - const selectedRequestInputTotal = selectedRequestAssistant !== undefined - && ( - selectedRequestAssistant.cell.input !== undefined - || selectedRequestAssistant.cell.cacheRead !== undefined - || selectedRequestAssistant.cell.cacheWrite !== undefined - ) - ? (selectedRequestAssistant.cell.input ?? 0) - + (selectedRequestAssistant.cell.cacheRead ?? 0) - + (selectedRequestAssistant.cell.cacheWrite ?? 0) - : undefined + const selectedRequestInfo = selectedRequest === null + ? undefined + : sessionRequestNumbers?.find(request => request.number === selectedRequest.number) + const selectedRequestUsage = selectedRequestInfo?.usage ?? ( + selectedRequestAssistant === undefined + ? undefined + : { + ...(selectedRequestAssistant.cell.input === undefined + ? {} + : { input: selectedRequestAssistant.cell.input }), + ...(selectedRequestAssistant.cell.cacheRead === undefined + ? {} + : { cacheRead: selectedRequestAssistant.cell.cacheRead }), + ...(selectedRequestAssistant.cell.cacheWrite === undefined + ? {} + : { cacheWrite: selectedRequestAssistant.cell.cacheWrite }), + ...(selectedRequestAssistant.cell.output === undefined + ? {} + : { output: selectedRequestAssistant.cell.output }), + ...(selectedRequestAssistant.cell.think === undefined + ? {} + : { reasoning: selectedRequestAssistant.cell.think }), + } + ) + const selectedRequestCumulativeUsage = + selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage + const selectedRequestOptions = selectedRequestInfo?.requestConfig const activeTurn = selectedRequest?.turn ?? selected?.turn const selectedTabs = selectedRequest !== null - ? REQUEST_TABS + ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : promptSelected ? SYSTEM_PROMPT_TABS : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined ? {} : parentRecords(allRecords, selected) - const hasSelectedParents = selectedParents.message !== undefined + const selectedAssistantRequest = selected?.cell.kind === 'message' + ? requestNumbers.get(requestKey(selected.turn, selected.group)) + : undefined + const selectedAssistantRequestTarget: SelectedRequest | undefined = + selected !== undefined && selectedAssistantRequest !== undefined + ? { + turn: selected.turn, + number: selectedAssistantRequest, + group: selected.group, + } + : undefined + const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined + || selectedParents.message !== undefined || selectedParents.tool !== undefined const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null ? undefined @@ -1109,10 +1271,13 @@ export function TrajectoryTable({ activateTab('system-prompt') } - const selectRequest = (request: SelectedRequest) => { + const selectRequest = ( + request: SelectedRequest, + tab: 'overview' | 'timing' = 'overview', + ) => { setSelectedIndex(null) setSelectedRequest(request) - activateTab('overview') + activateTab(tab) } const openRecordSummary = (target: TableRecord) => { @@ -1186,7 +1351,7 @@ export function TrajectoryTable({ const request = record.groupStart && !isCollapsedSummary && !collapsedTurns.has(record.turn) - ? requestNumber(record.group) + ? requestNumbers.get(requestKey(record.turn, record.group)) : undefined return ( Status
{statusLabel(selectedRequestState)}
-
-
Started
- -
-
-
Duration
-
- {formatElapsedSeconds( - selectedRequestAssistant?.cell.timeSeconds ?? null, - )} -
-
- {selectedRequestInputTotal !== undefined && ( + {(selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
-
Input
-
{selectedRequestInputTotal} tok
+
Provider
+
+ {selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider} +
)} - {selectedRequestAssistant?.cell.cacheRead !== undefined && ( -
-
Cached
-
{selectedRequestAssistant.cell.cacheRead} tok
-
- )} - {selectedRequestAssistant?.cell.cacheWrite !== undefined && ( -
-
Cache created
-
{selectedRequestAssistant.cell.cacheWrite} tok
-
- )} - {selectedRequestAssistant?.cell.input !== undefined && ( -
-
Other
-
{selectedRequestAssistant.cell.input} tok
-
- )} - {selectedRequestAssistant?.cell.output !== undefined && ( + {(selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
-
Output
-
{selectedRequestAssistant.cell.output} tok
+
Model
+
+ {selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model} +
)}
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
{selectedRequestSubtoolCalls}
)} + {selectedRequestAssistant !== undefined && ( +
+
Result
+
+ +
+
+ )}
- {selectedRequestAssistant !== undefined && ( - { openRecordSummary(selectedRequestAssistant) }} - > - + {selectedRequestOptions !== undefined && ( + { activateTab('options') }}> + )} + { activateTab('usage') }}> + + { activateTab('timing') }}> )} + {selectedRequest !== null && activeTab === 'options' && ( + + )} + {selectedRequest !== null && activeTab === 'usage' && ( + + )} {selectedRequest !== null && activeTab === 'timing' && (
- {hasSelectedParents && ( + {hasSelectedHierarchy && (
Hierarchy
+ {selectedAssistantRequestTarget !== undefined && ( + + )} {selectedParents.message !== undefined && (
)} -
Duration
{formatElapsedSeconds(selected.cell.timeSeconds)}
+ {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && ( +
+
Duration
+
{formatElapsedSeconds(selected.cell.timeSeconds)}
+
+ )}
{isMarkdownRecord(selected) @@ -1696,9 +1879,21 @@ export function TrajectoryTable({ )} - { activateTab('timing') }}> - - + {selectedAssistantRequestTarget !== undefined && ( + { + selectRequest(selectedAssistantRequestTarget, 'timing') + }} + > + + + )} + {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( + { activateTab('timing') }}> + + + )}
)} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 12bc9841f0..caced65ba8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,15 +2,65 @@ import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ConversationContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationContext, +} from '@deepseek-ai/dsh-client-runtime/client' import { ContextsPanel, contextLabel } from './ContextsPanel.tsx' -import { TrajectoryTable } from './TrajectoryTable.tsx' +import { + TrajectoryTable, + type TrajectoryRequestNumber, + type TrajectoryUsage, +} from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() +interface UsageLike { + inputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +function requestUsage(value: unknown): TrajectoryUsage | undefined { + const usage = value as UsageLike | undefined + if (usage === undefined) return undefined + return { + ...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }), + ...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }), + ...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }), + ...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }), + ...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }), + } +} + +function addUsage( + total: TrajectoryUsage | undefined, + usage: TrajectoryUsage | undefined, +): TrajectoryUsage | undefined { + if (usage === undefined) return total + return { + ...(total?.input === undefined && usage.input === undefined + ? {} + : { input: (total?.input ?? 0) + (usage.input ?? 0) }), + ...(total?.cacheRead === undefined && usage.cacheRead === undefined + ? {} + : { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }), + ...(total?.cacheWrite === undefined && usage.cacheWrite === undefined + ? {} + : { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }), + ...(total?.output === undefined && usage.output === undefined + ? {} + : { output: (total?.output ?? 0) + (usage.output ?? 0) }), + ...(total?.reasoning === undefined && usage.reasoning === undefined + ? {} + : { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }), + } +} + function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext { const context = contexts.at(-1) if (context === undefined) throw new Error('trajectory context projection must not be empty') @@ -43,6 +93,72 @@ export function TrajectoryView({ useSession }: ConvViewProps) { : contexts.find(context => context.id === selectedContextId) ?? currentContext const viewingCurrent = selectedContext.id === currentContext.id const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes + const requestNumbers = useMemo(() => { + const requestsBySeq = new Map() + for (const context of contexts) { + for (const node of context.nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + } + for (const node of nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + const orderedRequests = [...requestsBySeq.values()] + .sort((left, right) => left.seq - right.seq) + const requestBySeq = new Map() + let cumulativeUsage: TrajectoryUsage | undefined + for (const [index, node] of orderedRequests.entries()) { + const usage = requestUsage(node.usage) + cumulativeUsage = addUsage(cumulativeUsage, usage) + requestBySeq.set(node.seq, { + turn: node.turn, + step: node.step, + number: index + 1, + ...(node.provenance?.provider === undefined + ? {} + : { provider: node.provenance.provider }), + ...(node.provenance?.model === undefined + ? {} + : { model: node.provenance.model }), + ...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }), + ...(usage === undefined ? {} : { usage }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + + const selected: TrajectoryRequestNumber[] = [] + const selectedKeys = new Set() + for (const node of selectedNodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + const request = requestBySeq.get(node.seq) + if (request === undefined) continue + selected.push(request) + selectedKeys.add(`${node.turn}\u0000${node.step}`) + } + if (viewingCurrent && partial !== null && partial.step > 0) { + const key = `${partial.turn}\u0000${partial.step}` + if (!selectedKeys.has(key)) { + selected.push({ + turn: partial.turn, + step: partial.step, + number: orderedRequests.length + 1, + ...(currentContext.prompt?.config?.provider === undefined + ? {} + : { provider: currentContext.prompt.config.provider }), + ...(currentContext.prompt?.config?.model === undefined + ? {} + : { model: currentContext.prompt.config.model }), + ...(currentContext.prompt?.config === undefined + ? {} + : { requestConfig: currentContext.prompt.config }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + } + return selected + }, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent]) const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS const turns = useMemo( @@ -164,6 +280,7 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fcf92429ff..b4e4fca6a1 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -53,6 +53,15 @@ interface LaidCell { callId?: string } +interface LaidGroup { + title: string + laid: LaidCell[] +} + +interface TurnBucket { + groups: LaidGroup[] +} + /** * Fold a snapshot into turn → Message/Step groups with expanded cells. * @param input - nodes plus in-flight partial/runningCalls. @@ -70,7 +79,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const startedAt = finiteTime(call.time) if (startedAt !== null) callStartById.set(call.callId, startedAt) } - const turns = new Map }>() + const turns = new Map() let index = 0 let prevAbsTime: number | null = null let lastAssistantTurn: number | null = null @@ -78,20 +87,31 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const bucket = (turn: number) => { let entry = turns.get(turn) if (entry === undefined) { - entry = { message: [], steps: new Map() } + entry = { groups: [] } turns.set(turn, entry) } return entry } const pushMessage = (turn: number, laid: LaidCell) => { - bucket(turn).message.push(laid) + const groups = bucket(turn).groups + const last = groups.at(-1) + if (last?.title === 'Message') { + last.laid.push(laid) + return + } + groups.push({ title: 'Message', laid: [laid] }) } - const pushStep = (turn: number, step: number, laid: LaidCell) => { - const steps = bucket(turn).steps - const list = steps.get(step) ?? [] - list.push(laid) - steps.set(step, list) + const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => { + if (laid.length === 0) return + const groups = bucket(turn).groups + const title = `Step ${step}` + const existing = groups.find(group => group.title === title) + if (existing !== undefined) { + existing.laid.push(...laid) + return + } + groups.push({ title, laid: [...laid] }) } for (let i = 0; i < nodes.length; i++) { @@ -123,10 +143,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), codeDispatches, ) - for (const laid of laidList) { - if (node.step > 0) pushStep(node.turn, node.step, laid) - else pushMessage(node.turn, laid) - } + if (node.step > 0) pushStep(node.turn, node.step, laidList) + else for (const laid of laidList) pushMessage(node.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -153,7 +171,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!callEmittedInAssistant(nodes, node.callId)) { const toolName = node.call?.name - pushStep(0, 1, { + const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), callId: node.callId, @@ -172,11 +190,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: durationSeconds(node.time, node.callTime), startedAt: finiteTime(node.callTime), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { - pushStep(0, 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(0, 1, laidList) } prevAbsTime = finiteTime(node.time) ?? prevAbsTime } @@ -195,10 +214,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T callStartById, { streaming: true }, ) - for (const laid of laidList) { - if (partial.step > 0) pushStep(partial.turn, partial.step, laid) - else pushMessage(partial.turn, laid) - } + if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) + else for (const laid of laidList) pushMessage(partial.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index } @@ -206,7 +223,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const seenCalls = collectCallIds(turns) for (const call of runningCalls) { if (seenCalls.has(call.callId)) continue - pushStep(call.turn, call.step > 0 ? call.step : 1, { + const laidList: LaidCell[] = [{ absTime: null, toolName: call.name, callId: call.callId, @@ -219,34 +236,27 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: null, startedAt: finiteTime(call.time), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { - pushStep(call.turn, call.step > 0 ? call.step : 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(call.turn, call.step > 0 ? call.step : 1, laidList) } // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) - const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ - message: [], - steps: new Map(), - }) + const emptyTurn = (): TurnBucket => ({ groups: [] }) const first = turns.get(1) ?? emptyTurn() - first.message = [...prologue.message, ...first.message] - for (const [step, cells] of prologue.steps) { - const existing = first.steps.get(step) ?? [] - first.steps.set(step, [...cells, ...existing]) - } + first.groups = [...prologue.groups, ...first.groups] turns.set(1, first) } for (const entry of turns.values()) { - for (const laid of entry.message) attachToolSchema(laid, callSchemas) - for (const laid of entry.steps.values()) { - for (const cell of laid) attachToolSchema(cell, callSchemas) + for (const group of entry.groups) { + for (const laid of group.laid) attachToolSchema(laid, callSchemas) } } @@ -267,30 +277,20 @@ function attachToolSchema( function toTurnModel( turn: number, - entry: { message: LaidCell[]; steps: Map }, + entry: TurnBucket, ): TrajectoryTurnModel { - const groups: TrajectoryGroupModel[] = [] - if (entry.message.length > 0) { - const description = groupDescription(entry.message) - groups.push({ - title: 'Message', - ...(description !== undefined ? { description } : {}), - cells: entry.message.map(l => l.cell), - }) - } - for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { - const laid = entry.steps.get(step) ?? [] + const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => { const description = groupDescription(laid) - groups.push({ - title: `Step ${step}`, + return { + title, ...(description !== undefined ? { description } : {}), cells: laid.map(l => l.cell), - }) - } + } + }) return { turn, groups } } -/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */ function groupDescription(laid: readonly LaidCell[]): string | undefined { const parts: string[] = [] // Tool rows contribute start (absTime) and end (start + own duration) so a @@ -325,8 +325,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { function formatGroupDuration(seconds: number): string | undefined { if (!Number.isFinite(seconds)) return undefined const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -551,15 +551,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st } function collectCallIds( - turns: Map }>, + turns: Map, ): Set { const ids = new Set() for (const entry of turns.values()) { - for (const laid of entry.message) { - if (laid.callId !== undefined) ids.add(laid.callId) - } - for (const list of entry.steps.values()) { - for (const laid of list) { + for (const group of entry.groups) { + for (const laid of group.laid) { if (laid.callId !== undefined) ids.add(laid.callId) } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 140ae8fd53..9e15f61ba1 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes { export function formatElapsedSeconds(seconds: number | null): string { if (seconds === null || !Number.isFinite(seconds)) return '—' const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` } From b3ea0d987ec63f55158edba2bfa2a5b69e958109 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 09:53:32 +0800 Subject: [PATCH 007/117] feat(ui): complete trajectory request inspection --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 17 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 17 +- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 63 ++ .../src/client/sessions/fold-adapter.ts | 2 + .../runtime/src/client/sessions/session.ts | 259 +++++++- packages/client/tsdown.client.ts | 2 + .../ui-layout/src/client/AppFrame.module.css | 37 +- .../ui-primitives/src/JsonTree.module.css | 10 +- .../src/markdown/CodeBlock.module.css | 15 + .../ui-primitives/src/markdown/CodeBlock.tsx | 6 +- .../ui-primitives/src/markdown/plain-text.ts | 7 +- packages/client/ui-trajectory/package.json | 3 + .../src/client/ContextsPanel.module.css | 116 ---- .../src/client/ContextsPanel.tsx | 84 --- .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 4 + .../src/client/TrajectoryTable.module.css | 295 +++++++-- .../src/client/TrajectoryTable.tsx | 587 +++++++++++++----- .../src/client/TrajectoryToolbar.module.css | 32 +- .../src/client/TrajectoryToolbar.tsx | 15 - .../src/client/TrajectoryView.tsx | 312 ++++++---- .../src/client/context-branches.ts | 123 ++++ .../client/ui-trajectory/src/client/layout.ts | 218 ++++++- .../src/client/trajectory-record.ts | 22 +- .../ui-trajectory/src/client/views.module.css | 10 +- packages/compact/compact-basic/src/region.ts | 7 +- .../compact/compact-basic/src/summarizer.ts | 13 +- packages/compact/compact/src/types.ts | 8 +- pnpm-lock.yaml | 4 + scripts/client-bundle-css.spec.ts | 47 ++ 32 files changed, 1692 insertions(+), 655 deletions(-) delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx create mode 100644 packages/client/ui-trajectory/src/client/context-branches.ts create mode 100644 scripts/client-bundle-css.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 035cb14e90..9789c8b850 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 -2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c +2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7 +2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 30d1a2b0b4..feefac9098 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -12,12 +12,13 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** -- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. -- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. -- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. -- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. -- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. -- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. +- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. +- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. +- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. +- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. +- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. +- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -25,7 +26,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. -**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. +**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. @@ -33,4 +34,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. Focused component tests pin projection, folding, selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 2c8724160c..50da33604a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -12,12 +12,13 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 -- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 -- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 -- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 -- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 -- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 +- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 +- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 +- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -25,7 +26,7 @@ Status: implemented **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 -**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 +**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 @@ -33,4 +34,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b096a20697..57a1d09aab 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -30,7 +30,8 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, - ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, + CompactionRequestView, ConversationContext, ConversationContextOriginKind, + ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2421fe56f4..1deb456913 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -51,6 +51,7 @@ export interface UserMessageNode { time: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** Recorded boundaries used to derive assistant latency and throughput. */ @@ -67,6 +68,7 @@ export interface AssistantTiming { export interface AssistantRequestConfig { provider: string model: string + purpose?: string thinking?: string reasoningEffort?: string temperature?: number @@ -108,6 +110,7 @@ export interface SteeringMessageNode { turn: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** A context/system injection surfaced in the flow. */ @@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot { tools: readonly ToolSchema[] } +/** One system-prompt/tool-catalog state that became effective in the request timeline. */ +export interface ConversationPromptChange { + /** Sequence of the request/header event that introduced this state. */ + seq: number + /** Unix epoch ms from the request/header event. */ + time: number + /** How the model-visible system configuration differs from the prior recorded state. */ + kind: 'initial' | 'system' | 'tools' | 'system-and-tools' + /** Complete state effective from this event onward. */ + prompt: ConversationPromptSnapshot + /** State immediately before this change; absent for the initial header. */ + previous?: ConversationPromptSnapshot +} + /** One immutable model-context generation reconstructed from surface replacements. */ export interface ConversationContext { /** Zero-based generation within the session; stable across later appends. */ @@ -259,6 +276,46 @@ export interface ConversationContext { nodes: readonly ConversationNode[] } +/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */ +export interface CompactionRequestView { + startSeq: number + turn: number + startedAt: number + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + summarySeq?: number + replacementSeq?: number + summary?: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown +} + +/** One ordinary provider-request attempt reconstructed from a durable step boundary. */ +export interface ModelRequestView { + /** Sequence of the step/start event that opened this attempt. */ + startSeq: number + turn: number + step: number + /** Unix epoch ms from step/start. */ + startedAt: number + /** Assistant completion time, or the failed step/end time when no response completed. */ + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + /** Assistant/message sequence when this attempt completed successfully. */ + resultSeq?: number + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown + /** Retry ordinal scheduled after this failed attempt. */ + retry?: number + maxRetries?: number + retryDelayMs?: number +} + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' @@ -272,6 +329,12 @@ export interface ConversationSnapshot { nodes: readonly ConversationNode[] /** Append-only context generations split at every model-surface replacement. */ contexts?: readonly ConversationContext[] + /** Auxiliary compaction requests, including those without an assistant/message surface node. */ + compactionRequests?: readonly CompactionRequestView[] + /** Ordinary provider requests, including failed attempts that produced no assistant message. */ + requestAttempts?: readonly ModelRequestView[] + /** System-prompt/tool-catalog changes in request order. */ + promptChanges?: readonly ConversationPromptChange[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 6f88c678e7..9f49936e10 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -59,6 +59,7 @@ function materializeNode( return { kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'assistant/message': return { @@ -76,6 +77,7 @@ function materializeNode( return { kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 28acf5a859..cc79727f69 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -12,8 +12,9 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode, + ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot, + ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot { private callSchemas = new Map() private callSchemasRev = 0 private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null + private promptChangesRev = 0 + private promptChangesCache: { + rev: number + value: readonly ConversationPromptChange[] + } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot { } switch (event.type) { case 'request/header': { + this.promptChangesRev++ this.activeToolSchemas = new Map( (event.data.header.tools ?? []).map(schema => [schema.name, schema]), ) @@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot { this.activeToolSchemas = new Map() this.callSchemas = new Map() this.callSchemasRev++ + this.promptChangesRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot { if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } + if ( + this.promptChangesCache === null + || this.promptChangesCache.rev !== this.promptChangesRev + ) { + this.promptChangesCache = { + rev: this.promptChangesRev, + value: derivePromptChanges(this.events), + } + } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, nodes, contexts, + compactionRequests: deriveCompactionRequests(this.events), + requestAttempts: deriveModelRequests(this.events), + promptChanges: this.promptChangesCache.value, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, @@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha if (hasContent) return 'active' return promptAttempted ? 'engaging' : 'blank' } + +interface RetryEvent { + type: 'llm/retry' + seq: number + time: number + data: { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: { message: string } + } +} + +function modelRequestKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +/** Project every durable step into one provider request, retaining failed retry attempts. */ +function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] { + const requests: ModelRequestView[] = [] + const byStep = new Map() + let activeStep: string | undefined + let activeConfig: ConversationPromptSnapshot['config'] + + const update = (key: string, change: Partial): void => { + const index = byStep.get(key) + if (index === undefined) return + const request = requests[index] + if (request !== undefined) requests[index] = { ...request, ...change } + } + + for (const sourceEvent of events) { + if (sourceEvent.type === 'request/header') { + activeConfig = sourceEvent.data.header.config + if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig }) + continue + } + if (sourceEvent.type === 'step/start') { + const { turn, step } = sourceEvent.data + const key = modelRequestKey(turn, step) + byStep.set(key, requests.length) + requests.push({ + startSeq: sourceEvent.seq, + turn, + step, + startedAt: sourceEvent.time, + completedAt: null, + status: 'running', + ...(activeConfig === undefined ? {} : { requestConfig: activeConfig }), + }) + activeStep = key + continue + } + if (sourceEvent.type === 'assistant/message') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + update(key, { + completedAt: sourceEvent.time, + status: 'complete', + resultSeq: sourceEvent.seq, + provenance: { + provider: sourceEvent.data.provenance.provider, + model: sourceEvent.data.provenance.model, + }, + ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }), + }) + continue + } + if (sourceEvent.type === 'step/end') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + const index = byStep.get(key) + const request = index === undefined ? undefined : requests[index] + if (request !== undefined && request.status === 'running') { + requests[index] = { + ...request, + completedAt: sourceEvent.time, + status: 'error', + } + } + if (activeStep === key) activeStep = undefined + continue + } + if ((sourceEvent.type as string) === 'llm/retry') { + const event = sourceEvent as unknown as RetryEvent + update(modelRequestKey(event.data.turn, event.data.step), { + status: 'error', + error: event.data.failure.message, + retry: event.data.retry, + maxRetries: event.data.maxRetries, + retryDelayMs: event.data.delayMs, + }) + continue + } + if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue + const reason = sourceEvent.data.reason + update(modelRequestKey(sourceEvent.data.turn, reason.step), { + status: 'error', + error: 'failure' in reason ? reason.failure.message : reason.message, + }) + } + return requests +} + +interface CompactionStartEvent { + type: 'compact/start' + seq: number + time: number + data: { turn: number } +} + +interface CompactionSummaryEvent { + type: 'compact/summary' + seq: number + time: number + data: { + summary: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provider: string + model: string + maxTokens?: number + usage?: unknown + } +} + +interface CompactionEndEvent { + type: 'compact/end' + seq: number + time: number + data: { turn: number; error?: string } +} + +/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */ +function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] { + const requests: CompactionRequestView[] = [] + let active: CompactionRequestView | undefined + for (const sourceEvent of events) { + const type = sourceEvent.type as string + if (type === 'compact/start') { + const event = sourceEvent as unknown as CompactionStartEvent + active = { + startSeq: event.seq, + turn: event.data.turn, + startedAt: event.time, + completedAt: null, + status: 'running', + } + continue + } + if (type === 'compact/summary' && active !== undefined) { + const event = sourceEvent as unknown as CompactionSummaryEvent + active = { + ...active, + summarySeq: event.seq, + summary: event.data.summary, + ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + provenance: { + provider: event.data.provider, + model: event.data.model, + }, + requestConfig: { + provider: event.data.provider, + model: event.data.model, + purpose: 'compaction', + ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + }, + ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + } + continue + } + if ( + sourceEvent.type === 'user/message' + && active?.summarySeq !== undefined + && isCompactionSource(sourceEvent.data.source) + ) { + active = { ...active, replacementSeq: sourceEvent.seq } + continue + } + if (type !== 'compact/end' || active === undefined) continue + const event = sourceEvent as unknown as CompactionEndEvent + active = { + ...active, + completedAt: event.time, + status: event.data.error === undefined ? 'complete' : 'error', + ...(event.data.error === undefined ? {} : { error: event.data.error }), + } + requests.push(active) + active = undefined + } + if (active !== undefined) requests.push(active) + return requests +} + +/** Project request headers into model-visible system/tool changes only. */ +function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] { + const changes: ConversationPromptChange[] = [] + let previous: ConversationPromptSnapshot | undefined + for (const event of events) { + if (event.type !== 'request/header') continue + const prompt: ConversationPromptSnapshot = { + config: event.data.header.config, + system: event.data.header.system ?? '', + tools: event.data.header.tools ?? [], + } + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous === undefined || systemChanged || toolsChanged) { + changes.push({ + seq: event.seq, + time: event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged + ? 'system' + : 'tools', + prompt, + ...(previous === undefined ? {} : { previous }), + }) + } + previous = prompt + } + return changes +} + +function isCompactionSource(source: unknown): boolean { + return typeof source === 'object' + && source !== null + && 'kind' in source + && source.kind === 'plugin' + && 'plugin' in source + && source.plugin === 'compact' +} diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..bf560d4d53 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -6,6 +6,7 @@ * lightningcss inside the bundle: importing `x.module.css` yields the * hashed class map, and the css text auto-injects a