From 33d496951c8787e5b61a0e6d83fe242d8e7d7289 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 11:08:17 +0800 Subject: [PATCH 01/42] feat(workspace): session pinning and the sidebar archived filter Pin sessions to the top of the sidebar and reach archived sessions through the view-options filter instead of a settings page; archiving gains an undo/filter toast, and row motion stays reserved for pin jumps and archive fades. --- ...-session-pin-and-sidebar-archive.i18n.yaml | 6 + ...6-09-18-session-pin-and-sidebar-archive.md | 32 ++ ...9-18-session-pin-and-sidebar-archive.zh.md | 32 ++ THIRD_PARTY_NOTICES.md | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 - docs/module-graph.zh.md | 2 - .../workspace-controller/src/client/index.ts | 5 +- .../workspace-controller/src/client/model.ts | 67 +++ .../src/client/service.ts | 20 + .../api/workspace-controller/src/commands.ts | 38 +- packages/api/workspace-controller/src/feed.ts | 26 +- .../api/workspace-controller/src/index.ts | 23 + .../api/workspace-controller/src/types.ts | 24 + .../tests/model.client.spec.ts | 107 +++- .../tests/remote/workspace.client.ts | 12 +- .../tests/transport.client.spec.ts | 20 +- .../tests/workspace-controller.host.spec.ts | 49 +- packages/bundle/web-app/cordis.patch.yml | 3 - packages/bundle/web-app/package.json | 1 - .../locale/tests/language-row.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../tests/transcript-view-row.client.spec.tsx | 2 +- .../tests/enter-behavior-row.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 2 +- .../ui-layout/tests/app-frame.client.spec.tsx | 2 +- .../src/client/FeedbackDialog.module.css | 12 - .../src/client/FeedbackDialog.tsx | 4 +- .../client/ui-primitives/src/Toast.module.css | 32 +- packages/client/ui-primitives/src/Toast.tsx | 34 +- packages/client/ui-primitives/src/Tooltip.tsx | 23 +- .../client/ui-primitives/src/icons/index.tsx | 172 ++++++ .../ui-primitives/tests/icons.client.spec.tsx | 4 +- .../ui-primitives/tests/toast.client.spec.tsx | 30 ++ .../tests/tooltip.client.spec.tsx | 45 +- .../tests/shell.client.spec.ts | 6 +- .../README.i18n.yaml | 6 - .../ui-settings-unarchive-sessions/README.md | 112 ---- .../README.zh.md | 112 ---- .../package.json | 71 --- .../client/ArchivedSessionsSection.module.css | 83 --- .../src/client/ArchivedSessionsSection.tsx | 134 ----- .../src/client/index.ts | 49 -- .../src/client/locales.ts | 42 -- .../src/css-modules.d.ts | 6 - .../src/index.ts | 4 - .../tests/browser-plugin.client.spec.tsx | 85 --- .../tests/components.client.spec.tsx | 158 ------ .../tsconfig.json | 39 -- .../tsdown.config.ts | 3 - .../ui-theme/src/styles/design-platform.css | 6 +- .../tests/appearance-row.client.spec.tsx | 2 +- .../tests/font-size-row.client.spec.tsx | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 5 +- .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- packages/client/ui-workspace/package.json | 10 +- .../ui-workspace/src/client/contract/slots.ts | 6 + .../client/ui-workspace/src/client/index.ts | 3 + .../client/ui-workspace/src/client/locales.ts | 38 +- .../src/client/rows/Rows.module.css | 66 ++- .../ui-workspace/src/client/rows/Rows.tsx | 148 ++++- .../client/rows/WorkspaceBrowser.module.css | 101 +++- .../src/client/rows/WorkspaceBrowser.tsx | 505 ++++++++++++++++-- .../client/ui-workspace/src/client/stores.ts | 15 + .../client/ui-workspace/src/client/tree.ts | 140 ++++- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../ui-workspace/tests/rows.client.spec.tsx | 238 +++++++-- .../ui-workspace/tests/tree.client.spec.ts | 167 +++++- .../tests/workspace-browser.client.spec.tsx | 491 +++++++++++++++-- .../tests/workspace-picker.client.spec.tsx | 2 +- .../tests/workspaces-service.client.spec.ts | 3 + packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 1 - .../src/assembly/remote-default-responses.ts | 2 +- .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/workspaces.ts | 37 ++ packages/workspace/workspace/src/index.ts | 113 +++- packages/workspace/workspace/src/spec.ts | 21 +- .../workspace/tests/workspace.spec.ts | 146 ++++- pnpm-lock.yaml | 65 +-- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 1 - tsconfig.client.json | 1 - 93 files changed, 2826 insertions(+), 1290 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.i18n.yaml delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.zh.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/package.json delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/index.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/locales.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/index.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/tsconfig.json delete mode 100644 packages/client/ui-settings-unarchive-sessions/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml new file mode 100644 index 0000000000..55ca1b1eed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.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-09-18-session-pin-and-sidebar-archive.md +2026-09-18-session-pin-and-sidebar-archive.md: 57bace16ce7171935fe71de9751da5c21bf1e3cf +2026-09-18-session-pin-and-sidebar-archive.zh.md: a752e2d962ef844ac062c48c9d30ef4134e60e95 diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md new file mode 100644 index 0000000000..57bace16ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md @@ -0,0 +1,32 @@ +# Agent Note: Session pinning and the sidebar archived filter + +Status: implemented + +English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md) + +## Problem + +The sidebar session list had no way to keep important sessions at the top, and archived sessions could only be reached through a settings page (`ui-settings-unarchive-sessions`) far from the list they came from. Users archived a session and then lost sight of it. + +## Decision + +The `dsh-workspace` registry stores a registry-global pin set (`pinnedSessions`, most recently pinned first, each entry carrying its epoch-millisecond `pinnedAt`) beside the existing archive set, with durable `pinSession`/`unpinSession` operations. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`. + +The sidebar derives both states in `ui-workspace`: + +- Pinned rows lead their section as a partition that never disturbs relative order on either side, so unpinning restores the row's kept slot. Under recency order a pinned row ranks by the later of its pin instant and update recency; under manual order pinned-to-pinned drags hold. +- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package. +- Archiving raises a toast with undo and filter-archived actions, and a one-time hint anchors under the view-options trigger. +- Row motion uses `@formkit/auto-animate` reserved for pin jumps and archive/unarchive fades; wholesale layout passes (expand/collapse, drag commit, grouping/ordering/filter switches) mute row animations for one frame through `muteNextRowAnimations`. + +## Alternatives considered + +**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright. + +**Fold pin position into the persisted manual order.** The order would absorb pin state, so unpinning could not restore the row's previous slot; the partition keeps pin state and row order independent. + +**Animate every list pass with auto-animate defaults.** Expanding a group, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly and animation stays reserved for single-row pin and archive changes. + +## Consequences + +The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. `ui-workspace` gains a client dependency on `@formkit/auto-animate`. Unit suites pin the partition, filter, toast, and mute behavior; the workspace Host suite pins pin/archive exclusivity and durability. diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md new file mode 100644 index 0000000000..a752e2d962 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Session pinning and the sidebar archived filter + +Status: implemented + +[English](2026-09-18-session-pin-and-sidebar-archive.md) | 中文 + +## Problem + +侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。 + +## Decision + +`dsh-workspace` registry 在既有归档集合旁存储一个 registry 全局置顶集合(`pinnedSessions`,最近置顶在前,每项携带毫秒级 `pinnedAt`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。 + +侧边栏在 `ui-workspace` 中派生这两种状态: + +- 置顶行以分区方式排在所在区块最前,分区不打乱两侧的相对顺序,因此取消置顶会恢复行原有的位置。最近更新排序下,置顶行按置顶时间与更新时间中较晚者排名;手动排序下,置顶行之间的拖拽顺序保持。 +- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。 +- 归档动作弹出携带撤销与筛选已归档操作的 toast,并在视图选项触发按钮下方锚定一次性提示。 +- 行动画使用 `@formkit/auto-animate`,仅保留给置顶跳动与归档/取消归档淡入淡出;整体布局变化(展开/收起、拖拽提交、分组/排序/筛选切换)通过 `muteNextRowAnimations` 将行动画静默一帧。 + +## Alternatives considered + +**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。 + +**把置顶位置并入持久化的手动排序。** 排序会吸收置顶状态,取消置顶后无法恢复行先前的位置;分区让置顶状态与行顺序保持独立。 + +**用 auto-animate 默认行为为每次列表变化播放动画。** 展开分组、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,动画只保留给单行的置顶与归档变化。 + +## Consequences + +侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。`ui-workspace` 新增 client 依赖 `@formkit/auto-animate`。单元测试钉住分区、筛选、toast 与静默行为;workspace Host 测试钉住置顶/归档互斥与持久化。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ec698dca33..2eae5be989 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@formkit/auto-animate`](https://github.com/formkit/auto-animate) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@lexical/history`](https://github.com/facebook/lexical) | MIT | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ba1ff6a95c..7733f352e1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: c439dcf876922bc01d0ac4872f2101246ee53ed1 -config-catalog.zh.md: e3d76addd80421759db5c6ec896d9837a05532b4 +config-catalog.md: d802c5f084072dbe4f6654aecaf0640be96fb4bf +config-catalog.zh.md: b7c3b4be06b9ddf3fcd4117483fb0b1472493441 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c439dcf876..d802c5f084 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3818,7 +3818,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e3d76addd8..b7c3b4be06 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3820,7 +3820,6 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 78f2897d67..4a2feec24a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 2e2899c3a74e1d2c5fe50ab137408e7ed3beca82 -module-graph.zh.md: 49291ba55842462e5d532cce1de4f3803610d908 +module-graph.md: 90c9601b329daa91f21713a20cf499c9937e6d34 +module-graph.zh.md: 19e6bd1b942ae3319f137b7528fea4b69383d0aa diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e2899c3a7..90c9601b32 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -180,7 +180,6 @@ flowchart TD pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] - pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1362,7 +1361,6 @@ flowchart TD | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | -| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 49291ba558..19e6bd1b94 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -182,7 +182,6 @@ flowchart TD pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] - pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1364,7 +1363,6 @@ flowchart TD | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | -| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index fffa05c18f..94fe394182 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -17,7 +17,7 @@ export type { } from './model.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' -export type { WorkspaceId, WorkspaceView } from '../types.ts' +export type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '../types.ts' type WorkspaceBaselineFrame = Extract @@ -107,6 +107,9 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr case 'archived': accept.replaceArchived(frame.archivedSessionIds) return + case 'pinned': + accept.replacePinned(frame.pinnedSessions) + return /* v8 ignore next -- the generated Remote codec validates this closed union */ default: return assertNever(frame) diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 1921e9247f..bab0e2f85c 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -13,7 +13,10 @@ import type { WorkspaceDeleteValue, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, WorkspaceId, WorkspaceView, @@ -30,6 +33,8 @@ export interface WorkspaceSnapshot { readonly items: readonly WorkspaceView[] /** Complete registry-global archive set in Host order. */ readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] + /** Complete registry-global pin set, most recently pinned first. */ + readonly pinnedSessions: WorkspacePinValue['pinnedSessions'] readonly state: 'idle' | 'loading' | 'error' readonly phase: WorkspaceListPhase readonly error: RemoteFailure | null @@ -47,6 +52,8 @@ export interface WorkspaceFollowSink { replaceOrder(workspaceIds: readonly WorkspaceId[]): void /** Replace the complete archived Session set. */ replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void + /** Replace the complete pinned Session set. */ + replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void } /** @@ -55,6 +62,7 @@ export interface WorkspaceFollowSink { export class ClientWorkspaceModel implements WorkspaceFollowSink { private items: readonly WorkspaceView[] = [] private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = [] + private pinnedSessions: WorkspacePinValue['pinnedSessions'] = [] private state: WorkspaceSnapshot['state'] = 'loading' private phase: WorkspaceListPhase = 'pending' private error: RemoteFailure | null = null @@ -66,6 +74,8 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { private committedOrder: WorkspaceId[] = [] /** Latest archive-set request; a later request or a pushed set supersedes it. */ private archiveRequestSeq = 0 + /** Latest pin-set request; a later request or a pushed set supersedes it. */ + private pinRequestSeq = 0 /** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */ private readonly removedIds = new Set() private readonly listeners = new Set<() => void>() @@ -173,6 +183,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { const result = await this.remote.archiveSession({ sessionId }) if (result.ok && requestSeq === this.archiveRequestSeq) { this.installArchived(result.value.archivedSessionIds) + // The Host drops an archived session's pin in the same durable write; + // mirror that locally so no frame shows the row both archived and pinned. + this.installPinned(this.pinnedSessions.filter(entry => entry.sessionId !== sessionId)) } return result } @@ -194,6 +207,40 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { return result } + /** + * Pin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to pin. + * @returns generated Remote result. + */ + async pinSession( + sessionId: WorkspacePinSessionRequest['sessionId'], + ): Promise> { + const requestSeq = ++this.pinRequestSeq + const result = await this.remote.pinSession({ sessionId }) + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessions) + } + return result + } + + /** + * Unpin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to unpin. + * @returns generated Remote result. + */ + async unpinSession( + sessionId: WorkspaceUnpinSessionRequest['sessionId'], + ): Promise> { + const requestSeq = ++this.pinRequestSeq + const result = await this.remote.unpinSession({ sessionId }) + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessions) + } + return result + } + /** * Replace the projection from one complete stream-generation baseline. * @param baseline - complete Workspace and archive projection. @@ -201,8 +248,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { replaceBaseline(baseline: WorkspaceBaseline): void { this.orderFrameGeneration++ this.archiveRequestSeq++ + this.pinRequestSeq++ this.installViews(baseline.items) this.installArchived(baseline.archivedSessionIds) + this.installPinned(baseline.pinnedSessions) this.state = 'idle' this.phase = 'ready' this.error = null @@ -234,6 +283,15 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { this.installArchived(archivedSessionIds) } + /** + * Replace the pinned Session set from the current follow generation. + * @param pinnedSessions - complete Host-confirmed pin set, most recently pinned first. + */ + replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void { + this.pinRequestSeq++ + this.installPinned(pinnedSessions) + } + /** Keep the last complete projection visible while a lost carrier reconnects. */ handleCarrierFailure(): void { this.state = 'loading' @@ -275,6 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { return { items: this.items, archivedSessionIds: this.archivedSessionIds, + pinnedSessions: this.pinnedSessions, state: this.state, phase: this.phase, error: this.error, @@ -288,6 +347,14 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { this.invalidate() } + private installPinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void { + if (pinnedSessions.length === this.pinnedSessions.length + && pinnedSessions.every((entry, index) => entry.sessionId === this.pinnedSessions[index]?.sessionId + && entry.pinnedAt === this.pinnedSessions[index]?.pinnedAt)) return + this.pinnedSessions = [...pinnedSessions] + this.invalidate() + } + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { if (committed) this.committedOrder = [...workspaceIds] const rank = new Map(workspaceIds.map((id, index) => [id, index])) diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 9f44214801..58608215a7 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -67,6 +67,16 @@ export interface IWorkspaces { * @param sessionId - Session to unarchive. */ unarchiveSession(sessionId: SessionId): Promise + /** + * Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces. + * @param sessionId - Session to pin. + */ + pinSession(sessionId: SessionId): Promise + /** + * Unpin a Session back to its natural list position. + * @param sessionId - Session to unpin. + */ + unpinSession(sessionId: SessionId): Promise /** * Move a Session within one Workspace account. * @param workspaceId - owning Workspace. @@ -126,6 +136,16 @@ export class WorkspaceController extends Service implements IWorkspaces { if (!result.ok) throw commandError('session unarchive', result.error) } + async pinSession(sessionId: SessionId): Promise { + const result = await this.model.pinSession(sessionId) + if (!result.ok) throw commandError('session pin', result.error) + } + + async unpinSession(sessionId: SessionId): Promise { + const result = await this.model.unpinSession(sessionId) + if (!result.ok) throw commandError('session unpin', result.error) + } + async insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts index 642c7e7da1..2d922fac08 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -3,13 +3,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { Workspace } from '@deepseek-ai/dsh-workspace' import { + WorkspaceArchivedSessionPinError, WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import { workspaceView } from './feed.ts' +import { pinnedSessionViews, workspaceView } from './feed.ts' import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, @@ -20,8 +21,11 @@ import type { WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, } from './types.ts' @@ -173,6 +177,38 @@ export class WorkspaceCommands { return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] } } + /** + * Add one known unarchived Session to the registry-global pin set. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async pinSession(request: WorkspacePinSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.pinSession(request.sessionId) + } catch (error) { + if (error instanceof WorkspaceUnknownSessionError) { + throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error }) + } + if (error instanceof WorkspaceArchivedSessionPinError) { + throw new RemoteError('gateway/bad-request', error.message, {}, { cause: error }) + } + throw error + } + return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) } + } + + /** + * Drop one Session from the registry-global pin set. An id that is not + * pinned is not an error: the call is idempotent, so a lost race with + * another surface resolves as a no-op. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + await this.ctx.workspaceRegistry.unpinSession(request.sessionId) + return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) } + } + private requireWorkspace(workspaceId: WorkspaceId): Workspace { const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId)) if (workspace === undefined) throw workspaceNotFound(workspaceId) diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index 57d38f18e5..ef931ac7d3 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' -import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import type { PinnedSession, Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, @@ -12,6 +12,7 @@ import { import type { WorkspaceBaseline, WorkspaceFollowFrame, + WorkspacePinnedSession, WorkspaceView, } from './types.ts' @@ -43,12 +44,26 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } +/** + * Project registry pin entries into their Remote values. + * @param entries - authoritative registry pin entries. + * @returns detached pin projections for Remote consumers. + */ +export function pinnedSessionViews(entries: readonly PinnedSession[]): WorkspacePinnedSession[] { + return entries.map(entry => ({ sessionId: entry.id, pinnedAt: entry.pinnedAt })) +} + +/** One comparable key per pin entry; pinnedAt participates so a re-pin at a new instant publishes. */ +const pinKeys = (entries: readonly PinnedSession[]): string[] => + entries.map(entry => `${entry.id}@${entry.pinnedAt}`) + /** Owns Workspace domain observation and all active follow generations. */ export class WorkspaceFeed { private readonly followers = new Set() private knownIds: Set private order: readonly string[] private archived: readonly string[] + private pinned: readonly string[] /** @param ctx - Host context containing the authoritative Workspace registry. */ constructor(private readonly ctx: Context) { @@ -56,6 +71,7 @@ export class WorkspaceFeed { this.knownIds = new Set(baseline.map(workspace => String(workspace.id))) this.order = baseline.map(workspace => String(workspace.id)) this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String) + this.pinned = pinKeys(ctx.workspaceRegistry.pinnedSessions) ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) }) ctx.effect(() => () => { for (const follower of this.followers) follower.close() @@ -65,12 +81,13 @@ export class WorkspaceFeed { /** * Read the complete current projection synchronously. - * @returns all active Workspaces and archived Session identities. + * @returns all active Workspaces plus archived and pinned Session identities. */ baseline(): WorkspaceBaseline { return { items: this.ctx.workspaceRegistry.list().map(workspaceView), archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds], + pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions), } } @@ -115,6 +132,11 @@ export class WorkspaceFeed { this.archived = nextArchived this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] }) } + const nextPinned = pinKeys(state.pinnedSessions) + if (!sameStrings(this.pinned, nextPinned)) { + this.pinned = nextPinned + this.publish({ type: 'pinned', pinnedSessions: pinnedSessionViews(state.pinnedSessions) }) + } return } if (change.table !== 'workspaces') return diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 2db218df8c..ff433d8414 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -16,8 +16,11 @@ import type { WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, } from './types.ts' @@ -120,6 +123,26 @@ export class WorkspaceController extends TypertRemoteService { return this.commands.unarchiveSession(request) } + /** + * Surface one known unarchived Session ahead of unpinned Sessions. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote('pinSession') + pinSession(request: WorkspacePinSessionRequest): Promise { + return this.commands.pinSession(request) + } + + /** + * Return one pinned Session to its natural list position. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote('unpinSession') + unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + return this.commands.unpinSession(request) + } + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index 15678e9a8f..41dfc29a87 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -114,10 +114,33 @@ export interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] } +/** Session requested for pinning ahead of unpinned Sessions on grouping surfaces. */ +export interface WorkspacePinSessionRequest { + readonly sessionId: SessionId +} + +/** Session requested for unpinning back to its natural list position. */ +export interface WorkspaceUnpinSessionRequest { + readonly sessionId: SessionId +} + +/** One pinned Session entry: the Session and the epoch-millisecond instant it was pinned at. */ +export interface WorkspacePinnedSession { + readonly sessionId: SessionId + readonly pinnedAt: number +} + +/** Complete pinned Session set after a mutation, most recently pinned first. */ +export interface WorkspacePinValue { + readonly pinnedSessions: readonly WorkspacePinnedSession[] +} + /** Complete reconnect baseline for Workspace browser state. */ export interface WorkspaceBaseline { readonly items: readonly WorkspaceView[] readonly archivedSessionIds: readonly SessionId[] + /** Registry-global pin set, most recently pinned first. */ + readonly pinnedSessions: readonly WorkspacePinnedSession[] } /** One ordered Workspace change after a generation's baseline. */ @@ -126,6 +149,7 @@ export type WorkspaceFollowIncrement = | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + | { readonly type: 'pinned'; readonly pinnedSessions: readonly WorkspacePinnedSession[] } /** Workspace state stream; every generation starts with exactly one baseline. */ export type WorkspaceFollowFrame = diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 21d42f81f7..74b1f45285 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -13,10 +13,14 @@ import type { WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, WorkspaceId, + WorkspacePinnedSession, WorkspaceView, } from '../src/types.ts' import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -24,6 +28,9 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId +const pin = (id: string, pinnedAt = 1): WorkspacePinnedSession => ({ sessionId: sid(id), pinnedAt }) +const pinnedIds = (entries: readonly WorkspacePinnedSession[]): string[] => + entries.map(entry => String(entry.sessionId)) function workspace( id: string, @@ -89,6 +96,14 @@ class FakeWorkspaceRemote implements WorkspaceRemote { request: WorkspaceUnarchiveSessionRequest, ) => Promise> = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + onPinSession: ( + request: WorkspacePinSessionRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] })) + onUnpinSession: ( + _request: WorkspaceUnpinSessionRequest, + ) => Promise> = () => + Promise.resolve(remoteOk({ pinnedSessions: [] })) create(request: WorkspaceCreateRequest): Promise> { this.record('create', request) @@ -125,6 +140,16 @@ class FakeWorkspaceRemote implements WorkspaceRemote { return this.onUnarchiveSession(request) } + pinSession(request: WorkspacePinSessionRequest): Promise> { + this.record('pinSession', request) + return this.onPinSession(request) + } + + unpinSession(request: WorkspaceUnpinSessionRequest): Promise> { + this.record('unpinSession', request) + return this.onUnpinSession(request) + } + async *follow(_signal?: AbortSignal): AsyncGenerator {} private record(method: string, request: unknown): void { @@ -140,8 +165,9 @@ function baseline( model: ClientWorkspaceModel, items: readonly WorkspaceView[] = [], archivedSessionIds: readonly SessionId[] = [], + pinnedSessions: readonly WorkspacePinnedSession[] = [], ): void { - model.replaceBaseline({ items, archivedSessionIds }) + model.replaceBaseline({ items, archivedSessionIds, pinnedSessions }) } describe('ClientWorkspaceModel', () => { @@ -393,6 +419,85 @@ describe('ClientWorkspaceModel', () => { expect(model.getSnapshot().archivedSessionIds).toEqual(['second']) }) + it('applies pin mutation echoes and leaves failed results unchanged', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [], [], [pin('kept')]) + + remote.onPinSession = () => Promise.resolve(workspaceError( + new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }), + )) + await expect(model.pinSession(sid('missing'))).resolves.toMatchObject({ ok: false }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept']) + remote.onPinSession = request => Promise.resolve(remoteOk({ + pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 2 }, pin('kept')], + })) + await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept']) + expect(remote.calls).toContainEqual({ method: 'pinSession', request: { sessionId: 'fresh' } }) + + remote.onUnpinSession = () => Promise.resolve(workspaceError( + new RemoteError('gateway/internal', 'wire down', {}), + )) + await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: false }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept']) + remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessions: [pin('kept')] })) + await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept']) + expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } }) + + // A pushed set identical to the local one publishes no new snapshot; the + // same member re-pinned at a new instant does. + const before = model.getSnapshot() + model.replacePinned([pin('kept')]) + expect(model.getSnapshot()).toBe(before) + model.replacePinned([pin('kept', 9)]) + expect(model.getSnapshot()).not.toBe(before) + expect(model.getSnapshot().pinnedSessions).toEqual([pin('kept', 9)]) + }) + + it('keeps the latest pin reply when overlapping requests settle out of order', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + const firstGate = deferred>() + const secondGate = deferred>() + let request = 0 + remote.onPinSession = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = model.pinSession(sid('first')) + const second = model.pinSession(sid('second')) + secondGate.resolve(remoteOk({ pinnedSessions: [pin('second', 2), pin('first')] })) + await expect(second).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first']) + firstGate.resolve(remoteOk({ pinnedSessions: [pin('first')] })) + await expect(first).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first']) + }) + + it('keeps a pushed pin set when an unpin reply lands later', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [], [], [pin('first')]) + const gate = deferred>() + remote.onUnpinSession = () => gate.promise + + const pending = model.unpinSession(sid('first')) + model.replacePinned([pin('first'), pin('second', 2)]) + gate.resolve(remoteOk({ pinnedSessions: [] })) + await expect(pending).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['first', 'second']) + }) + + it('mirrors the Host pin drop locally when an archive echo lands', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [], [], [pin('pinned'), pin('kept')]) + + await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned']) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept']) + }) + it('keeps the newest row and places Workspaces missing from partial orders last', async () => { const model = modelFor() baseline(model, [ diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index c1324a4ce8..e65fdec1df 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -19,8 +19,11 @@ import type { WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, WorkspaceView, } from '../../src/types.ts' @@ -59,12 +62,15 @@ export function workspace(id: string, overrides: Partial = {}): W } /** - * A baseline frame holding the named Workspaces and no archived Sessions. + * A baseline frame holding the named Workspaces and no archived or pinned Sessions. * @param ids - Workspace ids in registry order. * @returns the frame. */ export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame { - return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } } + return { + type: 'baseline', + value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessions: [] }, + } } /** @@ -98,5 +104,7 @@ export const workspaceWorld: RemoteTable = { }), 'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [request.sessionId] }), 'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [] }), + 'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult => ok({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }), + 'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult => ok({ pinnedSessions: [] }), }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 67cc99c985..60c833b47f 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -58,6 +58,7 @@ function accepts(overrides: Partial = {}): WorkspaceFollowS removeView: ignore, replaceOrder: ignore, replaceArchived: ignore, + replacePinned: ignore, ...overrides, } } @@ -153,6 +154,7 @@ describe('Workspace state stream', () => { { type: 'remove', workspaceId: view.workspaceId }, { type: 'order', workspaceIds: [view.workspaceId] }, { type: 'archived', archivedSessionIds: [sid('session-one')] }, + { type: 'pinned', pinnedSessions: [{ sessionId: sid('session-two'), pinnedAt: 1 }] }, ] mock.stream(FOLLOW, openStream([opening, ...increments])) const replaceBaseline = vi.fn() @@ -160,20 +162,22 @@ describe('Workspace state stream', () => { const removeView = vi.fn() const replaceOrder = vi.fn() const replaceArchived = vi.fn() + const replacePinned = vi.fn() const stream = createWorkspaceStateStream(remote, { - accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }), + accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived, replacePinned }), failed: vi.fn(), }) stream.start() stream.start() - await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(replacePinned).toHaveBeenCalledOnce() }) expect(replaceBaseline).toHaveBeenCalledWith(opening.value) expect(upsertView).toHaveBeenCalledWith(view) expect(removeView).toHaveBeenCalledWith(view.workspaceId) expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId]) expect(replaceArchived).toHaveBeenCalledWith(['session-one']) + expect(replacePinned).toHaveBeenCalledWith([{ sessionId: 'session-two', pinnedAt: 1 }]) await stream.dispose() expect(streamStates(mock)).toEqual(['cancelled']) }) @@ -309,7 +313,7 @@ describe('WorkspaceController', () => { it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => { const { remote, client } = await gatewayClient(mock, start) const model = new ClientWorkspaceModel(remote.workspace) - model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] }) + model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessions: [] }) const controller = new WorkspaceController(client.ctx, model) expect(controller.list).toBe(model) @@ -322,6 +326,8 @@ describe('WorkspaceController', () => { }) await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined() await expect(controller.unarchiveSession(sid('session'))).resolves.toBeUndefined() + await expect(controller.pinSession(sid('session'))).resolves.toBeUndefined() + await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() // Each command crosses the wire as one positional request object. expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) @@ -330,6 +336,8 @@ describe('WorkspaceController', () => { expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }]) expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }]) expect(mock.log.requests('workspace/unarchiveSession')).toEqual([{ sessionId: 'session' }]) + expect(mock.log.requests('workspace/pinSession')).toEqual([{ sessionId: 'session' }]) + expect(mock.log.requests('workspace/unpinSession')).toEqual([{ sessionId: 'session' }]) expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }]) }) @@ -356,6 +364,12 @@ describe('WorkspaceController', () => { mock.remote.workspace.unarchiveSession.mockResolvedValueOnce(err(missingSession)) await expect(controller.unarchiveSession(sid('session'))) .rejects.toThrow('workspace session unarchive failed: session/not-found: missing session') + mock.remote.workspace.pinSession.mockResolvedValueOnce(err(missingSession)) + await expect(controller.pinSession(sid('session'))) + .rejects.toThrow('workspace session pin failed: session/not-found: missing session') + mock.remote.workspace.unpinSession.mockResolvedValueOnce(err(missingSession)) + await expect(controller.unpinSession(sid('session'))) + .rejects.toThrow('workspace session unpin failed: session/not-found: missing session') mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError( 'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') }, ))) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 031c5d0fc3..9947228f8b 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -156,6 +156,16 @@ describe('WorkspaceController commands', () => { vi.spyOn(ctx.workspaceRegistry, 'unarchiveSession').mockRejectedValueOnce(unarchiveFailure) await expect(controller.unarchiveSession({ sessionId: SessionId('session') })) .rejects.toBe(unarchiveFailure) + + const pinFailure = new Error('pin storage failed') + vi.spyOn(ctx.workspaceRegistry, 'pinSession').mockRejectedValueOnce(pinFailure) + await expect(controller.pinSession({ sessionId: SessionId('session') })) + .rejects.toBe(pinFailure) + + const unpinFailure = new Error('unpin storage failed') + vi.spyOn(ctx.workspaceRegistry, 'unpinSession').mockRejectedValueOnce(unpinFailure) + await expect(controller.unpinSession({ sessionId: SessionId('session') })) + .rejects.toBe(unpinFailure) }) it('resolves queued Workspace identities when their operation starts', async () => { @@ -232,6 +242,33 @@ describe('WorkspaceController commands', () => { await expect(controller.unarchiveSession({ sessionId: session.id })) .resolves.toEqual({ archivedSessionIds: [] }) }) + + it('pins only known unarchived Sessions and unpins idempotently', async () => { + const { controller, ctx, root } = await harness() + const created = await controller.create({ path: stageDir(root, 'pins') }) + const session = ctx.sessions.create(SessionId('pin-me'), { + meta: { cwd: created.workspace.path }, + }) + + await expect(controller.pinSession({ sessionId: session.id })) + .resolves.toEqual({ pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }] }) + await expect(controller.pinSession({ sessionId: SessionId('unknown') })) + .rejects.toMatchObject({ code: 'session/not-found' }) + + // Pinning an archived Session is a caller error, not a missing session. + const archived = ctx.sessions.create(SessionId('stored'), { + meta: { cwd: created.workspace.path }, + }) + await controller.archiveSession({ sessionId: archived.id }) + await expect(controller.pinSession({ sessionId: archived.id })) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + + await expect(controller.unpinSession({ sessionId: session.id })) + .resolves.toEqual({ pinnedSessions: [] }) + // Unpin is idempotent: an id that is not pinned is not an error. + await expect(controller.unpinSession({ sessionId: session.id })) + .resolves.toEqual({ pinnedSessions: [] }) + }) }) describe('WorkspaceController follow', () => { @@ -253,6 +290,7 @@ describe('WorkspaceController follow', () => { initialized: true, workspaceIds: ['missing'], archivedSessionIds: [], + pinnedSessions: [], }, }) }).toThrow('references missing Workspace "missing"') @@ -264,7 +302,7 @@ describe('WorkspaceController follow', () => { const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() await expect(nextFrame(iterator)).resolves.toEqual({ type: 'baseline', - value: { items: [], archivedSessionIds: [] }, + value: { items: [], archivedSessionIds: [], pinnedSessions: [] }, }) const first = await controller.create({ path: stageDir(root, 'first') }) @@ -307,6 +345,15 @@ describe('WorkspaceController follow', () => { await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [], }) + await controller.pinSession({ sessionId: session.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'pinned', pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }], + }) + // Unpin rides the same complete-set increment: no new frame type. + await controller.unpinSession({ sessionId: session.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'pinned', pinnedSessions: [], + }) await controller.delete({ workspaceId: second.workspace.workspaceId }) await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [first.workspace.workspaceId], diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fd0effbc5..1f4d203152 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -263,9 +263,6 @@ - id: ui-settings-plugin-inventory name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory' - - id: ui-settings-unarchive-sessions - name: '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions' - - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6a322911e9..cc00c61d15 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -83,7 +83,6 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index cb4a978ab0..31b763ced4 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -25,7 +25,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9db032dc27..ac9e3fe127 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -212,7 +212,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f3ac40e704..c2ed0d1baa 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -22,7 +22,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 8ddb46f500..5feacea703 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -28,7 +28,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 4123e7eee3..e9dbc5a61f 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -178,7 +178,7 @@ function bench(over?: BenchOptions) { subagentsByParent: {}, jobsBySession: {}, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 96de6a18d0..1656cc244b 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -62,7 +62,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 674cf3dc0a..5d04eb8944 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -156,7 +156,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b00138fa9c..3c4159c806 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -111,7 +111,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ - items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) function sessionSnapshotOf(overrides: Partial = {}): SessionSnapshot { diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index b7bf4e10e1..b7260eba83 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -81,7 +81,7 @@ function mountFrame(windowWidth = frameWidth) { jobsBySession: {}, }) const workspaceState: WorkspaceSnapshot = { - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } const useStore = bindSnapshotSelector(instance) diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index bd0fe184c6..af2cacebe7 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -90,18 +90,6 @@ font-weight: 500; } -/* The design's ringed green check, not the warning tint the Toast icon seat defaults to. */ -.toastIcon { - display: grid; - place-items: center; - width: 18px; - height: 18px; - border: 1.5px solid var(--dsw-alias-state-success-primary); - border-radius: 50%; - corner-shape: round; - color: var(--dsw-alias-state-success-primary); -} - @media (prefers-reduced-motion: reduce) { .chip, .detail { diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx index 423ce25bcd..785c5e2af7 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { - Button, IconCheckOutlineRegular, IconWarningOutlineRegular, Modal, Toast, + Button, IconWarningOutlineRegular, Modal, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types' import type { FeedbackDialogProps } from './slots.ts' @@ -68,7 +68,7 @@ export function FeedbackDialog({ } + tone="success" anchor={card} onDone={onToastDone} /> diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css index 793cbe23a1..b8c09caf9f 100644 --- a/packages/client/ui-primitives/src/Toast.module.css +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -23,8 +23,11 @@ max-width: min(640px, calc(100vw - 48px)); padding: 12px 16px; border-radius: 14px; - background: var(--dsw-alias-button-contrast-fill); - color: var(--dsw-alias-label-primary-inverted); + /* The design platform's toast surface is dark in BOTH themes (bluish-800 + light / bluish-750 dark), so the label and the action text below stay + fixed instead of flipping with the theme. */ + background: var(--dsw-alias-toast-bg); + color: var(--dsw-static-neutral-bluish-00); font-size: 14px; line-height: 22px; box-shadow: var(--dsw-shadow-lv3); @@ -41,10 +44,35 @@ color: var(--dsw-alias-state-warn-label); } +.icon.success { + color: var(--dsw-alias-state-success-primary); +} + .text { min-width: 0; } +.action { + display: inline; + padding: 0; + border: 0; + background: none; + /* deepseek-400, brightened at the designer's request, reads as the action + against the always-dark toast surface without an underline. */ + color: var(--dsw-static-deepseek-400); + font: inherit; + cursor: pointer; + /* Breathing room where the action text meets the surrounding copy. */ + margin-inline: 3px; + /* The banner surface stays click-through (`pointer-events: none` above); + only its action text takes the pointer. */ + pointer-events: auto; +} + +.action:hover { + opacity: 0.8; +} + @keyframes dsh-toast-in { from { opacity: 0; diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx index 0a50e47230..157c4b9eb7 100644 --- a/packages/client/ui-primitives/src/Toast.tsx +++ b/packages/client/ui-primitives/src/Toast.tsx @@ -1,6 +1,7 @@ -import { useEffect, useLayoutEffect, useState } from 'react' +import { Fragment, useEffect, useLayoutEffect, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' +import { IconCheckCircleOutlineRegular } from './icons/index.tsx' import css from './Toast.module.css' /** Full-opacity hold before the fade starts, when the owner names none. */ @@ -23,7 +24,16 @@ const FADE_MS = 1000 * reads it as a custom property — so the two can no longer disagree and leave * the banner unmounting mid-fade. * @param props.text - resolved banner copy; the owner passes localized text. - * @param props.icon - optional leading glyph (e.g. a warning icon). + * @param props.icon - optional leading glyph (e.g. a warning icon); ignored + * under `tone="success"`, which brings its own glyph. + * @param props.tone - 'success' renders the design's circled green check as + * the leading glyph; omitted, the icon seat keeps its warning tint. + * @param props.actions - optional inline actions continuing the sentence: + * each renders its plain-text `prefix` (a connective like 或) followed by its + * localized `label` as blue clickable text, flowing after `text` as one + * sentence. Each press is the owner's to handle (e.g. undo the reported + * change, then unmount the toast). The banner surface stays click-through — + * only the action text takes the pointer. * @param props.holdMs - full-opacity hold before the fade; defaults to 3000. * @param props.anchor - optional element whose horizontal center the banner * follows (e.g. the composer card, so the banner centers over the chat column @@ -31,11 +41,13 @@ const FADE_MS = 1000 * @param props.onDone - called once the fade completes; unmount the toast here. * @returns the floating banner. */ -export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: { +export function Toast({ text, icon, tone, anchor, holdMs = HOLD_MS, actions, onDone }: { text: string icon?: ReactNode + tone?: 'success' anchor?: HTMLElement | null holdMs?: number + actions?: readonly { label: string; prefix?: string; onClick: () => void }[] onDone: () => void }) { useEffect(() => { @@ -65,8 +77,20 @@ export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: { '--dsh-toast-hold': `${String(holdMs)}ms`, } as CSSProperties} > - {icon !== undefined && {icon}} - {text} + {tone === 'success' + ? + : icon !== undefined && {icon}} + + {text} + {actions?.map(action => ( + + {action.prefix} + + + ))} + , document.body, ) diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 41baf5b785..6f41eb7044 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -20,12 +20,25 @@ interface AnchorProps { ref?: Ref | undefined onMouseEnter?: MouseEventHandler | undefined onMouseLeave?: MouseEventHandler | undefined + onClick?: MouseEventHandler | undefined onFocus?: FocusEventHandler | undefined onBlur?: FocusEventHandler | undefined } type TooltipLabel = string | (() => string) +// Focus alone cannot reveal how it arrived: a closing menu hands focus back to +// its trigger, and after a mouse selection that programmatic return must not +// raise the trigger's bubble, while keyboard focus must. Capture-phase window +// listeners record the last input modality for every Tooltip. The guard keeps +// the module loadable where no window exists (node-side imports of the +// package's pure helpers). +let pointerModality = false +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', () => { pointerModality = true }, true) + window.addEventListener('keydown', () => { pointerModality = false }, true) +} + /** * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. @@ -36,7 +49,9 @@ type TooltipLabel = string | (() => string) * @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default * half-viewport cap would render a slab wider than the surface the anchor sits on. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. - * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. + * @returns the cloned anchor plus a fixed-position bubble while hovered/focused; clicking the + * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer + * interaction (a closing menu refocusing its trigger) never raises it. */ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) @@ -168,7 +183,11 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() }, - onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, + // Activating the anchor dismisses the bubble: the action often changes + // what the anchor now does (pin → unpin), and the click leaves the + // anchor focused, which would otherwise pin the relabelled bubble up. + onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() }, + onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {visible && !suppressed && ( diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 749f203439..4333bf43a6 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1403,3 +1403,175 @@ export const IconShieldOutlineRegular = (props: IconProps) => ( export const IconShieldOutlineMedium = (props: IconProps) => ( ) + +const IconCheckCircleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconCheckCircleOutline artwork. */ +export const IconCheckCircleOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconCheckCircleOutline artwork with a 1.3px stroke. */ +export const IconCheckCircleOutlineMedium = (props: IconProps) => ( + +) + +const IconUnarchiveOutlineArtwork = ({ size = 20, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconUnarchiveOutline artwork. */ +export const IconUnarchiveOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconUnarchiveOutline artwork with a 1.3px stroke. */ +export const IconUnarchiveOutlineMedium = (props: IconProps) => ( + +) + +const IconPinOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconPinOutline artwork. */ +export const IconPinOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconPinOutline artwork with a 1.3px stroke. */ +export const IconPinOutlineMedium = (props: IconProps) => ( + +) + +const IconPinFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconPinFill artwork. */ +export const IconPinFillRegular = (props: IconProps) => ( + +) + +/** Medium IconPinFill artwork with a 1.3px stroke. */ +export const IconPinFillMedium = (props: IconProps) => ( + +) + +const IconFlatListOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconFlatListOutline artwork. */ +export const IconFlatListOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconFlatListOutline artwork with a 1.3px stroke. */ +export const IconFlatListOutlineMedium = (props: IconProps) => ( + +) + +const IconWorkspaceTreeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconWorkspaceTreeOutline artwork. */ +export const IconWorkspaceTreeOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconWorkspaceTreeOutline artwork with a 1.3px stroke. */ +export const IconWorkspaceTreeOutlineMedium = (props: IconProps) => ( + +) + +const IconChevronsUpDownOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconChevronsUpDownOutline artwork. */ +export const IconChevronsUpDownOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconChevronsUpDownOutline artwork with a 1.3px stroke. */ +export const IconChevronsUpDownOutlineMedium = (props: IconProps) => ( + +) + +const IconArchiveCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconArchiveCheckOutline artwork. */ +export const IconArchiveCheckOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconArchiveCheckOutline artwork with a 1.3px stroke. */ +export const IconArchiveCheckOutlineMedium = (props: IconProps) => ( + +) + +const IconSlidersTwoOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconSlidersTwoOutline artwork. */ +export const IconSlidersTwoOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconSlidersTwoOutline artwork with a 1.3px stroke. */ +export const IconSlidersTwoOutlineMedium = (props: IconProps) => ( + +) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 0287450115..89b17cb4bf 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('product icon set', () => { - it('exports regular and medium variants for all 81 public glyphs', () => { - expect(iconNames.length).toBe(162) + it('exports regular and medium variants for all 90 public glyphs', () => { + expect(iconNames.length).toBe(180) expect(iconNames.some(name => /\d+$/.test(name))).toBe(false) const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length)) const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length)) diff --git a/packages/client/ui-primitives/tests/toast.client.spec.tsx b/packages/client/ui-primitives/tests/toast.client.spec.tsx index 4064d1d7ac..93b0b2066d 100644 --- a/packages/client/ui-primitives/tests/toast.client.spec.tsx +++ b/packages/client/ui-primitives/tests/toast.client.spec.tsx @@ -59,6 +59,36 @@ describe('Toast', () => { } }) + it('flows prefixed actions inline as one sentence and hands each press to the owner', () => { + vi.useFakeTimers() + try { + const undo = vi.fn() + const filter = vi.fn() + const view = render( + , + ) + // The success tone brings the circled green check itself; no icon prop. + const glyph = view.getByRole('alert').querySelector('[aria-hidden]') + expect(glyph?.className).toContain('success') + expect(glyph?.querySelector('svg')).toBeTruthy() + expect(view.getByRole('alert').textContent).toBe('会话已归档,可撤销或筛选已归档会话') + fireEvent.click(view.getByRole('button', { name: '撤销' })) + expect(undo).toHaveBeenCalledTimes(1) + fireEvent.click(view.getByRole('button', { name: '筛选已归档会话' })) + expect(filter).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('renders without an icon and cancels its timer on unmount', () => { vi.useFakeTimers() try { diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 26f66958ab..e17eaf532b 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -257,23 +257,66 @@ describe('Tooltip', () => { } }) + it('a click on the anchor dismisses the bubble even while the anchor stays focused', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + // Pointer click: browsers focus the button first, then deliver the click. + fireEvent.focus(anchor) + fireEvent.mouseEnter(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.click(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + // The retained focus alone must not resurrect it on mouse leave. + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + // A fresh hover shows the (possibly relabelled) bubble again. + fireEvent.mouseEnter(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + }) + + it('focus arriving after a pointer interaction does not raise the bubble', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + // A closing menu refocuses its trigger after a mouse selection: the last + // interaction was a pointerdown on the menu row, not a key press. + fireEvent.pointerDown(document.body) + fireEvent.focus(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.blur(anchor) + // The next key press restores focus-driven bubbles (keyboard selection). + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + }) + it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { const onMouseEnter = vi.fn() const onMouseLeave = vi.fn() + const onClick = vi.fn() const onFocus = vi.fn() const onBlur = vi.fn() render( - + , ) const anchor = screen.getByText('anchor') fireEvent.mouseEnter(anchor) fireEvent.mouseLeave(anchor) + fireEvent.click(anchor) fireEvent.focus(anchor) fireEvent.blur(anchor) expect(onMouseEnter).toHaveBeenCalledOnce() expect(onMouseLeave).toHaveBeenCalledOnce() + expect(onClick).toHaveBeenCalledOnce() expect(onFocus).toHaveBeenCalledOnce() expect(onBlur).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index bd24d3cd15..f0ed057d43 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -37,10 +37,10 @@ const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array /** * Section ids the web-app roster registers, in nav order: this package, then - * ui-settings-models, ui-settings-plugins, ui-agent-preset, and - * ui-settings-unarchive-sessions. A plugin adding a section changes this list. + * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin + * adding a section changes this list. */ -const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets', 'archived-sessions'] +const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets'] /** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */ const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [ { id: 'welcome-notice', order: -100 }, diff --git a/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml b/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml deleted file mode 100644 index 2a027aaa3f..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/ui-settings-unarchive-sessions/README.md -README.md: 88a5835c2bf2a2e4799ee02e94985db9162e4ef0 -README.zh.md: 9e2a907d75a84a4e3262fc157023d26f93919a5d diff --git a/packages/client/ui-settings-unarchive-sessions/README.md b/packages/client/ui-settings-unarchive-sessions/README.md deleted file mode 100644 index 88a5835c2b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -description: "Archived-session Settings page for the dsh web client: the registry-global archive set as a searchable list with one Unarchive action per row." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions - -English | [中文](README.zh.md) - -## Summary - -The **Archived sessions** Settings page is the restore point for sessions hidden from Workspace navigation. It lists each archived session with the Workspace that owns it and its last activity, newest archive first, and offers one Unarchive action per row. A search box filters the list by session title or Workspace name. Rows come from the archive set joined with the loaded Session summaries, so an archive entry whose session record is gone has no row and no action. Every restore goes through the shared Workspace command. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Open Settings and select **Archived sessions** to see the sessions currently hidden from every grouping surface. Mount `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` in a Web composition that already provides the settings shell, the Workspace service, and the Session list; the page registers its own navigation entry and needs no configuration. - -### Reading a row - -Each row shows the session's display title, the title of the Workspace that accounts for it or the ungrouped label, and its last activity as a compact relative time. The page lists the most recently archived session first, the reverse of the durable archive order. The page waits for the Session list before rendering rows; while that list loads it shows a reading status instead of an empty archive. An empty archive, an archive whose entries have no loaded Session to restore, and a query matching no row report three different messages, so neither a search nor an unaddressable entry ever looks like an empty archive. - -### Restoring a session - -Unarchive restores the session to its recorded position under its Workspace, or to the ungrouped sessions when it belongs to none, and the row disappears from the page. The action calls `ctx.uiWorkspace.unarchiveSession`, whose echoed archive set updates every surface that filters on it, so the session reappears in the sidebar and search as well. A rejected call is logged as a console diagnostic and leaves the row in place for another attempt. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -The page is one localized `settings.section` contribution with id `archived-sessions`; the Settings shell owns the navigation entry, the modal, and the mounted section, so none of that chrome lives here. - -### Registration and data sources - -`apply()` registers the locale namespace, binds it, and uses `ctx.slots.inject()` to contribute the section, so a late or restored slot declaration still reaches it. The page reads `state.archivedSessionIds` and `state.items` from `useWorkspaces` and the Session summaries from `useSessions`; it owns no store and no transport, and its only write is the injected `unarchive` callback that the registration closes over `ctx.uiWorkspace.unarchiveSession`. - -### Row derivation - -Rows are derived from the archive set joined with the loaded summaries: a member with no summary produces no row, which is why a session deleted outside the Workspace registry leaves no unarchive action behind. Workspace ownership is read from each Workspace's `sessionIds`; a member outside every Workspace renders with the ungrouped label. Search normalizes the query once and matches it against the row title and Workspace label. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Host loader entry: the page is browser-only, so the plugin body is empty | -| [`src/client/index.ts`](src/client/index.ts) | Browser plugin: locale namespace, section registration, injected Unarchive operation | -| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | The page component: row derivation, search, per-row Unarchive | -| [`src/client/locales.ts`](src/client/locales.ts) | Chinese and English dictionaries for every visible and accessible string | -| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | Page styles | - -
- ------ - - -## Further Exploration - -These pages cover the settings surface that hosts the page, the archive write behind it, and the state it renders. - -- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the namespace scope service. -- [ui-settings-general](../ui-settings-general/README.md) — the Settings shell that renders the navigation and mounts the section. -- [ui-workspace](../ui-workspace/README.md) — the sidebar browser whose Session rows archive, and the `ctx.uiWorkspace` service this page restores through. -- [Workspace Controller](../../api/workspace-controller/README.md) — the `workspace.unarchiveSession` Remote and the Client model that owns the archive set. -- [Workspace subsystem](../../../docs/subsystems/workspace.md) — the durable archive set, its domain field, and the registry operation behind a restore. - ------ - - -## Model Experience - -None, as the package is a browser-side UI plugin layer that registers nothing model-facing. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - - - - -These limits define which archived sessions this page can restore; they are current package constraints. - -- **Archived sessions without a loaded summary are unaddressable** — the page derives its rows by joining the archive set with the Session list, so a member the list does not carry has no row and no Unarchive action even though the archive set still holds it; a set whose members are all in that state reports itself as unrestorable rather than empty. -- **The page lists sessions only; it offers no session deletion** — archives are reversible through this page, while deleting a session record remains a separate capability. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
- -**Runtime invariant:** No companion is published. A browser-side settings page that registers one localized `settings.section` contribution and its locale namespace; it emits no Cordis events and owns no cross-plugin mutable relation. diff --git a/packages/client/ui-settings-unarchive-sessions/README.zh.md b/packages/client/ui-settings-unarchive-sessions/README.zh.md deleted file mode 100644 index 9e2a907d75..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.zh.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -description: "dsh Web 客户端的已归档会话设置页:把注册表全局归档集合呈现为可搜索列表,每行提供一个取消归档操作。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions - -[English](README.md) | 中文 - -## 概述 - -**已归档会话**设置页是从 Workspace 导航中隐藏的会话的恢复入口。它按归档时间由新到旧列出每个已归档会话,并显示其所属 Workspace 与最近活动时间,每行提供一个取消归档操作。搜索框按会话标题或 Workspace 名称过滤列表。行由归档集合与已加载的 Session 摘要合并而来,因此会话记录已不存在的归档条目既没有行也没有操作。每次恢复都经由共享的 Workspace 命令。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -打开设置并选择**已归档会话**,即可看到当前从所有分组视图中隐藏的会话。在已提供设置外壳、Workspace 服务与 Session 列表的 Web 组合中挂载 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`;该页面注册自己的导航条目,无需配置。 - -### 阅读一行 - -每行显示会话的显示标题、为其记账的 Workspace 标题或未分组标签,以及以紧凑相对时间表示的最近活动。页面把最近归档的会话排在前面,与持久归档顺序相反。页面会先等待 Session 列表再渲染行;列表加载期间显示读取状态,而不是空归档。空归档、归档条目均无已加载 Session 可恢复、以及查询无匹配分别给出三种提示,因此搜索和不可寻址的条目都不会被误认为归档为空。 - -### 恢复会话 - -取消归档会把会话恢复到其 Workspace 下记录的位置;会话不属于任何 Workspace 时则恢复到未分组会话中,该行随即从页面消失。该操作调用 `ctx.uiWorkspace.unarchiveSession`,其回传的完整归档集合会更新所有依据它过滤的界面,因此会话也会重新出现在侧边栏与搜索中。调用被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -页面是 id 为 `archived-sessions` 的一个本地化 `settings.section` 贡献;导航条目、模态框与挂载的分区都归设置外壳所有,不在此包内。 - -### 注册与数据来源 - -`apply()` 注册并绑定 locale namespace,再用 `ctx.slots.inject()` 贡献该分区,因此延迟声明或恢复声明的 slot 依然能触达它。页面从 `useWorkspaces` 读取 `state.archivedSessionIds` 与 `state.items`,从 `useSessions` 读取 Session 摘要;它不持有 store 也不持有 transport,唯一的写入是注册处以 `ctx.uiWorkspace.unarchiveSession` 闭包注入的 `unarchive` 回调。 - -### 行的派生 - -行由归档集合与已加载摘要合并派生:没有摘要的成员不产生行,因此在 Workspace 注册表之外被删除的会话不会留下取消归档操作。Workspace 归属从各 Workspace 的 `sessionIds` 读取;不属于任何 Workspace 的成员以未分组标签渲染。搜索只规范化一次查询,并与行标题和 Workspace 标签匹配。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 宿主 loader 入口:该页面仅供浏览器使用,因此插件体为空 | -| [`src/client/index.ts`](src/client/index.ts) | 浏览器插件:locale namespace、分区注册、注入的取消归档操作 | -| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | 页面组件:行的派生、搜索、每行的取消归档 | -| [`src/client/locales.ts`](src/client/locales.ts) | 全部可见与无障碍字符串的中英文字典 | -| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | 页面样式 | - -
- ------ - - -## 进一步探索 - -以下页面覆盖承载该页面的设置界面、其背后的归档写入,以及它所渲染的状态。 - -- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 namespace scope 服务的领域底座。 -- [ui-settings-general](../ui-settings-general/README.zh.md)——渲染导航并挂载该分区的设置外壳。 -- [ui-workspace](../ui-workspace/README.zh.md)——其 Session 行负责归档的侧边栏浏览器,以及本页面借以恢复的 `ctx.uiWorkspace` 服务。 -- [Workspace Controller](../../api/workspace-controller/README.zh.md)——`workspace.unarchiveSession` Remote 与持有归档集合的 Client model。 -- [Workspace 子系统](../../../docs/subsystems/workspace.zh.md)——持久归档集合、其领域字段,以及恢复背后的注册表操作。 - ------ - - -## 模型体验 - -无。该包是浏览器端 UI 插件层,不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与延期工作 - - - - -这些限制界定本页面能够恢复哪些已归档会话;它们是当前包约束。 - -- **已加载摘要缺失的已归档会话无法寻址**:页面通过与 Session 列表合并来派生行,因此列表未携带的成员没有行也没有取消归档操作,尽管归档集合仍持有它;当集合中的成员全部处于该状态时,页面报告无法恢复,而不是报告归档为空。 -- **页面只列出会话,不提供会话删除**:归档可通过本页面恢复,而删除会话记录仍是彼此独立的能力。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
- -**运行时不变式:** 不发布伴生入口。本包是浏览器端设置页,只注册一个本地化 `settings.section` 贡献及其 locale namespace;它不发出 Cordis 事件,也不持有跨插件可变关系。 diff --git a/packages/client/ui-settings-unarchive-sessions/package.json b/packages/client/ui-settings-unarchive-sessions/package.json deleted file mode 100644 index d9b13ccde6..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/package.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions", - "description": "Archived-session settings page: the registry-global archive set with one Unarchive action per row", - "version": "0.1.6-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-settings-unarchive-sessions" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-workspace" - ], - "platform": "web" - } - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@testing-library/react": "^16.1.0", - "@types/react": "~18.3.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "files": [ - "lib/index.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css b/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css deleted file mode 100644 index 7e2f55c85a..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css +++ /dev/null @@ -1,83 +0,0 @@ -.section { - display: flex; - flex-direction: column; - gap: 12px; - width: 100%; - max-width: 760px; - color: var(--dsw-alias-label-primary); -} - -.status { - margin: 0; - font-size: 13px; - line-height: 20px; - color: var(--dsw-alias-label-tertiary); -} - -.search { - position: relative; - display: flex; - align-items: center; - width: 100%; - color: var(--dsw-alias-label-tertiary); -} - -.search > svg { - position: absolute; - left: 12px; - pointer-events: none; -} - -.search input { - width: 100%; - height: 32px; - padding: 0 12px 0 36px; - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 8px; - background: var(--dsw-alias-bg-base); - color: var(--dsw-alias-label-primary); - font: inherit; -} - -.list { - display: flex; - flex-direction: column; - gap: 2px; - margin: 0; - padding: 0; - list-style: none; -} - -.row { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 10px; - border-radius: 8px; -} - -.row:hover { - background: var(--dsw-alias-bg-layer-1); -} - -.identity { - display: flex; - flex: 1; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.title { - overflow: hidden; - font-size: 13px; - line-height: 20px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.meta { - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx b/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx deleted file mode 100644 index cab7df1c7b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Archived-session Settings page: the registry-global archive set joined with - * the loaded Session summaries, newest archive first, filtered by one search - * box, with one Unarchive action per row. An archive entry whose Session is - * gone has no row and no action; the set itself stays host-owned. - */ -import { useMemo, useState, type ReactNode } from 'react' -import { Button, IconSearchOutlineRegular, relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import css from './ArchivedSessionsSection.module.css' - -/** Registration-side face used by the page. */ -export interface ArchivedSessionsSectionInjected { - /** - * Restore one archived Session. - * @param sessionId - Session to unarchive. - */ - unarchive: (sessionId: SessionId) => Promise -} - -/** Full component props assembled by the Settings slot renderer. */ -export type ArchivedSessionsSectionProps = - PropsRuntime<'settings.section'> - & PropsLocale<'settings.archivedSessions'> - & InjectFace - -type Translate = ArchivedSessionsSectionProps['t'] - -/** One archived Session as the page renders it. */ -interface ArchivedRow { - id: SessionId - title: string - /** Owning Workspace title, or the ungrouped label for a Session outside every Workspace. */ - workspace: string - updatedAt: number -} - -/** Localized compact relative time of one row's last activity. */ -function timeLabel(updatedAt: number, now: number, t: Translate): string { - const { unit, n } = relativeTime(updatedAt, now) - return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n }) -} - -/** Whether one row matches the normalized query in its title or Workspace label. */ -function matches(row: ArchivedRow, normalizedQuery: string): boolean { - return normalizedQuery.length === 0 - || row.title.toLowerCase().includes(normalizedQuery) - || row.workspace.toLowerCase().includes(normalizedQuery) -} - -/** - * Render the archived-session page. - * @param props - composed slot props (see {@link ArchivedSessionsSectionProps}). - * @returns the settings page element tree. - */ -export function ArchivedSessionsSection(props: ArchivedSessionsSectionProps): ReactNode { - const { t, unarchive, useSessions, useWorkspaces } = props - const sessions = useSessions(state => state) - const workspaces = useWorkspaces(state => state.items) - const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) - const [query, setQuery] = useState('') - const ungrouped = t('ungrouped') - const summaries = sessions.byId - - // Archive order is oldest first; the page lists the most recently archived - // Session first. A member with no loaded summary is not addressable here. - const rows = useMemo(() => { - const owners = new Map() - for (const workspace of workspaces) { - for (const id of workspace.sessionIds) owners.set(id, workspace.title) - } - return [...archivedSessionIds].reverse().flatMap((id) => { - const summary = summaries[id] - if (summary === undefined) return [] - return [{ - id, - title: summary.displayTitle, - workspace: owners.get(id) ?? ungrouped, - updatedAt: summary.updatedAt, - }] - }) - }, [archivedSessionIds, workspaces, summaries, ungrouped]) - - if (sessions.phase !== 'ready') return

{t('loading')}

- - const now = Date.now() - const visible = rows.filter(row => matches(row, query.trim().toLowerCase())) - const archived = archivedSessionIds.length > 0 - - return ( -
-
-
- {!archived ?

{t('empty')}

: null} - {archived && rows.length === 0 ?

{t('unavailable')}

: null} - {rows.length > 0 && visible.length === 0 ?

{t('emptySearch')}

: null} - {visible.length > 0 ? ( -
    - {visible.map(row => ( -
  • - - {row.title} - - {[row.workspace, timeLabel(row.updatedAt, now, t)].join(' · ')} - - - -
  • - ))} -
- ) : null} -
- ) -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/index.ts b/packages/client/ui-settings-unarchive-sessions/src/client/index.ts deleted file mode 100644 index 353e6c072b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Archived-session Settings page, browser half. */ - -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: pulls the `uiWorkspace` Context merge and the `useWorkspaces` -// global standard prop this page reads. -import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' -// Type-only: pulls the `useSessions` global standard prop. -import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { ArchivedSessionsSection } from './ArchivedSessionsSection.tsx' -import type { ArchivedSessionsSectionInjected } from './ArchivedSessionsSection.tsx' -import { en, zh, type ArchivedSessionsLocaleKey } from './locales.ts' - -export type { ArchivedSessionsSectionInjected, ArchivedSessionsSectionProps } from './ArchivedSessionsSection.tsx' -export type { ArchivedSessionsLocaleKey } from './locales.ts' - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Archived-session page copy. */ - 'settings.archivedSessions': ArchivedSessionsLocaleKey - } -} - -/** Dictionary namespace owned by this plugin. */ -const NS = 'settings.archivedSessions' - -/** Services required by the Settings registration and the archive write. */ -export const inject = ['slots', 'locale', 'uiWorkspace'] - -/** Contribute the archived-session page to Settings. */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-unarchive-sessions: dictionaries') - - const t = ctx.locale.bind(NS) - const injected = (): ArchivedSessionsSectionInjected => ({ - unarchive: sessionId => ctx.uiWorkspace.unarchiveSession(sessionId), - }) - - ctx.slots.inject('settings.section', () => ctx.slots.register({ - name: 'settings.section', - id: 'archived-sessions', - order: 25, - label: () => t('nav'), - locale: NS, - inject: injected, - }, ArchivedSessionsSection)) -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts b/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts deleted file mode 100644 index 4b408687ef..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Copy dictionaries for the archived-session Settings page. */ - -/** Simplified Chinese dictionary and key source of truth. */ -export const zh = { - nav: '已归档会话', - search: '搜索已归档会话', - loading: '正在读取会话…', - empty: '暂无已归档会话。', - unavailable: '这里没有可恢复的已归档会话。', - emptySearch: '没有匹配的会话。', - unarchive: '取消归档', - unarchiveNamed: '取消归档 {title}', - ungrouped: '未分组', - 'time.now': '刚刚', - 'time.minutes': '{n}分钟', - 'time.hours': '{n}小时', - 'time.days': '{n}天', - 'time.months': '{n}个月', - 'time.years': '{n}年', -} satisfies Record - -/** Archived-session page locale key union. */ -export type ArchivedSessionsLocaleKey = keyof typeof zh - -/** English dictionary checked against the Chinese key set. */ -export const en = { - nav: 'Archived sessions', - search: 'Search archived sessions', - loading: 'Reading sessions…', - empty: 'No archived sessions.', - unavailable: 'No archived session here can be restored.', - emptySearch: 'No matching sessions.', - unarchive: 'Unarchive', - unarchiveNamed: 'Unarchive {title}', - ungrouped: 'Ungrouped', - 'time.now': 'now', - 'time.minutes': '{n}min', - 'time.hours': '{n}h', - 'time.days': '{n}d', - 'time.months': '{n}mo', - 'time.years': '{n}y', -} satisfies Record diff --git a/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts b/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-settings-unarchive-sessions/src/index.ts b/packages/client/ui-settings-unarchive-sessions/src/index.ts deleted file mode 100644 index 3ebf995f31..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host loader entry for the archived-session page exported from `./client`. */ - -/** Host plugin body — the archived-session page is browser-only. */ -export function apply(): void {} diff --git a/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx deleted file mode 100644 index 48d8634251..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup } from '@testing-library/react' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { apply, inject } from '../src/client/index.ts' -import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx' -import type { ArchivedSessionsSectionInjected } from '../src/client/ArchivedSessionsSection.tsx' -import { apply as hostApply } from '../src/index.ts' - -usePinnedBrowserLanguages('zh-CN') -afterEach(cleanup) - -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const locale = new LocaleRuntime(ctx) - ctx.provide('locale', locale) - const unarchiveSession = vi.fn<(sessionId: SessionId) => Promise>(async () => {}) - ctx.provide('uiWorkspace', { unarchiveSession }) - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, unarchiveSession } -} - -function declare(slots: SlotRegistry): () => void { - return slots.register({ - name: 'root', - children: { 'settings.section': { kind: 'list', scope: 'root' } }, - } as never, () => null) -} - -describe('ui-settings-unarchive-sessions browser plugin', () => { - it('keeps the host Loader entry inert', () => { - expect(hostApply).not.toThrow() - }) - - it('declares only the services used by the page and the archive write', () => { - expect(inject).toEqual(['slots', 'locale', 'uiWorkspace']) - }) - - it('registers the archived-session page last with localized copy', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - - const entry = b.slots.entries('settings.section')[0]! - expect(entry.component).toBe(ArchivedSessionsSection) - expect(entry.options).toMatchObject({ id: 'archived-sessions', order: 25 }) - expect(entry.locale).toBe('settings.archivedSessions') - expect(resolveSlotLabel(entry.options.label)).toBe('已归档会话') - - b.locale.setLocale('en') - expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Archived sessions') - - const injected = (entry.inject as unknown as () => ArchivedSessionsSectionInjected)() - await expect(injected.unarchive('session-one' as SessionId)).resolves.toBeUndefined() - expect(b.unarchiveSession).toHaveBeenCalledWith('session-one') - await b.ctx.fiber.dispose() - }) - - it('follows a late declaration and a declarer reload', async () => { - const b = await bench() - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(0) - - const stop = declare(b.slots) - await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) - - stop() - expect(b.slots.entries('settings.section')).toHaveLength(0) - declare(b.slots) - await vi.waitFor(() => { - expect(b.slots.entries('settings.section')[0]?.component).toBe(ArchivedSessionsSection) - }) - - await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) - expect(() => b.locale.register('settings.archivedSessions', 'zh', {})).not.toThrow() - await b.ctx.fiber.dispose() - }) -}) diff --git a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx b/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx deleted file mode 100644 index a944d8dea9..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx +++ /dev/null @@ -1,158 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx' -import type { ArchivedSessionsSectionProps } from '../src/client/ArchivedSessionsSection.tsx' -import { en, type ArchivedSessionsLocaleKey } from '../src/client/locales.ts' - -afterEach(cleanup) - -const DAY = 86_400_000 -const sid = (id: string): SessionId => id as SessionId - -const t = ((key: ArchivedSessionsLocaleKey, params?: Record): string => - Object.entries(params ?? {}).reduce( - (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), - en[key], - )) as ArchivedSessionsSectionProps['t'] - -function summary(id: string, title: string, updatedAt: number): SessionSummary { - return { id: sid(id), displayTitle: title, running: false, retainedBy: {}, blank: false, updatedAt } -} - -function sessionState(sessions: readonly SessionSummary[], phase: SessionListState['phase'] = 'ready'): SessionListState { - return { - ids: sessions.map(session => session.id), - byId: Object.fromEntries(sessions.map(session => [session.id, session])), - phase, - subagentsByParent: {}, - jobsBySession: {}, - } -} - -function snapshot(archivedSessionIds: readonly string[], items: readonly WorkspaceView[] = []): WorkspaceSnapshot { - return { items, archivedSessionIds: archivedSessionIds.map(sid), state: 'idle', phase: 'ready', error: null } -} - -function workspace(title: string, sessionIds: readonly string[]): WorkspaceView { - return { - workspaceId: title as WorkspaceView['workspaceId'], - path: `/work/${title}`, - title, - sessionIds: sessionIds.map(sid), - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - } -} - -function props(options: { - sessions: SessionListState - workspaces: WorkspaceSnapshot - unarchive?: (sessionId: SessionId) => Promise -}): ArchivedSessionsSectionProps { - return { - t, - unarchive: options.unarchive ?? (async () => {}), - useSessions: ((select: (state: SessionListState) => unknown) => select(options.sessions)), - useWorkspaces: ((select: (state: WorkspaceSnapshot) => unknown) => select(options.workspaces)), - } as unknown as ArchivedSessionsSectionProps -} - -/** Two archived sessions in one Workspace, archived oldest first. */ -function twoRows(): ArchivedSessionsSectionProps { - return props({ - sessions: sessionState([ - summary('older', 'Older session', Date.now() - 2 * DAY), - summary('newer', 'Newer session', Date.now()), - ]), - workspaces: snapshot(['older', 'newer'], [workspace('Project', ['older', 'newer'])]), - }) -} - -describe('ArchivedSessionsSection', () => { - it('lists the newest archive first with its Workspace and last activity', () => { - render() - - expect(screen.getAllByRole('listitem').map(row => row.textContent)).toEqual([ - 'Newer sessionProject · nowUnarchive', - 'Older sessionProject · 2dUnarchive', - ]) - expect(screen.getAllByRole('button', { name: /^Unarchive/ }).map(button => button.getAttribute('aria-label'))) - .toEqual(['Unarchive Newer session', 'Unarchive Older session']) - }) - - it('labels a session outside every Workspace and hides an entry whose session is gone', () => { - render() - - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('Loose session')).toBeTruthy() - expect(screen.getByText('Ungrouped · now')).toBeTruthy() - }) - - it('reads sessions until the list arrives, then reports an empty archive', () => { - const pending = render() - expect(screen.getByText(en.loading)).toBeTruthy() - expect(screen.queryByRole('searchbox')).toBeNull() - pending.unmount() - - render() - expect(screen.getByText(en.empty)).toBeTruthy() - expect(screen.queryByRole('listitem')).toBeNull() - }) - - it('does not report an empty archive while every entry lacks a Session summary', () => { - render() - - expect(screen.getByText(en.unavailable)).toBeTruthy() - expect(screen.queryByText(en.empty)).toBeNull() - expect(screen.queryByText(en.emptySearch)).toBeNull() - }) - - it('filters by title and Workspace and reports a query with no match', () => { - render() - const search = screen.getByRole('searchbox', { name: en.search }) - - fireEvent.change(search, { target: { value: 'older' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('Older session')).toBeTruthy() - - fireEvent.change(search, { target: { value: 'project' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(2) - - fireEvent.change(search, { target: { value: 'absent' } }) - expect(screen.getByText(en.emptySearch)).toBeTruthy() - expect(screen.queryByRole('listitem')).toBeNull() - expect(screen.queryByText(en.empty)).toBeNull() - }) - - it('unarchives the clicked row and keeps a rejection as a console diagnostic', async () => { - const unarchive = vi.fn(async () => {}) - render() - fireEvent.click(screen.getByRole('button', { name: 'Unarchive Newer session' })) - expect(unarchive).toHaveBeenCalledWith('newer') - - cleanup() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const failure = new Error('transport down') - render( { throw failure }, - })} />) - fireEvent.click(screen.getByRole('button', { name: 'Unarchive Older session' })) - await waitFor(() => { expect(warn).toHaveBeenCalledWith('session unarchive rejected:', failure) }) - warn.mockRestore() - }) -}) diff --git a/packages/client/ui-settings-unarchive-sessions/tsconfig.json b/packages/client/ui-settings-unarchive-sessions/tsconfig.json deleted file mode 100644 index 35af3c7e53..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../locale" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-session" - }, - { - "path": "../ui-settings" - }, - { - "path": "../ui-slots" - }, - { - "path": "../ui-workspace" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts b/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts deleted file mode 100644 index 2fcc0276fc..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-unarchive-sessions', ['lib/types/index.js']) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 9ff8e82684..4cc18f3be5 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -22,7 +22,8 @@ body { --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); --dsw-static-deepseek-300: rgb(183, 200, 254); - --dsw-static-deepseek-400: rgb(103, 158, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); --dsw-static-deepseek-450: rgb(86, 134, 254); --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); @@ -98,7 +99,8 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); --dsw-static-deepseek-300: rgb(183, 200, 254); - --dsw-static-deepseek-400: rgb(103, 158, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); --dsw-static-deepseek-450: rgb(86, 134, 254); --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 60a830a76b..ebdce77d83 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -31,7 +31,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 7ec72aa242..6f3f0d6a90 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -33,7 +33,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index a7e0830c66..00be6482a4 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -178,7 +178,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } @@ -835,6 +835,9 @@ describe('timeline projection', () => { expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy() fireEvent.pointerEnter(boundary) expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() + // Keyboard modality first: Tooltip suppresses focus arriving after a + // pointer interaction, and earlier cases in this file press pointers. + fireEvent.keyDown(boundary, { key: 'Tab' }) fireEvent.focus(boundary) expect(screen.getByRole('tooltip').textContent) .toContain('Click to load earlier history') diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 29770236b8..561c3cb361 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -59,6 +59,7 @@ const attentionState: AttentionState = new Map() const workspaceState = { items: [], archivedSessionIds: [], + pinnedSessions: [], state: 'idle' as const, phase: 'ready' as const, error: null, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index b83eb16262..81648e06f6 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -57,6 +57,7 @@ const attentionState: AttentionState = new Map() const workspaceState = { items: [], archivedSessionIds: [], + pinnedSessions: [], state: 'idle' as const, phase: 'ready' as const, error: null, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5843a2009f..11835528ef 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: eabd5dc9eb8cad987da5eb7b6a747c3650ce69a4 -README.zh.md: 4358a00617376259a85b0e995aff3f95b295a2ff +README.md: f28bdd16143bd48a193a863193302b5dfd1aa345 +README.zh.md: 017dd6ae978c4cd361770103d22a0ca39723d117 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index eabd5dc9eb..f28bdd1614 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -116,7 +116,7 @@ None; this package neither assembles nor sends a provider request. These limits define the search depth, the archive surface, and the picking carrier; they are current package constraints. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion, and unarchive lives in Settings** — sessions can be archived but never deleted; the archived-sessions Settings page ([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.md)) owns viewing and restoring them, and Workspace registration deletion does not delete Sessions. +- **No Session deletion** — sessions can be archived but never deleted; archived rows stay recoverable in place through the archived view filter and the search results' unarchive action, and Workspace registration deletion does not delete Sessions. - **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 4358a00617..017dd6ae97 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -116,7 +116,7 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor 这些限制定义搜索深度、归档界面与选取载体;它们是当前包约束。 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除,取消归档位于设置中**:会话可以归档但绝不会被删除;已归档会话的查看与恢复由「已归档会话」设置页([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.zh.md))负责,删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除**:会话可以归档但绝不会被删除;已归档的行通过「已归档会话」视图筛选与搜索结果中的取消归档操作原位恢复,删除 Workspace 注册记录不会删除 Session。 - **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,进程内部署或远程浏览器部署无法打开本地操作系统对话框;可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0686e58497..925d10b551 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -66,14 +67,15 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-schedule": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@formkit/auto-animate": "0.10.0", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index baa263b1a6..f8d50e58d5 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -134,6 +134,12 @@ export type WorkspaceBrowserInjected = { * session clears the selection into the New Session view state. */ archiveSession: (sessionId: SessionId) => Promise + /** Remove a Session from the registry-global archived set. */ + unarchiveSession: (sessionId: SessionId) => Promise + /** Add a Session to the registry-global pinned set (newest pin first). */ + pinSession: (sessionId: SessionId) => Promise + /** Remove a Session from the registry-global pinned set. */ + unpinSession: (sessionId: SessionId) => Promise /** Adopt a picked host directory as a real Workspace before targeting a Session. */ createWorkspace: (input: { path: string }) => Promise } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 514ddd9e92..44d65efb20 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -132,6 +132,9 @@ export function apply(ctx: Context): void { await workspaces.insertBefore(workspaceId, beforeWorkspaceId) }, archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) }, + unarchiveSession: async (sessionId) => { await uiWorkspace.unarchiveSession(sessionId) }, + pinSession: async (sessionId) => { await workspaces.pinSession(sessionId) }, + unpinSession: async (sessionId) => { await workspaces.unpinSession(sessionId) }, createWorkspace: input => workspaces.create(input), hooks: { directoryFlow: browserFlowSource, hostInfo }, }) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 95c4d184eb..ca518bc2fc 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -18,17 +18,19 @@ export const zh = { 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', 'orderBy.updated': '最近更新', + 'filterBy.label': '筛选会话', + 'viewOptions.showArchived': '显示已归档', + 'viewOptions.onlyArchived': '仅显示已归档', 'sessions.expand': '展开其余 {n} 个会话', 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', - 'search.placeholder': '搜索会话…', + 'search.placeholder': '搜索会话名称', 'search.clear': '清除搜索', 'search.results.aria': '搜索结果', 'search.pending': '正在搜索会话历史…', - 'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。', 'search.noMatches': '无匹配会话', 'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。', 'menu.addWorkspace': '添加工作区…', @@ -46,6 +48,19 @@ export const zh = { 'delete.pending': '正在删除工作区…', 'menu.fork': '分叉会话', 'menu.archiveSession': '归档会话', + 'menu.unarchiveSession': '取消归档', + 'menu.pinSession': '置顶会话', + 'menu.unpinSession': '取消置顶', + 'row.archived': '已归档', + 'row.pinned': '已置顶', + 'toast.archivedNotOpenable': '已归档对话暂时无法查看,请取消归档后查看', + 'toast.archived': '会话已归档,可', + 'toast.archivedUndo': '撤销', + 'toast.archivedOr': '或', + 'toast.archivedFilter': '筛选已归档会话', + 'toast.pinFailed': '置顶失败,请稍后重试', + 'toast.unpinFailed': '取消置顶失败,请稍后重试', + 'hint.archivedFilter': '已归档的会话可以通过筛选展示', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -92,17 +107,19 @@ export const en = { 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', 'orderBy.updated': 'Last updated', + 'filterBy.label': 'Filter sessions', + 'viewOptions.showArchived': 'Show archived', + 'viewOptions.onlyArchived': 'Archived only', 'sessions.expand': 'Show {n} more sessions', 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', - 'search.placeholder': 'Search sessions...', + 'search.placeholder': 'Search session names', 'search.clear': 'Clear search', 'search.results.aria': 'Search results', 'search.pending': 'Searching session history…', - 'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.', 'search.noMatches': 'No matching sessions', 'search.hasMore': 'Showing the first {n} results. Narrow your search.', 'menu.addWorkspace': 'Add workspace…', @@ -120,6 +137,19 @@ export const en = { 'delete.pending': 'Deleting workspace…', 'menu.fork': 'Fork session', 'menu.archiveSession': 'Archive session', + 'menu.unarchiveSession': 'Unarchive session', + 'menu.pinSession': 'Pin session', + 'menu.unpinSession': 'Unpin session', + 'row.archived': 'Archived', + 'row.pinned': 'Pinned', + 'toast.archivedNotOpenable': 'Archived sessions cannot be opened. Unarchive it to view.', + 'toast.archived': 'Session archived. You can ', + 'toast.archivedUndo': 'undo', + 'toast.archivedOr': ' or ', + 'toast.archivedFilter': 'filter archived sessions', + 'toast.pinFailed': 'Pin failed. Try again later.', + 'toast.unpinFailed': 'Unpin failed. Try again later.', + 'hint.archivedFilter': 'Archived sessions can be shown with the filter', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 0f5792b4c2..77b172a957 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -101,13 +101,12 @@ height: 20px; } -/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ +/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. + Row appearance/removal animates through the browser's auto-animate + containers, so the row itself declares no mount animation. */ .sessionRow { height: 32px; gap: 0; - /* Mount fade: session rows appear by unfolding a group (or the tree - mounting). Stable row keys keep already-visible rows from replaying it. */ - animation: row-in 150ms var(--ds-ease-in-out); } .sessionRow .title { @@ -118,10 +117,6 @@ margin-left: 0; } -@keyframes row-in { - from { opacity: 0; } -} - .slot { flex: none; width: 16px; @@ -240,6 +235,29 @@ margin-left: 4px; } +.pinIndicator { + display: inline-flex; + flex: none; + width: 16px; + height: 20px; + align-items: center; + justify-content: center; + margin-left: 6px; + color: var(--dsw-alias-label-caption); +} + +/* Archived rows stay in place but read as inactive: title and time drop to + the caption label step (one step above dimmed, which reads as too faint + over the sidebar fill). Archived search rows drop their meta line to the + same step so both lines read as one unit. */ +.sessionRow.archived .title, +.sessionRow.archived .time, +.searchResultRow.archived .searchResultTitle, +.searchResultRow.archived .searchResultWorkspace, +.searchResultRow.archived .searchResultSnippet { + color: var(--dsw-alias-label-caption); +} + .dot { flex: none; } @@ -255,16 +273,41 @@ .projectRow:hover .rowActions, .sessionRow:hover .rowActions, +.searchResultRow:hover .rowActions, .projectRow.menuOpen .rowActions, .sessionRow.menuOpen .rowActions { display: inline-flex; } +/* The search heading has no trailing time cell, so the unarchive action + right-aligns itself. */ +.searchResultHeading .rowActions { + margin-left: auto; +} + .sessionRow:hover .time, .sessionRow.menuOpen .time { display: none; } +/* Hover swaps the trailing cell to the action buttons, which already carry + the pin/archive glyphs — hide the static markers so they do not double. */ +.sessionRow:hover .pinIndicator, +.sessionRow.menuOpen .pinIndicator { + display: none; +} + +/* Hover truncation fades the title's own alpha out instead of clipping to + "…": an overlay gradient would need the sidebar's fill, which macOS + vibrancy makes translucent, while a mask works over any backdrop. Short + titles never reach the faded band, so only overflow is affected. */ +.sessionRow:hover .title, +.sessionRow.menuOpen .title { + text-overflow: clip; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); +} + /* An open row menu pins the hover affordances (figma: the row keeps its hover fill while its dropdown is up). */ .projectRow.menuOpen, @@ -350,6 +393,12 @@ color: #ADB2B8; } +/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */ +.hoverArchived svg { + flex-shrink: 0; + margin: 0 -4px; +} + .iconButton { flex: none; display: inline-flex; @@ -376,7 +425,6 @@ } @media (prefers-reduced-motion: reduce) { - .sessionRow, .arrow { animation: none; transition: none; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 20cc9a4852..c31dfd1560 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -11,8 +11,8 @@ import clsx from 'clsx' import { HoverCard, IconAlarmClockOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, - IconPlusOutlineMedium, IconTrashOutlineRegular, IconTriangleRightFillRegular, Menu, relativeTime, - StateDot, + IconPinFillRegular, IconPinOutlineRegular, IconPlusOutlineMedium, IconTrashOutlineRegular, IconTriangleRightFillRegular, + IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -336,9 +336,22 @@ function ActiveScheduleIndicator({ t, search = false }: { t: RowTranslate; searc ) } +/** Non-interactive pinned-row marker; the enclosing row remains the only action. */ +function PinnedIndicator({ t }: { t: RowTranslate }) { + const label = t('row.pinned') + return ( + + + + ) +} + /** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { + // On archived rows the archived line already says the session is inactive, + // so resting statuses (idle/completed) drop; live activity still shows. const statuses = sessionStatuses(node, t) + .filter(status => !(node.archived && status.state === 'done')) return (
{displayTitle(node, t)}
@@ -351,6 +364,12 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; {status.label}
))} + {node.archived && ( +
+ + {t('row.archived')} +
+ )} ) } @@ -358,38 +377,55 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an - * event inside the conversation. + * event inside the conversation. Archived rows carry a hover unarchive + * button, because search is where the filter surfaces them for recovery. * @param props.result - merged local/content search row. * @param props.currentId - selected session id. * @param props.onOpen - open the selected session. + * @param props.onUnarchive - unarchive an archived result row. * @param props.t - Workspace-browser translation seat. - * @returns the result button. + * @returns the result row. */ -export function SearchResultItem({ result, currentId, onOpen, t }: { +export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void + onUnarchive: (id: SearchResultNode['id']) => void t: RowTranslate }) { const selected = result.id === currentId const statuses = sessionStatuses(result, t) const primaryStatus = statuses[0] return ( - + + )} {result.workspace || t('group.ungrouped')} @@ -397,7 +433,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.snippet} )} - + ) } @@ -419,7 +455,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { * @returns the session row. */ export function SessionNodeItem({ - node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t, + node, currentId, now, onOpen, onRename, onFork, onArchive, onUnarchive, onPin, onReveal, drag, flat = false, t, }: { node: SessionNode currentId: string | undefined @@ -431,6 +467,10 @@ export function SessionNodeItem({ onFork: (id: SessionNode['id']) => void /** Archive this session (row menu action; commits without a dialog). */ onArchive: (id: SessionNode['id']) => void + /** Unarchive this session (row menu action on archived rows). */ + onUnarchive: (id: SessionNode['id']) => void + /** Pin or unpin this session (row menu action; `pin` false unpins). */ + onPin: (id: SessionNode['id'], pin: boolean) => void /** Scroll this row into view after search navigation, then acknowledge it. */ onReveal?: (() => void) | undefined /** Present on reorderable-list rows so every row can remain a drop target. */ @@ -445,7 +485,10 @@ export function SessionNodeItem({ const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] const showStatus = primaryStatus.state !== 'idle' - const draggable = drag !== undefined && !row.blank + // Archived rows hold their in-place grayed slot, so manual reorder cannot + // move them. Pinned rows drag within the pinned block: the browser gates + // their drop targets to fellow pinned rows. + const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) const titleRef = useRef(null) @@ -456,19 +499,32 @@ export function SessionNodeItem({ }, [onReveal]) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no - // confirmation dialog. - const sessionMenuItems = [ - { id: 'rename', label: t('rename'), icon: }, - { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, - ] + // confirmation dialog. Archived rows swap pin/archive for unarchive. + const sessionMenuItems = row.archived + ? [ + { id: 'rename', label: t('rename'), icon: }, + { id: 'fork', label: t('menu.fork'), icon: }, + // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). + { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, + ] + : [ + { + id: row.pinned ? 'unpin' : 'pin', + label: t(row.pinned ? 'menu.unpinSession' : 'menu.pinSession'), + icon: row.pinned ? : , + }, + { id: 'rename', label: t('rename'), icon: }, + { id: 'fork', label: t('menu.fork'), icon: }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, + ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = (
{ revealClippedTitle(titleRef.current, true) }} onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} - onDragStart={drag === undefined || row.blank + onDragStart={!draggable ? undefined : (e) => { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', node.id) drag.start() }} - onDragEnd={drag === undefined || row.blank ? undefined : drag.end} + onDragEnd={!draggable ? undefined : drag.end} onDragOver={drag === undefined ? undefined : (e) => { @@ -503,13 +559,23 @@ export function SessionNodeItem({ > {/* Pending interaction and own or descendant activity outrank the finished-but-unviewed reminder, which returns after activity stops - and is cleared by opening the session. */} + and is cleared by opening the session. Archived rows keep the slot + blank — the grayed row carries the archived look — and their live + status stays on the hover card only. */} {(!flat || showStatus) && ( - {showStatus && } + {!row.archived && showStatus && } )} - {title} + { e.stopPropagation(); onRename(node.id, row.title) }} + > + {title} + {row.hasActiveSchedule && } {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs @@ -523,6 +589,9 @@ export function SessionNodeItem({ {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} )} + {/* Trails the time so the marker occupies the same right-edge cell as + the hover pin button that replaces it. */} + {row.pinned && !row.archived && } {!row.blank && ( { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> + {/* No tooltips on the hover actions: the row's hover card sits right + beside these buttons and the two bubbles collide; the actions + speak through their aria-labels and the resulting toasts. */} + + {/* Rightmost so it lands where the rest-state pin marker sits. */} + {!row.archived && ( + + )} )}
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 91ad5eb779..87acb02c9a 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -368,16 +368,64 @@ margin-top: 2px; } -.searchStatus, -.searchWarning { +.searchStatus { padding: 10px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } -.searchWarning { - color: var(--dsw-alias-label-secondary); +/* Pending content search: skeleton rows on searchResultRow metrics — a round + leading glyph beside a title bar and a wider snippet bar, breathing through + opacity (same pulse as the input-trigger menu skeleton). */ +.skeletonRow { + display: flex; + /* Top alignment lines the 16px dot up with the equally tall title bar; + the paddings center the 35px content in the 48px row. */ + align-items: flex-start; + gap: 8px; + box-sizing: border-box; + min-height: 48px; + padding: 6px 8px 7px; +} + +.skeletonDot { + flex: none; + width: 16px; + height: 16px; + border-radius: 50%; + corner-shape: round; +} + +.skeletonBars { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.skeletonDot, +.skeletonBar { + background: var(--dsw-alias-bg-skeleton); + animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +.skeletonBar { + width: 65%; + height: 16px; + border-radius: 4px; +} + +.skeletonBarWide { + width: 90%; + height: 13px; +} + +@keyframes search-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } } /* One workspace section: header row + a compact expanded session run. */ @@ -502,11 +550,56 @@ color: var(--dsw-alias-label-secondary); } +/* Post-archive callout bubble (tooltip visual): centered under the + view-options button, sliding inside the viewport when needed while the + arrow stays on the anchor. */ +.archiveHint { + position: fixed; + z-index: 100; + width: max-content; + max-width: 50vw; + padding: 3px 7px; + border-radius: 8px; + background: var(--dsw-alias-tooltip-bg); + color: var(--dsw-static-neutral-bluish-00); + font-size: 13px; + line-height: 20px; + transform: translateX(-50%); + pointer-events: none; + animation: archive-hint-in 150ms var(--ds-ease-in-out); +} + +/* Upward triangle riding the bubble's top edge; its inline `left` tracks the + anchor center when the bubble itself has been slid off-center. */ +.archiveHintArrow { + position: absolute; + top: -5px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid var(--dsw-alias-tooltip-bg); +} + +@keyframes archive-hint-in { + from { opacity: 0; } +} + @media (prefers-reduced-motion: reduce) { .wide { animation: none; } + .archiveHint { + animation: none; + } + + .skeletonDot, + .skeletonBar { + animation: none; + } + .search, .sectionLabel, .searchSlot, diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 784391e45d..9863762645 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -9,11 +9,14 @@ * menu in between; the flow and its error dialog live in WorkspacePicker * (same package — direct composition, no slot between them). */ -import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' +import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' import { - Button, IconCloseFillRegular, IconPersonalizationOutlineRegular, - IconProjectAddOutlineRegular, IconSearchOutlineRegular, Menu, Modal, Tooltip, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, + IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, + IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, + IconWarningOutlineRegular, IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSearchResultItem, @@ -21,7 +24,7 @@ import type { import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceBrowserProps } from '../contract/slots.ts' -import type { GroupNode, SessionNode, SessionOrderBy } from '../tree.ts' +import type { ArchivedFilter, GroupNode, SessionNode, SessionOrderBy, SessionRowState } from '../tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder, pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds, @@ -96,33 +99,168 @@ function useNativeDragAcceptance(active: boolean): void { }, [active]) } -/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ -function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { +/** + * First-interaction gate for the row animations: a reload streams the initial + * workspace/session data into freshly mounted containers, and auto-animate + * would play that whole arrival burst as entry animations. Controllers attach + * disabled and arm on the first pointer or keyboard input — every pin, + * archive, or reorder a user can trigger happens after that gate opens. The + * window guard keeps the module loadable where no window exists (node-side + * imports of the plugin entry). + */ +let rowAnimationsArmed = false +const disarmedRowControllers = new Set() +function armRowAnimations(): void { + rowAnimationsArmed = true + window.removeEventListener('pointerdown', armRowAnimations, true) + window.removeEventListener('keydown', armRowAnimations, true) + for (const controller of disarmedRowControllers) controller.enable() + disarmedRowControllers.clear() +} +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', armRowAnimations, true) + window.addEventListener('keydown', armRowAnimations, true) +} + +/** Fade window for rows that appear or disappear; shorter than the glide so the two motions read apart. */ +const ROW_FADE_MS = 100 +/** Glide window for rows that move to a new position. */ +const ROW_GLIDE_MS = 200 + +/** + * One-pass mute for whole-list layout changes: expanding or collapsing a + * group's hidden sessions, committing a drag, and switching the grouping, + * ordering, or archived filter reposition rows wholesale, so those passes + * apply instantly and animation stays reserved for pin jumps and archive + * fades. The DOM mutation auto-animate observes lands before the next frame + * renders, so the flag lifts there. + */ +let rowAnimationsMuted = false +export function muteNextRowAnimations(): void { + rowAnimationsMuted = true + requestAnimationFrame(() => { rowAnimationsMuted = false }) +} + +/** + * Auto-animate keyframes for the row containers: appearing and disappearing + * rows fade over {@link ROW_FADE_MS} while surviving rows glide to their new + * position over the longer {@link ROW_GLIDE_MS}. A muted pass + * ({@link muteNextRowAnimations}) settles every element immediately. + * @param el - the row element entering, leaving, or moving. + * @param action - what happened to the element in this layout pass. + * @param oldCoords - the element's box before the pass (moves only). + * @param newCoords - the element's box after the pass (moves only). + * @returns the keyframe effect auto-animate plays for the element. + */ +export const rowTransition: AutoAnimationPlugin = (el, action, oldCoords, newCoords) => { + if (rowAnimationsMuted) return new KeyframeEffect(el, [], { duration: 0 }) + if (action === 'add' || action === 'remove') { + return new KeyframeEffect(el, [ + { opacity: action === 'add' ? 0 : 1 }, + { opacity: action === 'add' ? 1 : 0 }, + ], { duration: ROW_FADE_MS, easing: 'ease-out' }) + } + const dx = (oldCoords?.left ?? 0) - (newCoords?.left ?? 0) + const dy = (oldCoords?.top ?? 0) - (newCoords?.top ?? 0) + return new KeyframeEffect(el, [ + { transform: `translate(${String(dx)}px, ${String(dy)}px)` }, + { transform: 'translate(0, 0)' }, + ], { duration: ROW_GLIDE_MS, easing: 'ease-out' }) +} + +/** + * Keyed auto-animate attachment for the row containers: rows glide to their + * new position when a pin reorders them and fade in or out when the archived + * filter, an archive, or an unarchive adds or removes them. Each key names one + * container; the factory hands out identity-stable callback refs so React + * attaches once per mounted element, and detach destroys the controller + * (auto-animate retains attached parents in a module-level strong set, so an + * undisposed controller would keep unmounted subtrees reachable across sidebar + * remounts). The library self-disables without ResizeObserver (jsdom) and + * under prefers-reduced-motion. + * @returns a factory yielding the stable callback ref for one container key. + */ +function useRowAnimator(): (key: string) => (el: HTMLElement | null) => void { + const controllers = useRef(new Map()) + const refs = useRef(new Map void>()) + return useCallback((key: string) => { + const existing = refs.current.get(key) + if (existing !== undefined) return existing + const ref = (el: HTMLElement | null): void => { + const previous = controllers.current.get(key) + if (previous !== undefined) { + previous.destroy?.() + disarmedRowControllers.delete(previous) + controllers.current.delete(key) + } + if (el === null) return + const controller = autoAnimate(el, rowTransition) + if (!rowAnimationsArmed) { + controller.disable() + disarmedRowControllers.add(controller) + } + controllers.current.set(key, controller) + } + refs.current.set(key, ref) + return ref + }, []) +} + +/** Grouping, ordering, and archived-filter menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, anchorRef, t }: { groupBy: SessionGroupBy orderBy: SessionOrderBy + archivedFilter: ArchivedFilter onGroupPick: (mode: SessionGroupBy) => void onOrderPick: (mode: SessionOrderBy) => void + onArchivedFilterPick: (filter: ArchivedFilter) => void + /** Each bump above zero opens the menu (the archive toast's filter action). */ + openSeq?: number + /** Receives the trigger button element, so the archive hint can anchor under it. */ + anchorRef?: ((el: HTMLButtonElement | null) => void) | undefined t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + // Seeded with the mount-time value: a chrome remount (e.g. window resize + // through the rail breakpoint) must not replay an old toast-action bump. + const seenOpenSeq = useRef(openSeq ?? 0) + useEffect(() => { + if (openSeq !== undefined && openSeq > seenOpenSeq.current) { + seenOpenSeq.current = openSeq + setOpen(true) + } + }, [openSeq]) return ( { setOpen(false) }} items={[ { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, - { id: 'workspace', label: t('groupBy.workspace') }, - { id: 'workspace-tree', label: t('groupBy.workspaceTree') }, - { id: 'flat', label: t('groupBy.flat') }, + { id: 'workspace', label: t('groupBy.workspace'), icon: }, + { id: 'workspace-tree', label: t('groupBy.workspaceTree'), icon: }, + { id: 'flat', label: t('groupBy.flat'), icon: }, { type: 'separator' as const, id: 'order-by-separator' }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, - { id: 'manual', label: t('orderBy.manual') }, - { id: 'updated', label: t('orderBy.updated') }, + { id: 'manual', label: t('orderBy.manual'), icon: }, + { id: 'updated', label: t('orderBy.updated'), icon: }, + { type: 'separator' as const, id: 'archived-filter-separator' }, + { type: 'label' as const, id: 'filter-by', text: t('filterBy.label') }, + { id: 'show-archived', label: t('viewOptions.showArchived'), icon: }, + { id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: }, + ]} + selectedIds={[ + groupBy, + orderBy, + ...archivedFilter === 'show' ? ['show-archived'] : [], + ...archivedFilter === 'only' ? ['only-archived'] : [], ]} - selectedIds={[groupBy, orderBy]} onSelect={(id) => { if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id) else if (id === 'manual' || id === 'updated') onOrderPick(id) + // The two archived items are mutually exclusive; re-picking the + // selected one returns to the default hide-archived view. + else if (id === 'show-archived') onArchivedFilterPick(archivedFilter === 'show' ? 'default' : 'show') + else if (id === 'only-archived') onArchivedFilterPick(archivedFilter === 'only' ? 'default' : 'only') setOpen(false) }} align="end" @@ -133,12 +271,13 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { anchor={( )} @@ -146,11 +285,87 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { ) } +/** Hold before the archive callout dismisses itself. */ +const ARCHIVE_HINT_HOLD_MS = 3000 +/** Hold for the actionable post-archive toast: two buttons need a longer read-and-react window than a plain notice. */ +const ARCHIVE_TOAST_HOLD_MS = 6000 +/** Minimum gap between the archive callout and the viewport edges. */ +const ARCHIVE_HINT_EDGE_MARGIN = 12 + +/** + * Post-archive callout bubble under the view-options button, naming the filter + * that reveals archived rows. Fixed positioning escapes the section header's + * overflow clipping without a portal (the Tooltip primitive's approach). + * The bubble centers on the anchor but slides back inside the viewport when + * the edges refuse it; the arrow stays on the anchor center either way. + * Dismissed by its hold timer or any pointer press on the page. + */ +function ArchiveFilterHint({ anchor, text, onDone }: { + anchor: HTMLElement | null + text: string + onDone: () => void +}) { + useEffect(() => { + const timer = setTimeout(onDone, ARCHIVE_HINT_HOLD_MS) + // Capture phase: a press anywhere dismisses, even when the target stops + // propagation (menus, rows). + const dismiss = (): void => { onDone() } + document.addEventListener('pointerdown', dismiss, true) + return () => { + clearTimeout(timer) + document.removeEventListener('pointerdown', dismiss, true) + } + }, [onDone]) + const bubble = useRef(null) + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // Horizontal slide applied to keep the bubble inside the viewport; the + // arrow compensates by the same amount to keep pointing at the anchor. + const [dx, setDx] = useState(0) + useLayoutEffect(() => { + if (anchor === null) return + const measure = (): void => { + const rect = anchor.getBoundingClientRect() + setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 }) + } + measure() + window.addEventListener('resize', measure) + return () => { window.removeEventListener('resize', measure) } + }, [anchor]) + useLayoutEffect(() => { + const el = bubble.current + if (el === null || pos === null) return + // Reset first so a larger viewport releases a previous slide. + el.style.left = `${String(pos.x)}px` + const r = el.getBoundingClientRect() + let slide = 0 + if (r.right > window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN) { + slide = window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN - r.right + } + if (r.left + slide < ARCHIVE_HINT_EDGE_MARGIN) slide = ARCHIVE_HINT_EDGE_MARGIN - r.left + el.style.left = `${String(pos.x + slide)}px` + setDx(slide) + }, [pos]) + if (pos === null) return null + return ( + + + {text} + + ) +} + /** In-flight root-row drag: source identity plus the current insert marker. */ interface DragState { /** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */ accountKey: string sessionId: SessionNode['id'] + /** Source row was pinned at drag start; drop targets share the pinned block. */ + pinned: boolean /** Row the marker sits on and which half (insert above/below it). */ over: { id: SessionNode['id']; half: 'before' | 'after' } | null } @@ -190,8 +405,8 @@ type SessionTreeProps = Pick< setGroupExpanded: (key: string, expanded: boolean) => void /** Save a drag order and select Manual. */ setSessionOrder: (accountKey: string, order: readonly string[]) => void - /** Registry-global archive set (hidden rows). */ - archivedSessionIds: readonly SessionNode['id'][] + /** Registry-global pin and archive sets plus the archived-visibility choice. */ + rowState: SessionRowState /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ @@ -200,6 +415,10 @@ type SessionTreeProps = Pick< onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void /** Archive a session (row menu action; the row disappears on the state echo). */ onSessionArchive: (sessionId: SessionNode['id']) => void + /** Unarchive a session (row menu action on archived rows). */ + onSessionUnarchive: (sessionId: SessionNode['id']) => void + /** Pin or unpin a session (row menu action; `pin` false unpins). */ + onSessionPin: (sessionId: SessionNode['id'], pin: boolean) => void /** One Session chosen from search that must be exposed and scrolled into view. */ revealSessionId?: SessionId | undefined /** Acknowledge that the chosen Session row has been revealed. */ @@ -209,9 +428,9 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ list, useSessionStatus, startSession, open, forkSession, workspaces, ungroupedSessionIds, - archivedSessionIds, + rowState, workspaceReady, usePanelInfo, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionPin, insertWorkspaceBefore, nestWorkspaces, groupExpansion, setGroupExpanded, setSessionOrder, home, t, @@ -226,6 +445,7 @@ function SessionTree({ ? undefined : owningGroupKey(workspaces, revealSessionId) const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) + const animator = useRowAnimator() // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -262,11 +482,11 @@ function SessionTree({ .filter(key => groupExpansion[key] ?? ancestorKeys.has(key)) }, [groupExpansion, parents, workspaces]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, statuses, { + () => deriveGroups(list, workspaces, rowState, statuses, { expandedGroups, ungroupedOrder: ungroupedSessionIds, }), - [list, workspaces, archivedSessionIds, statuses, expandedGroups, ungroupedSessionIds], + [list, workspaces, rowState, statuses, expandedGroups, ungroupedSessionIds], ) useEffect(() => { for (let key = revealGroup; key !== undefined; key = parents.get(key)) { @@ -280,30 +500,52 @@ function SessionTree({ const group = groups.find(candidate => candidate.key === revealGroup) if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return + muteNextRowAnimations() setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup]) }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return sessionDropCommitted.current = true + muteNextRowAnimations() setDrag(null) const group = groups.find(candidate => candidate.key === activeDrag.accountKey) if (group === undefined) return + if (over.id === activeDrag.sessionId) return + const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY + ? ungroupedSessionIds + : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds + if (accountSessionIds === undefined || !accountSessionIds.includes(activeDrag.sessionId)) return + const currentBlank = group.sessions.find(node => node.blank)?.id + if (activeDrag.pinned) { + // Pinned rows render as one leading block in group order; reorder that + // sequence and write it back into the pinned rows' account slots, so + // every other member (hidden ones included) keeps its position. + const pinnedSeq = group.sessions.filter(node => node.pinned).map(node => node.id) + const nextSeq = pinnedSeq.filter(id => id !== activeDrag.sessionId) + const at = nextSeq.indexOf(over.id) + // Pin state can move under an in-flight drag (a Host pin event lands). + if (at === -1 || !pinnedSeq.includes(activeDrag.sessionId)) return + nextSeq.splice(over.half === 'before' ? at : at + 1, 0, activeDrag.sessionId) + if (nextSeq.every((id, index) => id === pinnedSeq[index])) return + const pinnedSlots = new Set(pinnedSeq) + let cursor = 0 + // The cast is sound: nextSeq is a permutation of the pinned slots. + const nextOrder = accountSessionIds.map(id => + pinnedSlots.has(id) ? nextSeq[cursor++] as SessionId : id) + setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank)) + return + } const sessionsExpanded = expandedSessionGroups.includes(group.key) const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows const targetIndex = renderedSessions.findIndex(session => session.id === over.id) if (targetIndex === -1) return const sourceIndex = renderedSessions.findIndex(session => session.id === activeDrag.sessionId) - if (over.id === activeDrag.sessionId) return const withoutSource = renderedSessions.filter(session => session.id !== activeDrag.sessionId) const targetWithoutSourceIndex = withoutSource.findIndex(session => session.id === over.id) if (targetWithoutSourceIndex === -1) return const visibleInsertAt = over.half === 'before' ? targetWithoutSourceIndex : targetWithoutSourceIndex + 1 if (sourceIndex !== -1 && visibleInsertAt === sourceIndex) return - const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY - ? ungroupedSessionIds - : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds - if (accountSessionIds === undefined || !accountSessionIds.includes(activeDrag.sessionId)) return const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId) let anchor: SessionId | undefined if (sessionsExpanded) { @@ -329,7 +571,6 @@ function SessionTree({ }) if (!collapsedSessionRows(nextGroup).rows.some(node => node.id === activeDrag.sessionId)) return } - const currentBlank = group.sessions.find(node => node.blank)?.id setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank)) } const commitWorkspaceDrag = ( @@ -338,6 +579,7 @@ function SessionTree({ ): void => { if (workspaceDropCommitted.current) return workspaceDropCommitted.current = true + muteNextRowAnimations() setWorkspaceDrag(null) const owner = parents.get(activeDrag.workspaceId) const siblings = workspaces.filter(workspace => parents.get(workspace.workspaceId) === owner) @@ -410,6 +652,7 @@ function SessionTree({ // (WorkspaceBrowser.module.css).
{group.expanded && children.length > 0 && ( -
+
{children.map(child => renderGroup(child, depth + 1))}
)} @@ -484,16 +727,18 @@ function SessionTree({ ? group.sessions : collapsed.rows ).map((node) => { - // Session drag never leaves its browser-local account. + // Session drag never leaves its browser-local account, and pinned + // rows reorder only within their leading pinned block. const sameGroupDrag = drag !== null && drag.accountKey === group.key + const compatibleTarget = sameGroupDrag && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' => node.blank ? 'after' : half const dragProps = { start: () => { sessionDropCommitted.current = false - setDrag({ accountKey: group.key, sessionId: node.id, over: null }) + setDrag({ accountKey: group.key, sessionId: node.id, pinned: node.pinned, over: null }) }, - active: sameGroupDrag, + active: compatibleTarget, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ @@ -522,6 +767,8 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onUnarchive={onSessionUnarchive} + onPin={onSessionPin} onReveal={node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined} @@ -535,7 +782,7 @@ function SessionTree({ type="button" className={css.sessionOverflowButton} aria-expanded={sessionsExpanded} - onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }} + onClick={() => { muteNextRowAnimations(); setExpandedSessionGroups(keys => toggled(keys, group.key)) }} > {sessionsExpanded ? t('sessions.collapse') @@ -553,6 +800,7 @@ function SessionTree({ className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)} role="tree" aria-label={t('section.sessions')} + ref={animator('__list__')} > {groups.length === 0 && (
{t('empty.none')}
@@ -566,7 +814,8 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - list, sessionIds, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive, + list, sessionIds, rowState, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive, + onSessionUnarchive, onSessionPin, usePanelInfo, setSessionOrder, revealSessionId, onSessionRevealed, t, }: Pick< @@ -576,10 +825,13 @@ function FlatList({ | 'forkSession' | 'onSessionRename' | 'onSessionArchive' + | 'onSessionUnarchive' + | 'onSessionPin' | 'usePanelInfo' | 'setSessionOrder' | 'revealSessionId' | 'onSessionRevealed' + | 'rowState' | 't' > & { list: SessionListState @@ -588,8 +840,8 @@ function FlatList({ const panelActive = usePanelInfo(info => info.activePanelId !== null) const statuses = useSessionStatus(s => s) const rows = useMemo( - () => deriveFlat(list, sessionIds, statuses), - [list, sessionIds, statuses], + () => deriveFlat(list, sessionIds, rowState, statuses), + [list, sessionIds, rowState, statuses], ) const [drag, setDrag] = useState(null) const dropCommitted = useRef(false) @@ -615,14 +867,17 @@ function FlatList({ setSessionOrder(FLAT_SESSION_ORDER_KEY, pinCurrentBlank(nextOrder, currentBlank)) } const now = Date.now() + const animator = useRowAnimator() return (
-
+
{rows.length === 0 && (
{t('empty.none')}
)} {rows.map((node) => { - const active = drag !== null + // Pinned rows reorder only within their leading pinned block; the + // wholesale rendered-order write then keeps that block contiguous. + const active = drag !== null && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' => node.blank ? 'after' : half return ( @@ -635,6 +890,8 @@ function FlatList({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onUnarchive={onSessionUnarchive} + onPin={onSessionPin} onReveal={node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined} @@ -642,7 +899,7 @@ function FlatList({ drag={{ start: () => { dropCommitted.current = false - setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null }) + setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, pinned: node.pinned, over: null }) }, active, marker: active && drag.over?.id === node.id ? drag.over.half : null, @@ -682,8 +939,10 @@ function SearchResults({ useSessions, useSessionStatus, open, + onUnarchive, workspaces, archivedSessionIds, + archivedFilter, query, remote, resultLimit, @@ -692,6 +951,10 @@ function SearchResults({ }: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] + /** Search matches follow the archived filter selected for the list. */ + archivedFilter: ArchivedFilter + /** Unarchive an archived result row in place. */ + onUnarchive: (id: SessionNode['id']) => void query: string remote: RemoteSearchState resultLimit: number @@ -708,11 +971,12 @@ function SearchResults({ workspaces, query, archivedSessionIds, + archivedFilter, statuses, currentRemote, resultLimit, ), - [list, workspaces, query, archivedSessionIds, statuses, currentRemote, resultLimit], + [list, workspaces, query, archivedSessionIds, archivedFilter, statuses, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -730,16 +994,24 @@ function SearchResults({ result={result} currentId={currentId} onOpen={open} + onUnarchive={onUnarchive} t={t} /> ))}
{pending && ( -
{t('search.pending')}
- )} - {failed && ( -
- {t('search.unavailable')} + /* Two skeleton rows on an empty list, one when local matches already + show and only the content hits are outstanding. */ +
+ {(results.items.length === 0 ? [0, 1] : [0]).map(i => ( + + ))}
)} {!pending && results.items.length === 0 && ( @@ -778,6 +1050,9 @@ export function WorkspaceBrowser({ deleteWorkspace, insertWorkspaceBefore, archiveSession, + unarchiveSession, + pinSession, + unpinSession, createWorkspace, searchSessions, searchResultLimit, @@ -793,13 +1068,43 @@ export function WorkspaceBrowser({ const workspacePhase = useWorkspaces(state => state.phase) const workspaceStreamState = useWorkspaces(state => state.state) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) + const pinnedSessions = useWorkspaces(state => state.pinnedSessions) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) const orderBy = useStore(s => s.orderBy) + // Persisted view blobs written before the archived filter existed rehydrate + // without the field; they read as the default hide-archived view. + const archivedFilter = useStore(s => s.archivedFilter ?? 'default') const groupExpansion = useStore(s => s.groupExpansion) const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount) + const archiveHintSeen = useStore(s => s.archiveHintSeen) + // Archive callout under the view-options button; the seq keys remounts so + // consecutive archives restart the hold. + const viewOptionsButton = useRef(null) + const [archiveHintSeq, setArchiveHintSeq] = useState(0) + // Each bump opens the view-options menu (the archive toast's filter action). + const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0) + // One transient banner at a time; the seq keys remounts so a repeat message restarts its hold. + const [toast, setToast] = useState<{ + text: string + seq: number + tone: 'warning' | 'success' + actions?: readonly { label: string; onClick: () => void }[] + } | null>(null) + const showToast = (text: string): void => { + setToast(current => ({ text, seq: (current?.seq ?? 0) + 1, tone: 'warning' })) + } + // Archived sessions are not openable: the row stays visible under the + // filter but a click explains instead of navigating. + const guardedOpen = (sessionId: SessionId): void => { + if (archivedSessionIds.includes(sessionId)) { + showToast(t('toast.archivedNotOpenable')) + return + } + open(sessionId) + } const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading' const mainSessionId = Object.values(list.byId) .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id @@ -810,14 +1115,24 @@ export function WorkspaceBrowser({ const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds)) return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id)) }, [list, workspaces]) + const rowState = useMemo( + () => ({ pinnedSessions, archivedSessionIds, archivedFilter }), + [archivedSessionIds, pinnedSessions, archivedFilter], + ) + // Pinned rows rank by the later of their pin instant and update recency, so + // a newly pinned or newly updated pinned row leads the pinned block. + const pinnedAtBySession = useMemo( + () => new Map(pinnedSessions.map(entry => [entry.sessionId, entry.pinnedAt])), + [pinnedSessions], + ) const flatMemberIds = useMemo( - () => visibleSessionIds(list, archivedSessionIds), - [archivedSessionIds, list], + () => visibleSessionIds(list, archivedSessionIds, archivedFilter), + [archivedSessionIds, list, archivedFilter], ) const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => { const memberIds = workspace.sessionIds const baseOrder = orderBy === 'updated' - ? orderByRecency(memberIds, list.byId) + ? orderByRecency(memberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId) return { ...workspace, @@ -826,25 +1141,25 @@ export function WorkspaceBrowser({ currentBlank !== undefined && memberIds.includes(currentBlank) ? currentBlank : undefined, ), } - }), [currentBlank, list.byId, orderBy, sessionOrderByAccount, workspaces]) + }), [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, workspaces]) const orderedUngroupedSessionIds = useMemo(() => { const baseOrder = orderBy === 'updated' - ? orderByRecency(ungroupedMemberIds, list.byId) + ? orderByRecency(ungroupedMemberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(ungroupedMemberIds, sessionOrderByAccount[UNGROUPED_KEY], list.byId) return pinCurrentBlank( baseOrder, currentBlank !== undefined && ungroupedMemberIds.includes(currentBlank) ? currentBlank : undefined, ) - }, [currentBlank, list.byId, orderBy, sessionOrderByAccount, ungroupedMemberIds]) + }, [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, ungroupedMemberIds]) const orderedFlatSessionIds = useMemo(() => { const baseOrder = orderBy === 'updated' - ? orderByRecency(flatMemberIds, list.byId) + ? orderByRecency(flatMemberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId) return pinCurrentBlank( baseOrder, currentBlank !== undefined && flatMemberIds.includes(currentBlank) ? currentBlank : undefined, ) - }, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount]) + }, [currentBlank, flatMemberIds, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount]) const activeSessionOrders = useMemo>>(() => Object.fromEntries([ ...orderedWorkspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const), [UNGROUPED_KEY, orderedUngroupedSessionIds] as const, @@ -919,6 +1234,10 @@ export function WorkspaceBrowser({ const composingRef = useRef(false) const openSearchResult = (sessionId: SessionId): void => { + if (archivedSessionIds.includes(sessionId)) { + showToast(t('toast.archivedNotOpenable')) + return + } setRevealSessionId(sessionId) setQuery('') setSearchExpanded(false) @@ -1069,10 +1388,55 @@ export function WorkspaceBrowser({ // archive-set echo lands. Failures are non-fatal console diagnostics, the // same posture as reorder rejections. const onSessionArchive = (sessionId: SessionNode['id']) => { - archiveSession(sessionId).catch((reason: unknown) => { + archiveSession(sessionId).then(() => { + // The first archive teaches the filter with the anchored callout; every + // later archive confirms with an actionable toast instead (undo the + // archive, or open the view-options menu on the filter items). + if (!archiveHintSeen) { + actions.markArchiveHintSeen() + setArchiveHintSeq(seq => seq + 1) + return + } + setToast(current => ({ + text: t('toast.archived'), + seq: (current?.seq ?? 0) + 1, + tone: 'success', + actions: [ + { label: t('toast.archivedUndo'), onClick: () => { setToast(null); onSessionUnarchive(sessionId) } }, + { + prefix: t('toast.archivedOr'), + label: t('toast.archivedFilter'), + onClick: () => { setToast(null); setViewOptionsOpenSeq(seq => seq + 1) }, + }, + ], + })) + }).catch((reason: unknown) => { console.warn('session archive rejected:', reason) }) } + const onSessionUnarchive = (sessionId: SessionNode['id']) => { + unarchiveSession(sessionId).catch((reason: unknown) => { + console.warn('session unarchive rejected:', reason) + }) + } + // Pin failures surface as a toast: unlike archive, nothing else on the + // surface moves, so a silent failure would read as a dead menu action. + const onSessionPin = (sessionId: SessionNode['id'], pin: boolean) => { + (pin ? pinSession(sessionId) : unpinSession(sessionId)).then(() => { + // In manual mode a fresh pin also fronts the row's manual slot, so the + // pinned block starts in pin order; drags within the block still adjust + // it. The slot is not restored on unpin: the row stays at the top. + if (!pin || orderBy !== 'manual') return + actions.syncSessionOrders(Object.fromEntries( + [owningGroupKey(workspaces, sessionId), FLAT_SESSION_ORDER_KEY].map((key) => { + const saved = sessionOrderByAccount[key] ?? [] + return [key, [sessionId, ...saved.filter(id => id !== sessionId)]] + }), + )) + }).catch(() => { + showToast(t(pin ? 'toast.pinFailed' : 'toast.unpinFailed')) + }) + } // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. @@ -1179,8 +1543,12 @@ export function WorkspaceBrowser({ { actions.setGroupBy(mode) }} - onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }} + archivedFilter={archivedFilter} + onGroupPick={(mode) => { muteNextRowAnimations(); actions.setGroupBy(mode) }} + onOrderPick={(mode) => { muteNextRowAnimations(); actions.setOrderBy(mode, activeSessionOrders) }} + onArchivedFilterPick={(filter) => { muteNextRowAnimations(); actions.setArchivedFilter(filter) }} + openSeq={viewOptionsOpenSeq} + anchorRef={(el) => { viewOptionsButton.current = el }} t={t} /> )} @@ -1250,8 +1618,10 @@ export function WorkspaceBrowser({ useSessions={useSessions} useSessionStatus={useSessionStatus} open={openSearchResult} + onUnarchive={onSessionUnarchive} workspaces={workspaces} archivedSessionIds={archivedSessionIds} + archivedFilter={archivedFilter} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} @@ -1264,9 +1634,11 @@ export function WorkspaceBrowser({ usePanelInfo={usePanelInfo} list={list} sessionIds={orderedFlatSessionIds} + rowState={rowState} useSessionStatus={useSessionStatus} - open={open} forkSession={forkSession} + open={guardedOpen} forkSession={forkSession} onSessionRename={onSessionRename} onSessionArchive={onSessionArchive} + onSessionUnarchive={onSessionUnarchive} onSessionPin={onSessionPin} setSessionOrder={saveSessionOrder} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} @@ -1280,6 +1652,8 @@ export function WorkspaceBrowser({ useSessionStatus={useSessionStatus} onSessionRename={onSessionRename} onSessionArchive={onSessionArchive} + onSessionUnarchive={onSessionUnarchive} + onSessionPin={onSessionPin} forkSession={forkSession} workspaces={orderedWorkspaces} ungroupedSessionIds={orderedUngroupedSessionIds} @@ -1288,9 +1662,9 @@ export function WorkspaceBrowser({ groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} setSessionOrder={saveSessionOrder} - archivedSessionIds={archivedSessionIds} + rowState={rowState} startSession={startSession} - open={open} + open={guardedOpen} insertWorkspaceBefore={insertWorkspaceBefore} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} @@ -1400,6 +1774,25 @@ export function WorkspaceBrowser({ {deleting &&
{t('delete.pending')}
} {deleteError !== null &&
{deleteError}
} + {toast !== null && ( + }} + {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} + onDone={() => { setToast(null) }} + /> + )} + {archiveHintSeq > 0 && ( + { setArchiveHintSeq(0) }} + /> + )}
) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 10fa80239f..bd1ac63d87 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -6,6 +6,7 @@ * share from the return type. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ArchivedFilter } from './tree.ts' /** Browser-local order account for the hierarchy-free flat Session list. */ export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__' @@ -23,6 +24,14 @@ type WorkspaceViewState = { groupExpansion: Record /** Saved manual order per Workspace group plus the browser-local flat-list account. */ sessionOrderByAccount: Record + /** + * Archived-row visibility, kept across reloads like the other view options. + * Pre-filter v5 blobs lack the field; the browser reads it with a 'default' + * fallback at the rehydration boundary. + */ + archivedFilter: ArchivedFilter + /** The one-time hint pointing at the view menu after the first archive has been shown. */ + archiveHintSeen: boolean } /** @@ -48,6 +57,8 @@ type WorkspaceViewActions = { order: readonly string[], initialOrders: Readonly>, ) => void + setArchivedFilter: (draft: WorkspaceViewState, filter: ArchivedFilter) => void + markArchiveHintSeen: (draft: WorkspaceViewState) => void } /** Copy read-only projections into the persisted mutable store representation. */ @@ -68,6 +79,8 @@ export function createWorkspaceViewStore(): EngineStoreHandle { d.archivedFilter = filter }, + markArchiveHintSeen: (d) => { d.archiveHintSeen = true }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index f96157a83e..4dc4c8bbb0 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -6,12 +6,13 @@ import { type SessionListState, type SessionSearchResultItem, type SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot, } from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import { indexSubagentDescendants, type SubagentDescendantSummary, @@ -59,6 +60,10 @@ export interface SessionNode { completed: boolean /** The current list projection contains at least one active Schedule record. */ hasActiveSchedule: boolean + /** In the registry-global pin set: leads its section, reorderable only among pinned rows. */ + pinned: boolean + /** In the registry-global archive set: shown grayed in place and not openable. */ + archived: boolean updatedAt: number } @@ -98,6 +103,8 @@ export interface SearchResultNode { completed: boolean /** The current list projection contains at least one active Schedule record. */ hasActiveSchedule: boolean + /** In the registry-global archive set: shown grayed and not openable. */ + archived: boolean snippet?: string } @@ -139,18 +146,23 @@ export function workspaceLabel(cwd: string | undefined): string { * Project known account members by current Session recency. * @param sessionIds - authoritative account membership. * @param summaries - current Session summaries; members without a summary are omitted until it arrives. + * @param pinnedAt - pin instants by Session; a pinned row ranks by the later of its pin instant and update recency. * @returns known members newest first, with Session identity as the deterministic tie-break. */ export function orderByRecency( sessionIds: readonly SessionId[], summaries: SessionListState['byId'], + pinnedAt?: ReadonlyMap, ): SessionId[] { return sessionIds.flatMap((id) => { const summary = summaries[id] - return summary === undefined ? [] : [{ id, updatedAt: summary.updatedAt }] + if (summary === undefined) return [] + const pin = pinnedAt?.get(id) + const rank = pin === undefined ? summary.updatedAt : Math.max(pin, summary.updatedAt) + return [{ id, rank }] }) .sort((a, b) => { - if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt + if (a.rank !== b.rank) return b.rank - a.rank return a.id < b.id ? -1 : 1 }) .map(member => member.id) @@ -199,16 +211,69 @@ export function pinCurrentBlank( return [currentBlank, ...order.filter(id => id !== currentBlank)] } +/** + * Archived-row visibility choice: the default hides archived rows, `show` + * mixes them into their kept slots, and `only` restricts the view (and + * search) to archived rows. + */ +export type ArchivedFilter = 'default' | 'show' | 'only' + /** * Ordinary sessions are visible; among blank sessions, only the current one * is visible. Subagent children use their parent header catalog; archived - * sessions are visible nowhere, while their accounting slots remain so - * unarchiving restores position. + * sessions follow the archived filter, while their accounting slots remain + * either way so unarchiving restores position. */ -function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { - return session.origin !== 'subagent' - && !archived.has(session.id) - && (!session.blank || session.id === current) +function sessionVisible( + session: SessionSummary, + current: SessionId | undefined, + archived: ReadonlySet, + archivedFilter: ArchivedFilter, +): boolean { + if (session.origin === 'subagent') return false + if (session.blank && session.id !== current) return false + switch (archivedFilter) { + case 'default': + return !archived.has(session.id) + case 'show': + return true + case 'only': + return archived.has(session.id) + /* v8 ignore next 2 -- closed-union backstop; only reached if the filter is forged */ + default: + return assertNever(archivedFilter) + } +} + +/** Registry-global row state consumed by every tree derivation. */ +export interface SessionRowState { + /** Pin set with pin instants; pinned rows lead their section in the local order. */ + pinnedSessions: readonly WorkspacePinnedSession[] + /** Archive set; members keep their slots and show grayed while visible. */ + archivedSessionIds: readonly SessionId[] + /** Archived-row visibility choice applied to lists and search alike. */ + archivedFilter: ArchivedFilter +} + +/** + * Move pinned rows to the front without disturbing relative order on either + * side of the partition. Pinned rows keep the caller order among themselves — + * recency or the persisted manual order, so pinned-to-pinned drags hold — and + * archived rows keep their caller slots: the order never absorbs pin state, + * so unpinning restores the row's kept position. + */ +function sectionMembers( + members: readonly SessionSummary[], + pinned: ReadonlySet, + archived: ReadonlySet, +): SessionSummary[] { + const leading: SessionSummary[] = [] + const rest: SessionSummary[] = [] + for (const member of members) { + if (!archived.has(member.id) && pinned.has(member.id)) leading.push(member) + else rest.push(member) + } + return [...leading, ...rest] } /** @@ -264,6 +329,7 @@ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + archivedFilter: ArchivedFilter, ungroupedOrder: readonly string[] | undefined, ): Group[] { const current = mainSessionId(list) @@ -275,7 +341,7 @@ function groupByWorkspace( const summary = list.byId[id] if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands accounted.add(id) - if (!sessionVisible(summary, current, archived)) continue + if (!sessionVisible(summary, current, archived, archivedFilter)) continue members.push(summary) } groups.push(buildGroup( @@ -286,7 +352,7 @@ function groupByWorkspace( const stray = list.ids .map(id => list.byId[id]) .filter((s): s is SessionSummary => - s !== undefined && !accounted.has(s.id) && sessionVisible(s, current, archived)) + s !== undefined && !accounted.has(s.id) && sessionVisible(s, current, archived, archivedFilter)) if (stray.length > 0) { groups.push(buildGroup( UNGROUPED_KEY, @@ -316,6 +382,8 @@ function sessionNode( s: SessionSummary, descendants: ReadonlyMap, statuses: SessionStatuses, + pinned: ReadonlySet, + archived: ReadonlySet, ): SessionNode { const status = statuses.get(s.id) const pendingInteraction = visiblePendingKind(status?.pendingInteraction?.kind) @@ -327,6 +395,8 @@ function sessionNode( runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0, completed: status?.completionUnread === true, hasActiveSchedule: hasActiveSchedule(s), + pinned: !archived.has(s.id) && pinned.has(s.id), + archived: archived.has(s.id), updatedAt: s.updatedAt, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), } @@ -335,14 +405,14 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups in the selected - * local order. Blank sessions are excluded except for the selected - * provisional New Session row; archived sessions are excluded everywhere. - * Content search lives outside this derivation - * (see {@link deriveSearchResults}). + * Every group shows; sessions populate under expanded groups with pinned rows + * leading in the selected local order. Blank sessions are + * excluded except for the selected provisional New Session row; archived + * sessions keep their slots and appear per the archived filter. Content + * search lives outside this derivation (see {@link deriveSearchResults}). * @param list - sessions list snapshot (`mainView` retention feeds containsCurrent). * @param workspaces - real Workspaces in Host group order with caller-projected Session order. - * @param archivedSessionIds - registry-global archive set. + * @param rowState - registry-global pin and archive sets plus the archived filter. * @param statuses - unified UI status by Session. * @param view - local expansion arrays. * @returns group sections in render order. @@ -350,11 +420,12 @@ function sessionNode( export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], - archivedSessionIds: readonly SessionId[], + rowState: SessionRowState, statuses: SessionStatuses, view: TreeView, ): GroupNode[] { - const archived = new Set(archivedSessionIds) + const archived = new Set(rowState.archivedSessionIds) + const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId)) const expandedGroups = new Set(view.expandedGroups) const descendants = indexSubagentDescendants(list.byId) const current = mainSessionId(list) @@ -362,7 +433,7 @@ export function deriveGroups( ? undefined : owningGroupKey(workspaces, current) const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { + for (const g of groupByWorkspace(list, workspaces, archived, rowState.archivedFilter, view.ungroupedOrder)) { const expanded = expandedGroups.has(g.key) groups.push({ key: g.key, @@ -374,7 +445,8 @@ export function deriveGroups( expanded, containsCurrent: g.key === currentGroup, sessions: expanded - ? g.sessions.map(session => sessionNode(session, descendants, statuses)) + ? sectionMembers(g.sessions, pinned, archived) + .map(session => sessionNode(session, descendants, statuses, pinned, archived)) : [], }) } @@ -385,35 +457,42 @@ export function deriveGroups( * Select flat-list members without deriving row presentation or ordering. * @param list - sessions list snapshot. * @param archivedSessionIds - registry-global archive set. + * @param archivedFilter - archived-row visibility choice. * @returns known visible Session ids in list order, including ordinary forks and only the current blank. */ export function visibleSessionIds( list: SessionListState, archivedSessionIds: readonly SessionId[], + archivedFilter: ArchivedFilter, ): SessionId[] { const archived = new Set(archivedSessionIds) const current = mainSessionId(list) return list.ids.filter((id) => { const s = list.byId[id] - return s !== undefined && sessionVisible(s, current, archived) + return s !== undefined && sessionVisible(s, current, archived, archivedFilter) }) } /** - * Derive flat rows from the browser's ordered visible Session ids. + * Derive flat rows from the browser's ordered visible Session ids, with + * pinned rows fronted ahead of the supplied order. * @param list - sessions list snapshot used to select the ids. * @param sessionIds - known visible members in render order, including any pinned blank. + * @param rowState - registry-global pin and archive sets plus the archived filter. * @param statuses - unified UI status by Session. - * @returns flat rows in the supplied order with current status indicators. + * @returns flat rows in sectioned order with current status indicators. */ export function deriveFlat( list: SessionListState, sessionIds: readonly SessionId[], + rowState: SessionRowState, statuses: SessionStatuses, ): SessionNode[] { + const archived = new Set(rowState.archivedSessionIds) + const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId)) const descendants = indexSubagentDescendants(list.byId) - return sessionIds - .map(id => sessionNode(list.byId[id] as SessionSummary, descendants, statuses)) + return sectionMembers(sessionIds.map(id => list.byId[id] as SessionSummary), pinned, archived) + .map(session => sessionNode(session, descendants, statuses, pinned, archived)) } /** @@ -423,7 +502,8 @@ export function deriveFlat( * @param list - session metadata authority. * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. - * @param archivedSessionIds - registry-global archive set (members never match). + * @param archivedSessionIds - registry-global archive set (members match per the archived filter). + * @param archivedFilter - archived-row visibility choice; search follows it. * @param statuses - unified UI status by Session. * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. @@ -434,6 +514,7 @@ export function deriveSearchResults( workspaces: readonly WorkspaceView[], query: string, archivedSessionIds: readonly SessionId[], + archivedFilter: ArchivedFilter, statuses: SessionStatuses, content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, @@ -462,7 +543,7 @@ export function deriveSearchResults( const summary = list.byId[id] // Blank placeholders never match a query (their canonical title displays // localized, so matching it would tie search to one language). - if (summary === undefined || summary.blank || !sessionVisible(summary, current, archived)) continue + if (summary === undefined || summary.blank || !sessionVisible(summary, current, archived, archivedFilter)) continue if ( sessionTitle(summary).toLowerCase().includes(q) || labelOf(summary).toLowerCase().includes(q) @@ -484,7 +565,7 @@ export function deriveSearchResults( for (const summary of orderedLocal) include(summary) for (const item of content.items) { const summary = list.byId[item.sessionId] - if (summary !== undefined && !summary.blank && sessionVisible(summary, current, archived)) include(summary) + if (summary !== undefined && !summary.blank && sessionVisible(summary, current, archived, archivedFilter)) include(summary) } return { @@ -503,6 +584,7 @@ export function deriveSearchResults( : { pendingInteraction }), completed: status?.completionUnread === true, hasActiveSchedule: hasActiveSchedule(summary), + archived: archived.has(summary.id), ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 9e53d52b1b..1772e6be6d 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -48,7 +48,7 @@ async function bench() { ctx.provide('workspaces', { list: { getSnapshot: () => ({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }), subscribe, }, diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 57308820e2..e8b5439d95 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -62,15 +62,15 @@ describe('workspace browser rows', () => { it('omits only an empty leading status slot in the hierarchy-free flat list', () => { const idle: SessionNode = { id: sid('flat'), title: 'Flat Session', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const view = render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />) const title = screen.getByText('Flat Session') expect(title.previousElementSibling).toBeNull() view.rerender() + onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />) expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() }) @@ -84,9 +84,10 @@ describe('workspace browser rows', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, snippet: 'matching message excerpt', } - render() + render() const row = screen.getByRole('treeitem') expect(row.getAttribute('aria-selected')).toBe('true') expect(screen.getByText('Workspace context')).toBeTruthy() @@ -102,9 +103,9 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() const result: SearchResultNode = { id: sid('scheduled-result'), title: 'Scheduled result', workspace: 'Project', - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true, archived: false, } - render() + render() const row = screen.getByRole('treeitem') const title = screen.getByText('Scheduled result') @@ -126,9 +127,9 @@ describe('workspace browser rows', () => { const result: SearchResultNode = { id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', pendingInteraction, running: true, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, + hasActiveSchedule: false, archived: false, } - render() + render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() @@ -155,12 +156,12 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { id: sid('session'), title: 'Session', blank: false, running: true, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const onOpen = vi.fn() render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />, ) const row = screen.getByRole('treeitem') @@ -225,11 +226,11 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() const node: SessionNode = { id: sid('scheduled-session'), title: 'Scheduled Session', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0, pinned: false, archived: false, } const view = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />, ) const assertIndicator = (): HTMLElement => { @@ -248,7 +249,7 @@ describe('workspace browser rows', () => { view.rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />, ) assertIndicator() }) @@ -258,10 +259,10 @@ describe('workspace browser rows', () => { , ) const stateDot = (view: ReturnType) => @@ -290,10 +291,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: false, - runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.getByText('2 个子代理运行中')).toBeTruthy() @@ -312,10 +313,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: true, - runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1) expect(screen.getByText('进行中')).toBeTruthy() @@ -333,10 +334,10 @@ describe('workspace browser rows', () => { it('keeps child activity as a secondary status while user attention is primary', () => { const node: SessionNode = { id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question', - running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, + running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).not.toBeNull() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() @@ -348,9 +349,9 @@ describe('workspace browser rows', () => { render() expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull() }) @@ -480,10 +481,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s-blank'), title: 'ignored', blank: true, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) // The placeholder has no content yet: no row verbs, no "now" stamp. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() expect(screen.queryByText('刚刚')).toBeNull() @@ -507,10 +508,10 @@ describe('workspace browser rows', () => { const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={onRename} onFork={onFork} onArchive={onArchive} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) expect(onOpen).not.toHaveBeenCalled() // Archive is not destructive (log and accounting slot remain): no danger styling. @@ -541,10 +542,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Hovered', blank: false, running: true, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -573,10 +574,10 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, pendingInteraction, running: true, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, updatedAt: 0, + hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const view = render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') const title = screen.getByText('Needs input') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() @@ -587,7 +588,7 @@ describe('workspace browser rows', () => { expect(row.textContent).not.toContain('刚刚') view.rerender() + onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) @@ -623,10 +624,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Quiet', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('空闲').parentElement?.querySelector('[data-state="idle"]')).not.toBeNull() @@ -641,10 +642,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Done', blank: false, running: false, - runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) // Row's visually-hidden reminder label plus the hover card's status line. @@ -654,15 +655,41 @@ describe('workspace browser rows', () => { } }) + it('archived hover card replaces the resting status with the archived line but keeps live activity', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Stored', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: true, + } + const { rerender } = render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + // Row indicator label plus the hover card's archived line; the idle + // resting status would restate the same inactivity and stays out. + expect(screen.getAllByText('已归档')).toHaveLength(1) + expect(screen.queryByText('空闲')).toBeNull() + + // Live activity on an archived session still shows beside the line. + rerender() + expect(screen.getAllByText('进行中').length).toBeGreaterThan(0) + expect(screen.getAllByText('已归档')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { id: sid('s1'), title: 'Drag me', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const inactive = dragProps() const { rerender } = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={inactive} t={t} />, ) const row = screen.getByRole('treeitem') stubRect(row) @@ -680,7 +707,7 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={active} t={t} />, ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -694,8 +721,139 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={after} t={t} />, ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) + + it('marks a pinned row, keeps it draggable, and unpins through the row menu', () => { + const onPin = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Pinned', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: true, archived: false, + } + render() + const row = screen.getByRole('treeitem') + // Reorderable within the pinned block; the browser gates the targets. + expect(row.getAttribute('draggable')).toBe('true') + expect(screen.getByRole('img', { name: '已置顶' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '会话“Pinned”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + expect(onPin).toHaveBeenCalledWith(node.id, false) + }) + + it('pins an unpinned row through the row menu', () => { + const onPin = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Plain', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + render() + expect(screen.queryByRole('img', { name: '已置顶' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '会话“Plain”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(onPin).toHaveBeenCalledWith(node.id, true) + }) + + it('marks an archived row, blocks dragging it, and swaps its menu to rename/fork/unarchive', () => { + const onUnarchive = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Stored', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: true, + } + render() + const row = screen.getByRole('treeitem') + expect(row.className).toMatch(/archived/) + expect(row.getAttribute('draggable')).toBe('false') + // No leading marker: the grayed row alone carries the archived look. + expect(screen.queryByRole('img', { name: '已归档' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '会话“Stored”的操作' })) + // Pin and archive are replaced by unarchive; rename and fork remain. + expect(screen.queryByRole('menuitem', { name: '置顶会话' })).toBeNull() + expect(screen.queryByRole('menuitem', { name: '归档会话' })).toBeNull() + expect(screen.getByRole('menuitem', { name: '重命名' })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '分叉会话' })).toBeTruthy() + fireEvent.click(screen.getByRole('menuitem', { name: '取消归档' })) + expect(onUnarchive).toHaveBeenCalledWith(node.id) + }) + + it('double-clicking the title opens rename with the current title, but not on a blank row', () => { + const onRename = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Named', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.doubleClick(screen.getByText('Named')) + expect(onRename).toHaveBeenCalledWith(node.id, 'Named') + expect(onOpen).not.toHaveBeenCalled() + + // A blank placeholder has no content to rename: double-click is inert. + view.rerender() + fireEvent.doubleClick(screen.getByText('新会话')) + expect(onRename).toHaveBeenCalledOnce() + }) + + it('quick pin button toggles pin state without opening the session', () => { + const onPin = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Quick', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '置顶会话' })) + expect(onPin).toHaveBeenCalledWith(node.id, true) + + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '取消置顶' })) + expect(onPin).toHaveBeenCalledWith(node.id, false) + expect(onOpen).not.toHaveBeenCalled() + }) + + it('quick archive button archives a live row and unarchives an archived one, hiding quick pin there', () => { + const onArchive = vi.fn() + const onUnarchive = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Quick', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '归档会话' })) + expect(onArchive).toHaveBeenCalledWith(node.id) + + view.rerender() + // An archived row cannot be pinned: no quick pin button. + expect(screen.queryByRole('button', { name: '置顶会话' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '取消归档' })) + expect(onUnarchive).toHaveBeenCalledWith(node.id) + expect(onArchive).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() + }) + + it('flags an archived content-search result', () => { + const result: SearchResultNode = { + id: sid('result'), title: 'Old result', workspace: 'Workspace context', + running: false, runningSubagentCount: 0, completed: false, + hasActiveSchedule: false, archived: true, + } + render() + const row = screen.getByRole('treeitem') + expect(row.className).toMatch(/archived/) + // No leading marker: the grayed row alone carries the archived look. + expect(screen.queryByRole('img', { name: '已归档' })).toBeNull() + }) }) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 4ce41c80ac..c67d1b1c85 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -7,6 +7,7 @@ import type { import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { + type ArchivedFilter, deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder, pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY, } from '../src/client/tree.ts' @@ -45,6 +46,16 @@ const status = ( overrides: Partial = {}, ): SessionStatus => ({ running: undefined, pendingInteraction, completionUnread: false, ...overrides }) const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) +const rowState = (options: { + pinned?: readonly string[] + archived?: readonly string[] + archivedFilter?: ArchivedFilter +} = {}) => ({ + pinnedSessions: (options.pinned ?? []).map(id => ({ sessionId: sid(id), pinnedAt: 1 })), + archivedSessionIds: (options.archived ?? []).map(sid), + archivedFilter: options.archivedFilter ?? 'default' as const, +}) +const noRows = rowState() const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({ id: id as ScheduleId, kind: 'at', @@ -69,6 +80,16 @@ describe('Session ordering', () => { )).toEqual([sid('tie-a'), sid('tie-b'), sid('older')]) }) + it('ranks a pinned member by the later of its pin instant and update recency', () => { + const summaries = list(summary('stale-pin', 10), summary('fresh-pin', 30), summary('plain', 20)).byId + const pinnedAt = new Map([[sid('stale-pin'), 25], [sid('fresh-pin'), 5]]) + expect(orderByRecency( + [sid('stale-pin'), sid('fresh-pin'), sid('plain')], + summaries, + pinnedAt, + )).toEqual([sid('fresh-pin'), sid('stale-pin'), sid('plain')]) + }) + it('reconciles retained manual slots and appends newly known members by recency', () => { const summaries = list(summary('kept', 1), summary('newer', 30), summary('older', 20)).byId expect(reconcileManualOrder( @@ -92,7 +113,7 @@ describe('deriveGroups', () => { it('keeps caller-supplied Workspace and sessionIds order', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noRows, noAttention, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) @@ -105,10 +126,10 @@ describe('deriveGroups', () => { status({ key: 'question:1', kind: 'plan-review', sessionId: awaiting.id } as SessionPendingInteraction, { running: true }), ]]) const grouped = deriveGroups( - sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']), + sessions, [workspace('project', ['awaiting'])], noRows, attention, view(['project']), ) expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), attention)[0]) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, attention)[0]) .toMatchObject({ pendingInteraction: 'plan-review', running: true }) }) @@ -121,14 +142,14 @@ describe('deriveGroups', () => { status({ key: `${kind}:1`, kind, sessionId: awaiting.id } as SessionPendingInteraction), ]]) - expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind) + expect(deriveFlat(list(awaiting), [awaiting.id], noRows, attention)[0]?.pendingInteraction).toBe(kind) }, ) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups( - sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]), + sessions, [workspace('first', ['owned'])], noRows, noAttention, view([UNGROUPED_KEY]), ) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) @@ -139,7 +160,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( sessions, [], - noArchive, + noRows, noAttention, view([UNGROUPED_KEY], ['two', 'stale', 'two']), ) @@ -155,7 +176,7 @@ describe('deriveGroups', () => { const sessions = list(real, currentBlank, staleBlank) const groups = deriveGroups( sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], - noArchive, noAttention, view(['first']), + noRows, noAttention, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -168,7 +189,7 @@ describe('deriveGroups', () => { // A non-current blank stray never surfaces an Ungrouped bucket either. const strayGroups = deriveGroups( list({ ...summary('stray', 2), blank: true }), - [workspace('first', [])], noArchive, noAttention, view(), + [workspace('first', [])], noRows, noAttention, view(), ) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -179,16 +200,16 @@ describe('deriveGroups', () => { const sessions = list(done, plain) const statuses: SessionStatusSnapshot = new Map([[done.id, status(undefined, { completionUnread: true })]]) const groups = deriveGroups( - sessions, [workspace('first', ['done', 'plain'])], noArchive, statuses, view(['first']), + sessions, [workspace('first', ['done', 'plain'])], noRows, statuses, view(['first']), ) const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! expect(doneNode.completed).toBe(true) expect(plainNode.completed).toBe(false) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), statuses) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, statuses) .find(node => node.id === done.id)!.completed).toBe(true) const search = deriveSearchResults( - sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, + sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, 'default', statuses, { items: [], hasMore: false }, 10, ) expect(search.items[0]?.completed).toBe(true) @@ -215,12 +236,12 @@ describe('deriveGroups', () => { ] expect(deriveGroups( - sessions, workspaces, noArchive, noAttention, view(['project']), + sessions, workspaces, noRows, noAttention, view(['project']), )[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) .map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) expect(deriveSearchResults( - sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10, + sessions, workspaces, 'project', noArchive, 'default', noAttention, { items: [], hasMore: false }, 10, ).items.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) }) @@ -240,7 +261,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( sessions, [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])], - noArchive, + noRows, noAttention, view(['first']), ) @@ -249,12 +270,12 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(2) expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 }) expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 }) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) .map(node => [node.id, node.runningSubagentCount])).toEqual([ [parent.id, 2], [fork.id, 1], ]) expect(deriveSearchResults( - sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, + sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, 'default', noAttention, { items: [], hasMore: false }, 10, ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 }) }) @@ -272,7 +293,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], - noArchive, + noRows, noAttention, { expandedGroups: [UNGROUPED_KEY] }, ) @@ -285,7 +306,7 @@ describe('deriveGroups', () => { // Equal timestamps use ids as a deterministic tiebreak in either input order. expect(deriveGroups( - list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]), + list(summary('tie-a', 1), summary('tie-b', 1)), [], noRows, noAttention, view([UNGROUPED_KEY]), )[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -297,7 +318,7 @@ describe('deriveGroups', () => { byId: { [sid('present')]: summary('present', 1) }, } const groups = deriveGroups( - partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']), + partial, [workspace('project', ['missing', 'present'])], noRows, noAttention, view(['project']), ) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) @@ -308,7 +329,7 @@ describe('deriveGroups', () => { const looseGone = summary('loose-gone', 3, '/other') const sessions = list(kept, gone, looseGone) const groups = deriveGroups( - sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), + sessions, [workspace('first', ['kept', 'gone'])], rowState({ archived: ['gone', 'loose-gone'] }), noAttention, view(['first', UNGROUPED_KEY]), ) // The archived member drops from its group AND the archived stray never @@ -318,16 +339,47 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(1) }) + it('leads expanded groups with pinned rows in the supplied order', () => { + const sessions = list(summary('a', 4), summary('b', 3), summary('c', 2), summary('d', 1)) + const groups = deriveGroups( + sessions, [workspace('first', ['a', 'c', 'b', 'd'])], + rowState({ pinned: ['c', 'b'] }), + noAttention, view(['first']), + ) + // c precedes b per the supplied order even though b updated more recently: + // the pinned block never re-sorts, so manual pinned-to-pinned drags hold. + expect(groups[0]!.sessions.map(node => [node.id, node.pinned])).toEqual([ + [sid('c'), true], + [sid('b'), true], + [sid('a'), false], + [sid('d'), false], + ]) + }) + + it('keeps shown archived rows in place and never sections an archived pin', () => { + const sessions = list(summary('top', 3), summary('mid', 2), summary('low', 1)) + const groups = deriveGroups( + sessions, [workspace('first', ['top', 'mid', 'low'])], + rowState({ pinned: ['mid'], archived: ['mid'], archivedFilter: 'show' }), + noAttention, view(['first']), + ) + expect(groups[0]!.sessions.map(node => [node.id, node.pinned, node.archived])).toEqual([ + [sid('top'), false, false], + [sid('mid'), false, true], + [sid('low'), false, false], + ]) + }) + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) const ownedGroups = deriveGroups( - withMain(list(owned, loose), owned.id), [ws], noArchive, noAttention, view(), + withMain(list(owned, loose), owned.id), [ws], noRows, noAttention, view(), ) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) const looseGroups = deriveGroups( - withMain(list(owned, loose), loose.id), [ws], noArchive, noAttention, view(), + withMain(list(owned, loose), loose.id), [ws], noRows, noAttention, view(), ) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) @@ -339,7 +391,7 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noAttention) + const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noRows, noAttention) expect(rows.map(row => row.id)).toEqual([parent.id, tieA.id, child.id, tieB.id]) }) @@ -347,20 +399,20 @@ describe('deriveFlat', () => { const parent = summary('parent', 1) const fork = { ...summary('fork', 2), parentId: parent.id } const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const } - const ids = visibleSessionIds(withMain(list(parent, fork, subagent), subagent.id), noArchive) + const ids = visibleSessionIds(withMain(list(parent, fork, subagent), subagent.id), noArchive, 'default') expect(ids).toEqual([parent.id, fork.id]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(visibleSessionIds(partial, noArchive)).toEqual([sid('present')]) + expect(visibleSessionIds(partial, noArchive, 'default')).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { const currentBlank = { ...summary('current-blank', 9), blank: true, retainedBy: { mainView: 1 } } const staleBlank = { ...summary('stale-blank', 8), blank: true } const sessions = list(currentBlank, summary('real', 1), staleBlank) - const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) @@ -369,7 +421,29 @@ describe('deriveFlat', () => { it('hides archived sessions in flat mode', () => { const kept = summary('kept', 1) const gone = summary('gone', 2) - expect(visibleSessionIds(list(kept, gone), archived('gone'))).toEqual([kept.id]) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'default')).toEqual([kept.id]) + }) + + it('keeps archived sessions visible while the view shows them', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'show')).toEqual([kept.id, gone.id]) + }) + + it('restricts the view to archived sessions under the only filter', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'only')).toEqual([gone.id]) + }) + + it('leads the flat list with pinned rows in the supplied order', () => { + const sessions = list(summary('a', 2), summary('b', 2), summary('c', 2), summary('d', 3)) + const rows = deriveFlat( + sessions, [sid('b'), sid('a'), sid('c'), sid('d')], rowState({ pinned: ['c', 'b', 'a'] }), noAttention, + ) + expect(rows.map(row => [row.id, row.pinned])).toEqual([ + [sid('b'), true], [sid('a'), true], [sid('c'), true], [sid('d'), false], + ]) }) }) @@ -384,12 +458,33 @@ describe('deriveSearchResults archive filtering', () => { [], 'needle', archived('gone'), + 'default', noAttention, { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, 10, ) expect(result.items.map(item => item.id)).toEqual([hit.id]) }) + + it('matches archived sessions and flags them while the view shows them', () => { + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(gone), [], 'needle', archived('gone'), 'show', noAttention, { items: [], hasMore: false }, 10, + ) + expect(result.items.map(item => [item.id, item.archived])).toEqual([[gone.id, true]]) + }) + + it('matches only archived sessions under the only filter', () => { + const hit = summary('hit', 2) + hit.displayTitle = 'Needle row' + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(hit, gone), [], 'needle', archived('gone'), 'only', noAttention, { items: [], hasMore: false }, 10, + ) + expect(result.items.map(item => [item.id, item.archived])).toEqual([[gone.id, true]]) + }) }) describe('deriveSearchResults', () => { @@ -409,6 +504,7 @@ describe('deriveSearchResults', () => { ], ' NEEDLE ', noArchive, + 'default', new Map([[titleHit.id, status({ key: 'question:1', kind: 'plan-review', sessionId: titleHit.id, } as SessionPendingInteraction)]]), @@ -435,6 +531,7 @@ describe('deriveSearchResults', () => { pendingInteraction: 'plan-review', completed: false, hasActiveSchedule: false, + archived: false, snippet: 'title session body excerpt', }, { @@ -445,6 +542,7 @@ describe('deriveSearchResults', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, }, { id: contentHit.id, @@ -454,6 +552,7 @@ describe('deriveSearchResults', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, snippet: 'body needle excerpt', }, ], @@ -472,6 +571,7 @@ describe('deriveSearchResults', () => { [workspace('first', ['opaque-current', 'new session stale'])], 'new session', noArchive, + 'default', noAttention, { items: [ @@ -496,6 +596,7 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + 'default', noAttention, { items: [], hasMore: false }, 3, @@ -508,13 +609,14 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + 'default', noAttention, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, 'default', noAttention, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) @@ -565,6 +667,13 @@ describe('createWorkspaceViewStore', () => { expect(store.getSnapshot().sessionOrderByAccount).toEqual({}) }) + it('records the one-time archive hint', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().archiveHintSeen).toBe(false) + store.actions.markArchiveHintSeen() + expect(store.getSnapshot().archiveHintSeen).toBe(true) + }) + it('removes view state outside the retained Workspace key set', () => { const store = createWorkspaceViewStore().create() store.actions.setGroupExpanded('', true) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index e372891f5c..b127ec558e 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -14,7 +14,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { DirectoryFlowOwnerProps, WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' import { UNGROUPED_KEY } from '../src/client/tree.ts' -import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' +import { muteNextRowAnimations, rowTransition, WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. @@ -67,7 +67,15 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView const workspaceState = ( items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = [], -): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null }) + pinnedSessionIds: readonly SessionId[] = [], +): WorkspaceSnapshot => ({ + items, + archivedSessionIds, + pinnedSessions: pinnedSessionIds.map(sessionId => ({ sessionId, pinnedAt: 1 })), + state: 'idle', + phase: 'ready', + error: null, +}) const noPendingInteraction: SessionStatusSnapshot = new Map() function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } @@ -106,6 +114,9 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + unarchiveSession: vi.fn(async () => {}), + pinSession: vi.fn(async () => {}), + unpinSession: vi.fn(async () => {}), insertWorkspaceBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -238,7 +249,7 @@ describe('WorkspaceBrowser', () => { const current = screen.getByText('current').closest('[role="treeitem"]') expect(current?.getAttribute('aria-selected')).toBe('false') fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') expect(document.activeElement).toBe(input) fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) const add = screen.getByRole('button', { name: '添加工作区' }) @@ -400,9 +411,9 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('button', { name: '视图选项' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label - expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getAllByRole('separator')).toHaveLength(2) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - '按工作区', '按工作区树', '单列表', '手动排序', '最近更新', + '按工作区', '按工作区树', '单列表', '手动排序', '最近更新', '显示已归档', '仅显示已归档', ]) expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy() expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() @@ -428,6 +439,76 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().groupBy).toBe('workspace') }) + it('picking 显示已归档 keeps existing rows and reveals archived ones in place', () => { + mount({ + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.getByText('stored')).toBeTruthy() + }) + + it('the two archived filters are mutually exclusive and re-picking returns to default', () => { + const b = mount({ + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + }) + fireEvent.click(screen.getByText('alpha')) + const pick = (name: string) => { + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name })) + } + + // 仅显示已归档 hides the live rows and shows the archived one. + pick('仅显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('only') + expect(screen.queryByText('kept')).toBeNull() + expect(screen.getByText('stored')).toBeTruthy() + + // Picking the other filter replaces it: everything visible. + pick('显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('show') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.getByText('stored')).toBeTruthy() + + // Re-picking the selected filter returns to the default hidden view. + pick('显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('default') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + + // The same toggle-off applies to 仅显示已归档. + pick('仅显示已归档') + pick('仅显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('default') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + }) + + it('keeps the picked archived filter across remounts through the view store', () => { + const seats = { + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + } + const b = mount(seats) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(b.store.getSnapshot().archivedFilter).toBe('show') + + cleanup() + const remounted = mount(seats) + expect(remounted.store.getSnapshot().archivedFilter).toBe('show') + // The persisted group expansion also survives, so the rows are already out. + expect(screen.getByText('stored')).toBeTruthy() + }) + it('keeps Workspaces as siblings by default and restores the selected tree grouping', () => { const workspaces = hook(workspaceState([ { ...workspace('root', [], 'Projects'), path: '/projects' }, @@ -714,6 +795,199 @@ describe('WorkspaceBrowser', () => { } }) + it('fronts a freshly pinned row in the saved manual orders and seeds missing accounts', async () => { + const pinSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + pinSession, + }) + act(() => { b.store.actions.setSessionOrder('alpha', ['one', 'two', 'three'], {}) }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“three”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(pinSession).toHaveBeenCalledWith(sid('three')) + // The seeded group order fronts the pin; the flat account starts from the + // pinned row and the membership reconciler appends the remaining rows. + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'one', 'two']) + }) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['three', 'one', 'two']) + }) + + it('leaves saved orders alone for updated-mode pins and for unpins', async () => { + localStorage.clear() + const pinSession = vi.fn(async () => {}) + const unpinSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + pinSession, unpinSession, + }) + expect(b.store.getSnapshot().orderBy).toBe('updated') + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“two”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(pinSession).toHaveBeenCalledWith(sid('two')) + await act(async () => { await Promise.resolve() }) + expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({}) + + // Unpinning in manual mode does not rewrite any saved slot: the orders the + // membership reconciler seeded on entering manual mode stay as they are. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('two')])) }) + act(() => { b.store.actions.setOrderBy('manual', {}) }) + const before = b.store.getSnapshot().sessionOrderByAccount + expect(before).toEqual({ + alpha: ['one', 'two'], + [UNGROUPED_KEY]: [], + [FLAT_SESSION_ORDER_KEY]: ['one', 'two'], + }) + fireEvent.click(screen.getByRole('button', { name: '会话“two”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + expect(unpinSession).toHaveBeenCalledWith(sid('two')) + await act(async () => { await Promise.resolve() }) + expect(b.store.getSnapshot().sessionOrderByAccount).toEqual(before) + }) + + it('surfaces pin and unpin rejections as toasts', async () => { + const pinSession = vi.fn(async () => { throw new Error('pin wire down') }) + const unpinSession = vi.fn(async () => { throw new Error('unpin wire down') }) + const b = mount({ + useSessions: hook(sessionState([summary('one', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one'])])), + pinSession, unpinSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“one”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toContain('置顶失败,请稍后重试') + }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['one'])], [], [sid('one')])) }) + fireEvent.click(screen.getByRole('button', { name: '会话“one”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toContain('取消置顶失败,请稍后重试') + }) + }) + + it('teaches the filter once, then confirms later archives with an undo/filter toast', async () => { + const archiveSession = vi.fn(async () => {}) + const unarchiveSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + archiveSession, unarchiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + const archive = (name: string) => { + fireEvent.click(screen.getByRole('button', { name: `会话“${name}”的操作` })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + } + const flush = () => act(async () => { await Promise.resolve() }) + + // First archive: the anchored callout teaches the filter, no toast. + archive('one') + await flush() + expect(screen.getByText('已归档的会话可以通过筛选展示')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(b.store.getSnapshot().archiveHintSeen).toBe(true) + + // Later archives confirm with the actionable toast; a back-to-back + // archive replaces the still-showing toast. + archive('two') + await flush() + expect(screen.getByRole('alert').textContent).toContain('会话已归档') + archive('three') + await flush() + expect(screen.getAllByRole('alert')).toHaveLength(1) + + // 撤销 unarchives the toast's session and dismisses the banner. + fireEvent.click(screen.getByRole('button', { name: '撤销' })) + expect(unarchiveSession).toHaveBeenCalledWith(sid('three')) + expect(screen.queryByRole('alert')).toBeNull() + + // 筛选查看 dismisses the banner and opens the view-options menu. + archive('two') + await flush() + fireEvent.click(screen.getByRole('button', { name: '筛选已归档会话' })) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('menuitem', { name: '显示已归档' })).toBeTruthy() + + // A chrome remount (window resized through the rail breakpoint) must not + // replay the toast-action bump and reopen the closed menu. + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(screen.queryByRole('menuitem', { name: '显示已归档' })).toBeNull() + rerender(b, { wide: false }) + rerender(b, { wide: true }) + expect(screen.queryByRole('menuitem', { name: '显示已归档' })).toBeNull() + }) + + it('offers unarchive on archived search results only', async () => { + const unarchiveSession = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('alive', 2), summary('gone', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alive', 'gone'])], [sid('gone')])), + unarchiveSession, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'e' } }) + await act(async () => { await Promise.resolve() }) + const tree = screen.getByRole('tree', { name: '搜索结果' }) + expect(within(tree).getByText('alive')).toBeTruthy() + expect(within(tree).getByText('gone')).toBeTruthy() + const unarchive = within(tree).getAllByRole('button', { name: '取消归档' }) + expect(unarchive).toHaveLength(1) + fireEvent.click(unarchive[0]!) + expect(unarchiveSession).toHaveBeenCalledWith(sid('gone')) + }) + + it('fades appearing and disappearing rows quicker than it glides moved ones, and mutes gesture passes', async () => { + class FakeKeyframeEffect { + constructor( + readonly target: Element | null, + readonly keyframes: readonly Keyframe[], + readonly options: KeyframeEffectOptions, + ) {} + } + vi.stubGlobal('KeyframeEffect', FakeKeyframeEffect) + try { + const el = document.createElement('div') + const effect = (value: unknown): FakeKeyframeEffect => value as FakeKeyframeEffect + const added = effect(rowTransition(el, 'add')) + expect(added.options).toEqual({ duration: 100, easing: 'ease-out' }) + expect(added.keyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]) + const removed = effect(rowTransition(el, 'remove')) + expect(removed.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]) + const moved = effect(rowTransition( + el, 'remain', + { top: 40, left: 10, width: 200, height: 30 }, + { top: 10, left: 10, width: 200, height: 30 }, + )) + expect(moved.options).toEqual({ duration: 200, easing: 'ease-out' }) + expect(moved.keyframes).toEqual([ + { transform: 'translate(0px, 30px)' }, + { transform: 'translate(0, 0)' }, + ]) + const settled = effect(rowTransition(el, 'remain')) + expect(settled.keyframes).toEqual([ + { transform: 'translate(0px, 0px)' }, + { transform: 'translate(0, 0)' }, + ]) + // A muted pass (expand/collapse or drag commit) settles instantly, and + // the mute lifts by the next frame. + muteNextRowAnimations() + expect(effect(rowTransition(el, 'add')).options).toEqual({ duration: 0 }) + expect(effect(rowTransition(el, 'remain')).options).toEqual({ duration: 0 }) + await new Promise(resolve => requestAnimationFrame(resolve)) + expect(effect(rowTransition(el, 'add')).options).toEqual({ duration: 100, easing: 'ease-out' }) + } finally { + vi.unstubAllGlobals() + } + }) + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } @@ -797,9 +1071,9 @@ describe('WorkspaceBrowser', () => { expect(screen.getAllByText('新会话')).toHaveLength(1) // Search excludes blank rows entirely — neither the canonical stored // title nor the localized display label participates in matching. - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'new session' } }) expect(screen.queryByText('新会话')).toBeNull() - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: '新会话' } }) expect(screen.queryByText('新会话')).toBeNull() }) @@ -881,13 +1155,15 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) expect(screen.getByText('Needle row')).toBeTruthy() expect(screen.queryByText('Other row')).toBeNull() const status = screen.getByRole('status') - expect(status.textContent).toBe('正在搜索会话历史…') + expect(status.getAttribute('aria-label')).toBe('正在搜索会话历史…') + // Local matches already show, so only one skeleton row trails them. + expect(status.childElementCount).toBe(1) expect(resultTree.contains(status)).toBe(false) fireEvent.change(input, { target: { value: 'zzz' } }) @@ -913,7 +1189,7 @@ describe('WorkspaceBrowser', () => { expect(search.getAttribute('aria-expanded')).toBe('false') fireEvent.click(search) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: ' ' } }) fireEvent.click(document.body) expect(search.getAttribute('aria-expanded')).toBe('false') @@ -953,9 +1229,9 @@ describe('WorkspaceBrowser', () => { open, searchSessions, }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'waterfall token' } }) - expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() + expect(screen.getByRole('status', { name: '正在搜索会话历史…' })).toBeTruthy() expect(screen.queryByText('Research notes')).toBeNull() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -992,7 +1268,7 @@ describe('WorkspaceBrowser', () => { ]) const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1027,7 +1303,7 @@ describe('WorkspaceBrowser', () => { state: 'loading' as const, } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1059,7 +1335,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('research', [...sessions.ids])])), }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1073,7 +1349,7 @@ describe('WorkspaceBrowser', () => { const sessions = sessionState([summary('target', 1, { displayTitle: 'Needle session' })]) const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1097,7 +1373,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1116,7 +1392,7 @@ describe('WorkspaceBrowser', () => { try { const searchSessions = vi.fn(async () => ({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') expect(input.maxLength).toBe(500) fireEvent.change(input, { target: { value: 'y'.repeat(501) } }) expect(input.value).toBe('y'.repeat(500)) @@ -1136,7 +1412,7 @@ describe('WorkspaceBrowser', () => { } }) - it('keeps local matches and shows a lightweight warning when Host search fails', async () => { + it('keeps local matches without a warning when Host search fails', async () => { vi.useFakeTimers() try { const searchSessions = vi.fn(async () => { throw new Error('index unavailable') }) @@ -1147,13 +1423,13 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), searchSessions, }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'needle' }, }) expect(screen.getByText('Needle title')).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('Needle title')).toBeTruthy() - expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy() + expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull() expect(screen.queryByText('无匹配会话')).toBeNull() } finally { vi.useRealTimers() @@ -1184,7 +1460,7 @@ describe('WorkspaceBrowser', () => { ])), searchSessions, }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal @@ -1218,7 +1494,7 @@ describe('WorkspaceBrowser', () => { ? first : Promise.resolve({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -1241,8 +1517,9 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getByText('暂无会话')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } }) - expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'x' } }) + // No matches yet, so the empty list shows two skeleton rows. + expect(screen.getByRole('status', { name: '正在搜索会话历史…' }).childElementCount).toBe(2) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('无匹配会话')).toBeTruthy() } finally { @@ -1257,12 +1534,12 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('工作区')).toBeNull() - expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull() + expect(screen.queryByPlaceholderText('搜索会话名称')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). @@ -1286,7 +1563,7 @@ describe('WorkspaceBrowser', () => { fireEvent.click(document.body) expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true') act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话…')) + expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话名称')) // The gesture has settled: outside clicks dismiss the search again. fireEvent.click(document.body) expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false') @@ -1428,6 +1705,160 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three']) }) + it('drags a pinned row within the pinned block, rewriting only pinned slots', () => { + const sessions = sessionState([ + summary('one', 4), summary('two', 3), summary('three', 2), summary('four', 1), + ]) + // Interleaved manual slots: pinned rows front in render while unpinned + // members keep their account positions when the pinned block reorders. + createWorkspaceViewStore().create().actions.setSessionOrder('alpha', ['three', 'one', 'four', 'two'], {}) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three', 'four'])], [], [sid('one'), sid('two')], + )), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('one'), expect.stringContaining('two'), + expect.stringContaining('three'), expect.stringContaining('four'), + ]) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one']) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), expect.stringContaining('one'), + expect.stringContaining('three'), expect.stringContaining('four'), + ]) + + // Dropping the trailing pinned row after the leading one restates the + // current block: a no-op that writes nothing. + const oneAgain = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const twoAgain = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + twoAgain.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(oneAgain, { dataTransfer: dragData() }) + fireDrag(twoAgain, 'drop', 130) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one']) + }) + + it('keeps pinned and unpinned rows in separate drag domains', () => { + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('one')])), + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + for (const row of [one, two]) { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + } + const before = b.store.getSnapshot().sessionOrderByAccount.alpha + + // A pinned source ignores unpinned targets… + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 105) + fireDrag(two, 'drop', 105) + fireEvent.dragEnd(one) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + + // …and an unpinned source ignores pinned targets. + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + fireEvent.dragEnd(two) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + }) + + it('ignores a pinned drop whose pin state moved in flight', () => { + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three'])], [], [sid('one'), sid('two')], + )), + }) + fireEvent.click(screen.getByText('alpha')) + const before = b.store.getSnapshot().sessionOrderByAccount.alpha + const markTarget = (): HTMLElement => { + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + return two + } + + // The target unpins in flight: its id left the pinned block. + let one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(markTarget(), 'dragOver', 105) + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])], [], [sid('one')])), + }) + fireEvent.dragEnd(screen.getByText('one').closest('[role="treeitem"]') as HTMLElement) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + + // The source unpins in flight. + rerender(b, { + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three'])], [], [sid('one'), sid('two')], + )), + }) + one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(markTarget(), 'dragOver', 105) + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])], [], [sid('two')])), + }) + fireEvent.dragEnd(screen.getByText('one').closest('[role="treeitem"]') as HTMLElement) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + }) + + it('drags pinned rows within the flat pinned block and keeps cross-block targets inert', async () => { + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([], [], [sid('one'), sid('two')])), + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + for (const row of [one, two]) { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + } + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'one', 'three']) + + // An unpinned source ignores pinned targets. + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(three, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + fireEvent.dragEnd(three) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'one', 'three']) + }) + it('persists Ungrouped drag order in both modes', async () => { const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) const b = mount({ @@ -1686,7 +2117,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement expect(row.hasAttribute('draggable')).toBe(false) }) @@ -1817,7 +2248,7 @@ describe('Workspace tree grouping', () => { useWorkspaces: hook(workspaceState([root, team, child])), useSessions: hook(sessionState([summary('child-session', 1)])), }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'child-session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'child-session' } }) fireEvent.click(screen.getByRole('treeitem')) expect(b.store.getSnapshot().groupExpansion).toEqual({ child: true }) expect(screen.getByText('child-session')).toBeTruthy() diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index e3c3437d58..28f8aaacae 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -39,7 +39,7 @@ const sessions: SessionListState = { } const noPendingInteraction: SessionStatusSnapshot = new Map() const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ - items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) function anchor(): { current: HTMLElement } { const element = document.createElement('button') diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 79423d9c03..92f51f3328 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -88,6 +88,7 @@ function workspaceState( return { items, archivedSessionIds, + pinnedSessions: [], phase, state: phase === 'ready' ? 'idle' : 'loading', error: null, @@ -189,6 +190,8 @@ class FakeWorkspaces implements IWorkspaces { declare readonly delete: IWorkspaces['delete'] declare readonly insertBefore: IWorkspaces['insertBefore'] declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore'] + declare readonly pinSession: IWorkspaces['pinSession'] + declare readonly unpinSession: IWorkspaces['unpinSession'] constructor(initial: WorkspaceSnapshot) { this.list = new MutableSource(initial) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 84a4e1a640..35a5af86e3 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -59,6 +59,9 @@ { "path": "../ui-session" }, + { + "path": "../../util/values" + }, { "path": "../../util/workspace-path" } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index d2c50baf26..487dea1160 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2269,7 +2269,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-settings-general GeneralSection id \'general\'', 'client-ui-settings-models ModelsSection id \'models\'', 'client-ui-settings-plugins PluginsSettingsSection id \'plugins\'', - 'client-ui-settings-unarchive-sessions ArchivedSessionsSection id \'archived-sessions\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.section\', () => ctx.slots.register(\n { name: \'settings.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index 707028af66..fe31f35c53 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -42,6 +42,6 @@ export const remoteDefaultResponses: RemoteTable = { // api-session-controller client `apply`: the control stream's opening baseline, then open. 'session/control': openStream([{ type: 'baseline', value: { jobs: {}, projections: {} } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. - 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]), + 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessions: [] } }]), }, } diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 8e39f3c112..5e621ee6fc 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -111,6 +111,7 @@ export function workspaceSnapshot(): WorkspaceSnapshot { return { items: [], archivedSessionIds: [], + pinnedSessions: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 15ecc71d58..8cba3707b5 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -159,4 +159,41 @@ export class TestWorkspaces implements IWorkspaces { draft.archivedSessionIds = draft.archivedSessionIds.filter(id => id !== sessionId) }) } + + /** + * Pin a session (recorded). The default mirrors the production face's + * observable effect: the id leads the list state's pin set. + * @param sessionId - session to pin. + */ + async pinSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'pinSession', args: [sessionId] }) + const stub = this.stubs.get('pinSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.pinnedSessions = [ + { sessionId, pinnedAt: Date.now() }, + ...draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId), + ] + }) + } + + /** + * Unpin a session (recorded). The default mirrors the production face's + * observable effect: the id leaves the list state's pin set. + * @param sessionId - session to unpin. + */ + async unpinSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'unpinSession', args: [sessionId] }) + const stub = this.stubs.get('unpinSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.pinnedSessions = draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId) + }) + } } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 2d69aafe6c..1c510685c7 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -17,12 +17,12 @@ import type { WorkspaceEntityHost } from './entity.ts' export { WorkspaceMoveInvalidError } from './entity.ts' import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' -import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' +import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' export type { Workspace } from './types.ts' export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts' -export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' +export type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts' export { realpathNormalize } from './paths.ts' /** Identifies one workspace record (see `src/types.ts` for the brand rationale). */ @@ -38,16 +38,29 @@ export function WorkspaceId(id: string): WorkspaceId { } /** - * An archiveSession request named a session neither live nor in session - * persistence — a definite miss only; storage faults propagate as themselves. + * An archiveSession or pinSession request named a session neither live nor in + * session persistence — a definite miss only; storage faults propagate as + * themselves. */ export class WorkspaceUnknownSessionError extends Error { /** * @param sessionId - The unknown session id. + * @param verb - The registry operation that named the session. + */ + constructor(readonly sessionId: SessionId, verb: 'archive' | 'pin') { + super(`cannot ${verb} session '${sessionId}': live sessions and session persistence hold no such session`) + this.name = 'WorkspaceUnknownSessionError' + } +} + +/** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */ +export class WorkspaceArchivedSessionPinError extends Error { + /** + * @param sessionId - The archived session id. */ constructor(readonly sessionId: SessionId) { - super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`) - this.name = 'WorkspaceUnknownSessionError' + super(`cannot pin session '${sessionId}': the session is archived`) + this.name = 'WorkspaceArchivedSessionPinError' } } @@ -236,7 +249,9 @@ export class WorkspaceRegistry extends Service { /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. - * An already archived id resolves without writing. + * Archiving drops the session's pin in the same durable write (pinning and + * archival are mutually exclusive). An already archived id resolves without + * writing. * @param sessionId - The session to archive. * @returns resolution after durability. */ @@ -246,10 +261,14 @@ export class WorkspaceRegistry extends Service { // check-then-write pair cannot interleave with another archive. if (this.requireState().archivedSessionIds.includes(sessionId)) return if (!(await this.sessionKnown(sessionId))) { - throw new WorkspaceUnknownSessionError(sessionId) + throw new WorkspaceUnknownSessionError(sessionId, 'archive') } const state = this.requireState() - await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) + await this.setState({ + ...state, + archivedSessionIds: [...state.archivedSessionIds, sessionId], + pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId), + }) }) } @@ -276,6 +295,65 @@ export class WorkspaceRegistry extends Service { }) } + /** + * The registry-global pin set: sessions surfaced ahead of every unpinned + * session on grouping surfaces. Pinning never touches workspace accounting. + * @returns the pin entries in pin order (most recently pinned first), each + * carrying the epoch-millisecond `pinnedAt` it was pinned at. + */ + get pinnedSessions(): readonly PinnedSession[] { + return this.requireState().pinnedSessions + } + + /** + * Pin one session durably, prepending it to the registry-global pin set + * with the current wall-clock time as its `pinnedAt`. The session must + * exist (live or in session persistence) and must not be archived (pinning + * and archival are mutually exclusive). An already pinned id resolves + * without writing and keeps its original `pinnedAt`. + * @param sessionId - The session to pin. + * @returns resolution after durability. + */ + pinSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with another pin or archive. + if (this.requireState().pinnedSessions.some(entry => entry.id === sessionId)) return + if (this.requireState().archivedSessionIds.includes(sessionId)) { + throw new WorkspaceArchivedSessionPinError(sessionId) + } + if (!(await this.sessionKnown(sessionId))) { + throw new WorkspaceUnknownSessionError(sessionId, 'pin') + } + const state = this.requireState() + await this.setState({ + ...state, + pinnedSessions: [{ id: sessionId, pinnedAt: Date.now() }, ...state.pinnedSessions], + }) + }) + } + + /** + * Unpin one session durably by dropping it from the registry-global pin + * set. Unpinning runs no session-existence check because removing an id + * cannot introduce an unknown one, so an entry whose session is gone still + * resolves. An id that is not pinned resolves without writing. + * @param sessionId - The session to unpin. + * @returns resolution after durability. + */ + unpinSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with a concurrent pin. + const state = this.requireState() + if (!state.pinnedSessions.some(entry => entry.id === sessionId)) return + await this.setState({ + ...state, + pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId), + }) + }) + } + /** * Whether a session is live, header-indexed, or present in a fresh * persistence listing. Only a definite miss returns false — a failing @@ -353,6 +431,7 @@ export class WorkspaceRegistry extends Service { initialized: true, workspaceIds: [id, ...state.workspaceIds], archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, }) } catch (error) { this.entities.delete(id) @@ -385,6 +464,7 @@ export class WorkspaceRegistry extends Service { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, } await this.setState({ ...nextState, @@ -442,6 +522,7 @@ export class WorkspaceRegistry extends Service { initialized: state.initialized, workspaceIds: state.workspaceIds, archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, }) } @@ -524,9 +605,19 @@ export class WorkspaceRegistry extends Service { .map(([id]) => id) if (!sameIds(state.workspaceIds, workspaceIds)) { - await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds }) + await this.setState({ + initialized: false, + workspaceIds, + archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, + }) } - await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds }) + await this.setState({ + initialized: true, + workspaceIds, + archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, + }) } private validateStoredState(state: WorkspaceDomainState): void { diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 089e039f27..ac1d737716 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -40,6 +40,19 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ z.object({ operation: z.literal('delete'), workspaceId }), ]) +/** + * One registry-global pin entry: the session and the wall-clock epoch + * milliseconds it was pinned at. Grouping surfaces rank a pinned row by the + * later of `pinnedAt` and the session's own update recency. + */ +const pinnedSessionEntry = z.object({ + id: z.string().transform(value => brandString(value)), + pinnedAt: z.number(), +}) + +/** One stored pin entry inferred from {@link pinnedSessionEntry}. */ +export type PinnedSession = z.infer + /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; @@ -47,12 +60,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ * the registry-global archive set layered over workspace accounting: an * archived session keeps its `sessionIds` slot (unarchiving must restore the * position), so the set never participates in the one-owner accounting - * invariant. Defaulted so records written before the field parse unchanged. + * invariant. `pinnedSessions` is the registry-global pin set in pin order + * (most recently pinned first); pinning and archival are mutually + * exclusive, so archiving drops the session's pin. Both session sets are + * defaulted so records written before the fields parse unchanged. */ export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), archivedSessionIds: z.array(z.string().transform(value => brandString(value))).default([]), + pinnedSessions: z.array(pinnedSessionEntry).default([]), pendingMutation: workspacePendingMutation.optional(), }) @@ -70,7 +87,7 @@ export const workspaceDomainSpec = defineDomain({ version: 2, global: { schema: workspaceDomainState, - initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] }, + initial: { initialized: false, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }, }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index c6e283868e..48a4d98753 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -17,7 +17,7 @@ import WorkspaceRegistry, { WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, } from '../src/index.ts' -import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' +import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath } from '../src/paths.ts' const DOMAIN_VERSION = 2 @@ -145,11 +145,12 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0 } /** - * Media written before archivedSessionIds existed omit the field; keeping the - * fixtures in that shape continuously proves the schema default upgrades them. + * Media written before archivedSessionIds and pinnedSessions existed omit + * the fields; keeping the fixtures in that shape continuously proves the + * schema defaults upgrade them. */ -type StoredDomainState = Omit - & Partial> +type StoredDomainState = Omit + & Partial> function storedPool( entries: Array<[string, WorkspaceRecord]>, @@ -172,6 +173,8 @@ function storedState(pool: MemoryMediaPool): WorkspaceDomainState { return pool.media.get('workspace')!.global as WorkspaceDomainState } +const pinnedIds = (entries: readonly PinnedSession[]): string[] => entries.map(entry => String(entry.id)) + let base: string const tempDirs: string[] = [] @@ -201,7 +204,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { await fiber.await() expect(ctx.workspaceRegistry.list()).toEqual([]) expect(list).toHaveBeenCalledTimes(1) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) }) it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { @@ -235,6 +238,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { initialized: true, workspaceIds: result.registry.list().map(workspace => workspace.id), archivedSessionIds: [], + pinnedSessions: [], }) }) @@ -263,7 +267,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { const second = await harness({ pool, sessions: [header('late', late, 100)] }) expect(second.list).not.toHaveBeenCalled() expect(second.registry.list()).toEqual([]) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) }) it('reuses partial records after a bootstrap record write fails', async () => { @@ -517,7 +521,7 @@ describe('WorkspaceRegistry create and lookup', () => { await expect(result.registry.delete(workspace.id)).resolves.toBe(false) expect(result.registry.get(workspace.id)).toBeUndefined() expect(result.registry.list()).toEqual([]) - expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) @@ -561,6 +565,7 @@ describe('WorkspaceRegistry create and lookup', () => { initialized: true, workspaceIds: [], archivedSessionIds: [], + pinnedSessions: [], pendingMutation: { operation: 'delete', workspaceId: workspace.id }, }) const reregistered = await first.registry.create(dir) @@ -569,6 +574,7 @@ describe('WorkspaceRegistry create and lookup', () => { initialized: true, workspaceIds: [reregistered.id], archivedSessionIds: [], + pinnedSessions: [], }) await first.fiber.dispose() @@ -848,7 +854,7 @@ describe('header-validated membership projection', () => { const createRecovery = await harness({ pool: interruptedCreate }) expect(createRecovery.registry.list()).toEqual([]) expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) - expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) const interruptedDelete = storedPool( [[deleteId, record(deleteDir, [])]], @@ -861,7 +867,7 @@ describe('header-validated membership projection', () => { const deleteRecovery = await harness({ pool: interruptedDelete }) expect(deleteRecovery.registry.list()).toEqual([]) expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) - expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) const corruptPending = storedPool( [[deleteId, record(deleteDir, [])]], @@ -1033,3 +1039,123 @@ describe('registry-global session unarchive', () => { expect(second.registry.archivedSessionIds).toEqual(['kept']) }) }) + +describe('registry-global session pin', () => { + it('pins durably prepending with a pinnedAt stamp, idempotently skips repeats, and leaves accounting untouched', async () => { + const dir = await makeDir('pin-home') + const result = await harness({ sessions: [header('first', dir, 100), header('second', dir, 200)] }) + const workspace = result.registry.list()[0]! + expect(result.registry.pinnedSessions).toEqual([]) + + const before = Date.now() + await result.registry.pinSession(SessionId('first')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['first']) + const firstEntry = result.registry.pinnedSessions[0]! + expect(firstEntry.pinnedAt).toBeGreaterThanOrEqual(before) + expect(firstEntry.pinnedAt).toBeLessThanOrEqual(Date.now()) + // Pinning is a display-set write: the workspace account keeps the id. + expect(workspace.sessionIds).toContain('first') + expect(storedState(result.pool).pinnedSessions).toEqual([firstEntry]) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.pinSession(SessionId('first')) + // The idempotent repeat keeps the original pinnedAt, never rewrites the + // medium, and emits no change. + expect(result.registry.pinnedSessions).toEqual([firstEntry]) + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + + // The most recent pin lands first (display order). + await result.registry.pinSession(SessionId('second')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['second', 'first']) + expect(result.registry.pinnedSessions[0]!.pinnedAt).toBeGreaterThanOrEqual(firstEntry.pinnedAt) + }) + + it('rejects pinning archived and unknown sessions without writing', async () => { + const dir = await makeDir('pin-rejects') + const result = await harness({ sessions: [header('stored', dir, 100)] }) + await result.registry.archiveSession(SessionId('stored')) + + await expect(result.registry.pinSession(SessionId('stored'))) + .rejects.toThrow(/cannot pin session 'stored': the session is archived/) + await expect(result.registry.pinSession(SessionId('ghost'))) + .rejects.toThrow(/cannot pin session 'ghost'/) + expect(storedState(result.pool).pinnedSessions).toEqual([]) + }) + + it('propagates a persistence-listing failure instead of reporting an unknown session', async () => { + const result = await harness({ sessions: [] }) + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + // The storage fault is the error — never WorkspaceUnknownSessionError, + // which the API layer would misreport as session-not-found. + await expect(result.registry.pinSession(SessionId('unlisted'))) + .rejects.toThrow(/persistence backend down/) + expect(storedState(result.pool).pinnedSessions).toEqual([]) + }) + + it('archiving drops the pin in the same durable write', async () => { + const dir = await makeDir('pin-then-archive') + const result = await harness({ sessions: [header('s1', dir, 100), header('s2', dir, 200)] }) + await result.registry.pinSession(SessionId('s1')) + await result.registry.pinSession(SessionId('s2')) + + await result.registry.archiveSession(SessionId('s1')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['s2']) + expect(result.registry.archivedSessionIds).toEqual(['s1']) + const stored = storedState(result.pool) + expect(pinnedIds(stored.pinnedSessions)).toEqual(['s2']) + expect(stored.archivedSessionIds).toEqual(['s1']) + }) + + it('restores the pin set across restarts', async () => { + const dir = await makeDir('pin-restart') + const pool = new MemoryMediaPool() + const sessions = [header('kept', dir, 100)] + const first = await harness({ pool, sessions }) + await first.registry.pinSession(SessionId('kept')) + const pinned = [...first.registry.pinnedSessions] + await first.fiber.dispose() + + const second = await harness({ pool, sessions }) + // The stored entry survives verbatim, pinnedAt included. + expect(second.registry.pinnedSessions).toEqual(pinned) + }) +}) + +describe('registry-global session unpin', () => { + it('unpins durably in place and idempotently skips absent ids', async () => { + const dir = await makeDir('unpin-home') + const result = await harness({ + sessions: [header('one', dir, 100), header('two', dir, 200), header('three', dir, 300)], + }) + await result.registry.pinSession(SessionId('one')) + await result.registry.pinSession(SessionId('two')) + await result.registry.pinSession(SessionId('three')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'two', 'one']) + + await result.registry.unpinSession(SessionId('two')) + // Removal keeps the survivors in pin order. + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one']) + expect(pinnedIds(storedState(result.pool).pinnedSessions)).toEqual(['three', 'one']) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.unpinSession(SessionId('two')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one']) + // The absent-id repeat neither rewrites the medium nor emits a change. + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + }) + + it('unpins an entry whose session is gone without consulting session persistence', async () => { + const dir = await makeDir('unpin-vanished') + const result = await harness({ sessions: [header('vanished', dir, 100)] }) + await result.registry.pinSession(SessionId('vanished')) + result.setSessions([]) + const listingsBefore = result.list.mock.calls.length + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + + // Removing an id cannot introduce an unknown one, so the pin entry + // resolves even though no session backs it and no listing runs. + await expect(result.registry.unpinSession(SessionId('vanished'))).resolves.toBeUndefined() + expect(result.registry.pinnedSessions).toEqual([]) + expect(result.list.mock.calls.length).toBe(listingsBefore) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce532bc60a..370c1d9ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2184,9 +2184,6 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins - '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions': - specifier: workspace:^ - version: link:../../client/ui-settings-unarchive-sessions '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -4262,57 +4259,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-unarchive-sessions: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-api-workspace-controller': - specifier: workspace:^ - version: link:../../api/workspace-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-sidebar: devDependencies: '@deepseek-ai/cordis': @@ -5247,9 +5193,15 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path + '@formkit/auto-animate': + specifier: 0.10.0 + version: 0.10.0 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -14053,6 +14005,9 @@ packages: '@noble/hashes': optional: true + '@formkit/auto-animate@0.10.0': + resolution: {integrity: sha512-KGomRttjUfORuPUaR/ZGQw+6xfMrTM+sxnILv7JAd9AmabU9rg9i6gF/iC0Ih+QpKCubJpCA/1DX9UHKE8cX+A==} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -21148,6 +21103,8 @@ snapshots: optionalDependencies: '@noble/hashes': 2.3.0 + '@formkit/auto-animate@0.10.0': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 33363b380e..4727164e95 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -131,7 +131,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-plugin-manager': { kind: 'none', reason: 'Browser-side management surface; registers nothing model-facing.' }, 'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, - 'packages/client/ui-settings-unarchive-sessions': { kind: 'none', reason: 'Browser-side settings page; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 12075ca464..dfd585cbf7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -256,7 +256,6 @@ "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], "@deepseek-ai/dsh-client-ui-plugin-manager": ["./packages/client/ui-plugin-manager/src"], "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"], - "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web/injections": ["./packages/client/web/src/apply-injections.ts"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b830d85b2f..08538a0e76 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -114,7 +114,6 @@ { "path": "./packages/client/ui-settings-models" }, { "path": "./packages/client/ui-plugin-manager" }, { "path": "./packages/client/ui-settings-plugin-inventory" }, - { "path": "./packages/client/ui-settings-unarchive-sessions" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, From 3e96ed79a7ec21cdb0b92ca3f9dfe386a9494f17 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:37:03 +0800 Subject: [PATCH 02/42] fix(workspace): steady hovered session titles and polish the sidebar controls Hovering a session row no longer scrolls its clipped title (the mask fade stays), the one-time archived-filter callout and the content-search unavailable notice are gone, the group-row new-session button shares the new-chat glyph, and the view-options trigger uses the two-slider icon with a 200px menu floor and a visible separator hairline. --- .../tests/sidebar-title-hover-scroll.e2e.ts | 86 -------------- .../client/ui-primitives/src/Menu.module.css | 5 +- packages/client/ui-primitives/src/Menu.tsx | 9 +- .../client/ui-workspace/src/client/locales.ts | 2 - .../src/client/rows/Rows.module.css | 20 ---- .../ui-workspace/src/client/rows/Rows.tsx | 43 ++----- .../client/rows/WorkspaceBrowser.module.css | 55 +++------ .../src/client/rows/WorkspaceBrowser.tsx | 108 ++---------------- .../client/ui-workspace/src/client/stores.ts | 5 - .../tests/browser-styles.client.spec.ts | 6 +- .../ui-workspace/tests/rows.client.spec.tsx | 50 -------- .../ui-workspace/tests/tree.client.spec.ts | 7 -- .../tests/workspace-browser.client.spec.tsx | 11 +- 13 files changed, 46 insertions(+), 361 deletions(-) delete mode 100644 apps/web/tests/sidebar-title-hover-scroll.e2e.ts diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts deleted file mode 100644 index 2a0fe8caf4..0000000000 --- a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Web e2e scenario: hovering a sidebar session row reveals a title wider than -// its cell. The jsdom lane pins only the scroll position the handler writes; -// the assembled browser is where the title really clips, really reaches its own -// hovered extent (the trailing relative-time cell yields to the row menu while -// the pointer rests on the row, so the hovered cell is wider than the resting -// one), and really returns to its start when the pointer leaves. -// -// Zero model calls: seeding one renamed session and hovering its row touches no -// provider. -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' -import type { Browser, Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ -const TITLE = 'Forked session clipped before its suffix (1)' - -describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) - const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll') - await workspace.attachSession(id) - await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE }) - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it('reveals the clipped title under the pointer and restores its start after', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) - const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) - await row.waitFor({ timeout: 20_000 }) - const title = row.getByText(TITLE, { exact: true }) - - // At rest the row clips its title with an ellipsis and holds its start. - expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - expect(await title.evaluate(el => el.scrollLeft)).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') - - await row.hover() - // Poll against the live extent: the reveal must reach the far edge of the - // hovered cell (the wider one — the relative-time label yields to the row - // menu), whatever width the handler itself read. - await expect.poll( - async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), - { timeout: 10_000 }, - ).toBe(true) - // Scrolled to its end, the title must not paint the ellipsis over the - // characters the reveal reached. - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') - - // Leaving returns in one step: a smooth return would still be travelling - // inside this window, and it would carry the resting ellipsis back with it. - await page.mouse.move(0, 0) - await expect.poll( - async () => title.evaluate(el => el.scrollLeft), - { timeout: 150, interval: 20 }, - ).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - - // The reduce override reaches the real stylesheet, so the reveal jumps - // instead of gliding. - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') - await page.emulateMedia({ reducedMotion: null }) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) -}) diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index eef5526a1b..18600736eb 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -240,11 +240,12 @@ color: var(--dsw-alias-label-tertiary); } -/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ +/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not + * l1, for the same reason as .footer: l1 is near-invisible on this surface. */ .separator { height: 0.5px; margin: 3px 2px; - background: var(--dsw-alias-border-l1); + background: var(--dsw-alias-border-l2); } /* Nested card to the right of the parent row. diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index bd56ee1309..1a759c7b20 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -87,9 +87,13 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * (`'check'`, default — figma .Menu_cell) or the hover fill held on the row * with no check (`'fill'`, for icon-labelled rows where a trailing glyph * crowds the cell). + * @param props.className - extra class on the anchor wrapper span. + * @param props.listClassName - extra class on the dropdown card itself; the + * only style hook that reaches a portaled list, which renders under + * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: { open: boolean autoFocus?: boolean anchor: ReactNode @@ -108,6 +112,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o selection?: 'check' | 'fill' getAnchorRect?: () => DOMRect | null className?: string | undefined + listClassName?: string | undefined }) { const rootRef = useRef(null) const listRef = useRef(null) @@ -404,7 +409,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o const list = open && (
{ e.stopPropagation(); onCreate() }} > - +
@@ -351,7 +327,7 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; // On archived rows the archived line already says the session is inactive, // so resting statuses (idle/completed) drop; live activity still shows. const statuses = sessionStatuses(node, t) - .filter(status => !(node.archived && status.state === 'done')) + .filter(status => !(node.archived && (status.state === 'done' || status.state === 'idle'))) return (
{displayTitle(node, t)}
@@ -491,7 +467,6 @@ export function SessionNodeItem({ const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) - const titleRef = useRef(null) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -504,8 +479,7 @@ export function SessionNodeItem({ ? [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). - { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, + { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, ] : [ { @@ -515,7 +489,7 @@ export function SessionNodeItem({ }, { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -530,8 +504,6 @@ export function SessionNodeItem({ role="treeitem" aria-selected={selected} onClick={() => { onOpen(node.id) }} - onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} - onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} onDragStart={!draggable ? undefined @@ -568,7 +540,6 @@ export function SessionNodeItem({ )} { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 87acb02c9a..f0b960dea8 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -34,6 +34,12 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* View-options dropdown card: labels are short, so a content-sized card reads + cramped and its width shifts with the locale — hold a stable floor. */ +.viewOptionsMenu { + min-width: 200px; +} + /* Section header: title, an inline search control, and the two trailing actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { @@ -550,40 +556,17 @@ color: var(--dsw-alias-label-secondary); } -/* Post-archive callout bubble (tooltip visual): centered under the - view-options button, sliding inside the viewport when needed while the - arrow stays on the anchor. */ -.archiveHint { - position: fixed; - z-index: 100; - width: max-content; - max-width: 50vw; - padding: 3px 7px; - border-radius: 8px; - background: var(--dsw-alias-tooltip-bg); - color: var(--dsw-static-neutral-bluish-00); - font-size: 13px; - line-height: 20px; - transform: translateX(-50%); - pointer-events: none; - animation: archive-hint-in 150ms var(--ds-ease-in-out); -} - -/* Upward triangle riding the bubble's top edge; its inline `left` tracks the - anchor center when the bubble itself has been slid off-center. */ -.archiveHintArrow { - position: absolute; - top: -5px; - width: 0; - height: 0; - transform: translateX(-50%); - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 5px solid var(--dsw-alias-tooltip-bg); -} - -@keyframes archive-hint-in { - from { opacity: 0; } +/* The design's ringed green check for the success toast; the Toast icon seat + defaults to the warning tint. */ +.toastIcon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border: 1.5px solid var(--dsw-alias-state-success-primary); + border-radius: 50%; + corner-shape: round; + color: var(--dsw-alias-state-success-primary); } @media (prefers-reduced-motion: reduce) { @@ -591,10 +574,6 @@ animation: none; } - .archiveHint { - animation: none; - } - .skeletonDot, .skeletonBar { animation: none; diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 9863762645..00d66ddfab 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -9,14 +9,15 @@ * menu in between; the flow and its error dialog live in WorkspacePicker * (same package — direct composition, no slot between them). */ -import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' import { - Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, - IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, - IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, - IconWarningOutlineRegular, IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconCheckOutlineRegular, + IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, + IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, + IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, IconWarningOutlineRegular, + IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSearchResultItem, @@ -245,7 +246,7 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde { id: 'updated', label: t('orderBy.updated'), icon: }, { type: 'separator' as const, id: 'archived-filter-separator' }, { type: 'label' as const, id: 'filter-by', text: t('filterBy.label') }, - { id: 'show-archived', label: t('viewOptions.showArchived'), icon: }, + { id: 'show-archived', label: t('viewOptions.showArchived'), icon: }, { id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: }, ]} selectedIds={[ @@ -265,6 +266,7 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde }} align="end" dense + listClassName={css.viewOptionsMenu} // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal @@ -285,79 +287,8 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde ) } -/** Hold before the archive callout dismisses itself. */ -const ARCHIVE_HINT_HOLD_MS = 3000 /** Hold for the actionable post-archive toast: two buttons need a longer read-and-react window than a plain notice. */ const ARCHIVE_TOAST_HOLD_MS = 6000 -/** Minimum gap between the archive callout and the viewport edges. */ -const ARCHIVE_HINT_EDGE_MARGIN = 12 - -/** - * Post-archive callout bubble under the view-options button, naming the filter - * that reveals archived rows. Fixed positioning escapes the section header's - * overflow clipping without a portal (the Tooltip primitive's approach). - * The bubble centers on the anchor but slides back inside the viewport when - * the edges refuse it; the arrow stays on the anchor center either way. - * Dismissed by its hold timer or any pointer press on the page. - */ -function ArchiveFilterHint({ anchor, text, onDone }: { - anchor: HTMLElement | null - text: string - onDone: () => void -}) { - useEffect(() => { - const timer = setTimeout(onDone, ARCHIVE_HINT_HOLD_MS) - // Capture phase: a press anywhere dismisses, even when the target stops - // propagation (menus, rows). - const dismiss = (): void => { onDone() } - document.addEventListener('pointerdown', dismiss, true) - return () => { - clearTimeout(timer) - document.removeEventListener('pointerdown', dismiss, true) - } - }, [onDone]) - const bubble = useRef(null) - const [pos, setPos] = useState<{ x: number; y: number } | null>(null) - // Horizontal slide applied to keep the bubble inside the viewport; the - // arrow compensates by the same amount to keep pointing at the anchor. - const [dx, setDx] = useState(0) - useLayoutEffect(() => { - if (anchor === null) return - const measure = (): void => { - const rect = anchor.getBoundingClientRect() - setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 }) - } - measure() - window.addEventListener('resize', measure) - return () => { window.removeEventListener('resize', measure) } - }, [anchor]) - useLayoutEffect(() => { - const el = bubble.current - if (el === null || pos === null) return - // Reset first so a larger viewport releases a previous slide. - el.style.left = `${String(pos.x)}px` - const r = el.getBoundingClientRect() - let slide = 0 - if (r.right > window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN) { - slide = window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN - r.right - } - if (r.left + slide < ARCHIVE_HINT_EDGE_MARGIN) slide = ARCHIVE_HINT_EDGE_MARGIN - r.left - el.style.left = `${String(pos.x + slide)}px` - setDx(slide) - }, [pos]) - if (pos === null) return null - return ( - - - {text} - - ) -} /** In-flight root-row drag: source identity plus the current insert marker. */ interface DragState { @@ -979,7 +910,6 @@ function SearchResults({ [list, workspaces, query, archivedSessionIds, archivedFilter, statuses, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' - const failed = currentRemote.status === 'error' const currentId = panelActive ? undefined : Object.values(list.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)?.id @@ -1079,11 +1009,7 @@ export function WorkspaceBrowser({ const archivedFilter = useStore(s => s.archivedFilter ?? 'default') const groupExpansion = useStore(s => s.groupExpansion) const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount) - const archiveHintSeen = useStore(s => s.archiveHintSeen) - // Archive callout under the view-options button; the seq keys remounts so - // consecutive archives restart the hold. const viewOptionsButton = useRef(null) - const [archiveHintSeq, setArchiveHintSeq] = useState(0) // Each bump opens the view-options menu (the archive toast's filter action). const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0) // One transient banner at a time; the seq keys remounts so a repeat message restarts its hold. @@ -1389,14 +1315,6 @@ export function WorkspaceBrowser({ // same posture as reorder rejections. const onSessionArchive = (sessionId: SessionNode['id']) => { archiveSession(sessionId).then(() => { - // The first archive teaches the filter with the anchored callout; every - // later archive confirms with an actionable toast instead (undo the - // archive, or open the view-options menu on the filter items). - if (!archiveHintSeen) { - actions.markArchiveHintSeen() - setArchiveHintSeq(seq => seq + 1) - return - } setToast(current => ({ text: t('toast.archived'), seq: (current?.seq ?? 0) + 1, @@ -1779,20 +1697,12 @@ export function WorkspaceBrowser({ key={`toast-${String(toast.seq)}`} text={toast.text} {...toast.tone === 'success' - ? { tone: 'success' as const } + ? { icon: } : { icon: }} {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} onDone={() => { setToast(null) }} /> )} - {archiveHintSeq > 0 && ( - { setArchiveHintSeq(0) }} - /> - )}
) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index bd1ac63d87..d6855b5156 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -30,8 +30,6 @@ type WorkspaceViewState = { * fallback at the rehydration boundary. */ archivedFilter: ArchivedFilter - /** The one-time hint pointing at the view menu after the first archive has been shown. */ - archiveHintSeen: boolean } /** @@ -58,7 +56,6 @@ type WorkspaceViewActions = { initialOrders: Readonly>, ) => void setArchivedFilter: (draft: WorkspaceViewState, filter: ArchivedFilter) => void - markArchiveHintSeen: (draft: WorkspaceViewState) => void } /** Copy read-only projections into the persisted mutable store representation. */ @@ -80,7 +77,6 @@ export function createWorkspaceViewStore(): EngineStoreHandle { d.archivedFilter = filter }, - markArchiveHintSeen: (d) => { d.archiveHintSeen = true }, }, }) } diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 38d4ee36a3..df9938bd29 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -108,11 +108,7 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) - it('reveals a clipped session title by scrolling it on row hover', () => { - // Smooth versus reduced motion is pinned as a computed style in - // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges - // same-selector rules across media queries, so the reduce override would - // mask the smooth declaration here. + it('fades a hovered title out through the mask instead of the resting ellipsis', () => { expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') }) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index e8b5439d95..f9a4b2bcac 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -172,56 +172,6 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) - it('reveals a clipped session title by scrolling it while the row is hovered', () => { - const node: SessionNode = { - id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - // jsdom lays out nothing: the title's clipped geometry is stated outright. - const geometry = (scrollWidth: number, clientWidth: number): void => { - Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) - Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) - } - - geometry(320, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(140) - fireEvent.pointerLeave(row) - expect(title.scrollLeft).toBe(0) - - // A title that fits has no scroll range: hovering leaves it at its start. - geometry(180, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - }) - - it('returns a revealed title to its start in one step', () => { - const node: SessionNode = { - id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - const scrollTo = vi.fn() - Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) - - fireEvent.pointerEnter(row) - fireEvent.pointerLeave(row) - // Browsers receive the explicit instant behavior that the stylesheet's - // smooth scroll-behavior would otherwise override. - expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) - }) - it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { const onOpen = vi.fn() const node: SessionNode = { diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index c67d1b1c85..2c36b28f4d 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -667,13 +667,6 @@ describe('createWorkspaceViewStore', () => { expect(store.getSnapshot().sessionOrderByAccount).toEqual({}) }) - it('records the one-time archive hint', () => { - const store = createWorkspaceViewStore().create() - expect(store.getSnapshot().archiveHintSeen).toBe(false) - store.actions.markArchiveHintSeen() - expect(store.getSnapshot().archiveHintSeen).toBe(true) - }) - it('removes view state outside the retained Workspace key set', () => { const store = createWorkspaceViewStore().create() store.actions.setGroupExpanded('', true) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index b127ec558e..a099cfdca3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -871,7 +871,7 @@ describe('WorkspaceBrowser', () => { }) }) - it('teaches the filter once, then confirms later archives with an undo/filter toast', async () => { + it('confirms every archive with an undo/filter toast', async () => { const archiveSession = vi.fn(async () => {}) const unarchiveSession = vi.fn(async () => {}) const b = mount({ @@ -886,14 +886,7 @@ describe('WorkspaceBrowser', () => { } const flush = () => act(async () => { await Promise.resolve() }) - // First archive: the anchored callout teaches the filter, no toast. - archive('one') - await flush() - expect(screen.getByText('已归档的会话可以通过筛选展示')).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - expect(b.store.getSnapshot().archiveHintSeen).toBe(true) - - // Later archives confirm with the actionable toast; a back-to-back + // Every archive confirms with the actionable toast; a back-to-back // archive replaces the still-showing toast. archive('two') await flush() From bd0461c894ea960d0b601aa780de95e07710fda6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:45:42 +0800 Subject: [PATCH 03/42] fix(workspace): give the compact-label row spec the pin/archive props --- packages/client/ui-workspace/tests/rows.client.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f9a4b2bcac..330b1e7cef 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -559,10 +559,10 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, pendingInteraction, running: false, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, updatedAt: 0, + hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={tEn} />) const row = screen.getByRole('treeitem') expect(screen.getByText('Needs input').nextElementSibling?.textContent).toBe(compactLabel) expect(screen.getByText('Needs input').nextElementSibling?.getAttribute('aria-hidden')).toBe('true') From 5474efe890631e89c12067b6661618a47d325d5e Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 16:12:30 +0800 Subject: [PATCH 04/42] fix(client): lighten the ellipsis glyph and adopt the Toast success tone --- packages/client/ui-primitives/src/icons/index.tsx | 6 +++--- .../src/client/rows/WorkspaceBrowser.module.css | 13 ------------- .../src/client/rows/WorkspaceBrowser.tsx | 4 ++-- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4333bf43a6..e122d60363 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -96,9 +96,9 @@ export const IconPanelLeftOutlineMedium = (props: IconProps) => ( const IconEllipsisOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index f0b960dea8..930f492b94 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -556,19 +556,6 @@ color: var(--dsw-alias-label-secondary); } -/* The design's ringed green check for the success toast; the Toast icon seat - defaults to the warning tint. */ -.toastIcon { - display: grid; - place-items: center; - width: 18px; - height: 18px; - border: 1.5px solid var(--dsw-alias-state-success-primary); - border-radius: 50%; - corner-shape: round; - color: var(--dsw-alias-state-success-primary); -} - @media (prefers-reduced-motion: reduce) { .wide { animation: none; diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 00d66ddfab..5b611d800b 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -13,7 +13,7 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, us import clsx from 'clsx' import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' import { - Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconCheckOutlineRegular, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, IconWarningOutlineRegular, @@ -1697,7 +1697,7 @@ export function WorkspaceBrowser({ key={`toast-${String(toast.seq)}`} text={toast.text} {...toast.tone === 'success' - ? { icon: } + ? { tone: 'success' as const } : { icon: }} {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} onDone={() => { setToast(null) }} From 94c62ea564c492c2c2873440606f46c0c6a0a959 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 16:56:01 +0800 Subject: [PATCH 05/42] fix(client): draw the sidebar new-chat glyph at the regular weight --- .../ui-sidebar/src/client/HeaderLeadingControls.tsx | 4 ++-- packages/client/ui-sidebar/src/client/SidebarRoot.tsx | 8 ++++++-- .../__snapshots__/sidebar-snapshot.client.spec.tsx.snap | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index e4af1e420d..58ab8104a4 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,6 +1,6 @@ /** macOS-desktop resident-header controls for the fully hidden sidebar. */ import { - IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, + IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the conversation header slot declarations. @@ -45,7 +45,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('session.new.label')} onClick={() => { startSession() }} > - +
diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index ea362a51f2..874e81ff3a 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - FishLogo, IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, + FishLogo, IconNewChatOutlineMedium, IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { @@ -245,7 +245,11 @@ export function SidebarRoot({ aria-label={t('session.new.label')} onClick={() => { startSession() }} > - + {/* The rail draws Regular: Medium's 1.3px stroke scaled to the rail's + larger glyph reads visibly heavier than the neighboring 1px icons. */} + {wide + ? + : } {wide && {t('session.new')}} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index ce6cdec05b..d3f6bbfc56 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -44,7 +44,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded data-content="47b5e433" fill="none" height="16" - stroke-width="1.3" + stroke-width="1" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" @@ -278,7 +278,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad data-content="47b5e433" fill="none" height="18" - stroke-width="1.3" + stroke-width="1" viewBox="0 0 16 16" width="18" xmlns="http://www.w3.org/2000/svg" From 9ca19474ab337acc662ee8a0a22fe27d2fdf6bcb Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 17:34:45 +0800 Subject: [PATCH 06/42] fix(workspace): tooltip the row hover actions and open the sliders glyph --- .../client/ui-primitives/src/icons/index.tsx | 8 +- .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/rows/Rows.tsx | 97 ++++++++++--------- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index e122d60363..4f926c7e00 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1559,10 +1559,10 @@ export const IconArchiveCheckOutlineMedium = (props: IconProps) => ( const IconSlidersTwoOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 7012e32b39..db48180ab3 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -64,6 +64,8 @@ export const zh = { 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', 'actions.session.aria': '会话“{name}”的操作', + 'actions.more': '更多操作', + 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.subagentsRunning.one': '{n} 个子代理运行中', @@ -152,6 +154,8 @@ export const en = { 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', 'actions.session.aria': 'Session actions for {name}', + 'actions.more': 'More actions', + 'actions.newSession': 'New session', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', 'status.subagentsRunning.one': '{n} subagent running', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index b4c924bdd9..b3e43d2ec3 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,7 +12,7 @@ import { HoverCard, IconAlarmClockOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, IconNewChatOutlineRegular, IconPinFillRegular, IconPinOutlineRegular, IconTrashOutlineRegular, - IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, + IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -176,25 +176,29 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog portal closeOnPointerLeave anchor={( - + + + )} /> )} - + + +
) @@ -581,41 +585,44 @@ export function SessionNodeItem({ portal closeOnPointerLeave anchor={( - + + + )} /> - {/* No tooltips on the hover actions: the row's hover card sits right - beside these buttons and the two bubbles collide; the actions - speak through their aria-labels and the resulting toasts. */} - - {/* Rightmost so it lands where the rest-state pin marker sits. */} - {!row.archived && ( + + + {/* Rightmost so it lands where the rest-state pin marker sits. */} + {!row.archived && ( + + + )} )} From 1d221f9723d4aad79517174b7fd094af5df22db5 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 18:16:16 +0800 Subject: [PATCH 07/42] fix(workspace): end-aligned action tooltips, slower hover cards, archived-row alignment --- .../ui-primitives/src/Tooltip.module.css | 10 +++ packages/client/ui-primitives/src/Tooltip.tsx | 12 +++- .../client/ui-workspace/src/client/locales.ts | 10 ++- .../ui-workspace/src/client/rows/Rows.tsx | 64 +++++++++---------- .../tests/host-home-staleness.client.spec.tsx | 4 +- .../ui-workspace/tests/rows.client.spec.tsx | 24 +++---- .../tests/workspace-browser.client.spec.tsx | 2 +- 7 files changed, 75 insertions(+), 51 deletions(-) diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 031c45f02f..926fb68557 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -31,6 +31,16 @@ transform: translate(-50%, -100%); } +/* End alignment: `left` carries the anchor's right edge, so the bubble sits + fully left of it instead of centering. */ +.bubble[data-side='bottom'][data-align='end'] { + transform: translateX(-100%); +} + +.bubble[data-side='top'][data-align='end'] { + transform: translate(-100%, -100%); +} + @keyframes tooltip-in { from { opacity: 0; } } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 6f41eb7044..688a411fb9 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -43,6 +43,9 @@ if (typeof window !== 'undefined') { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. * @param props.side - placement relative to the anchor (default 'right'). + * @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins + * the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered + * bubble would overlap); default 'center'. Ignored for side 'right'. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). @@ -53,7 +56,7 @@ if (typeof window !== 'undefined') { * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -154,7 +157,11 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // Every show starts from the requested side; the fit pass flips it only // where this anchor's position demands it. setPlacement(side) - setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom }) + setPos({ + x: side === 'right' ? r.right + 10 : align === 'end' ? r.right : r.left + r.width / 2, + top: r.top, + bottom: r.bottom, + }) announce(true) } const showAfterHoverDelay = () => { @@ -195,6 +202,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, ref={bubble} className={css.bubble} data-side={placement} + data-align={align} style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }} role="tooltip" > diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index db48180ab3..e721af231f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -64,7 +64,10 @@ export const zh = { 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', 'actions.session.aria': '会话“{name}”的操作', - 'actions.more': '更多操作', + 'actions.archive': '归档会话', + 'actions.unarchive': '取消归档', + 'actions.pin': '置顶会话', + 'actions.unpin': '取消置顶', 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', @@ -154,7 +157,10 @@ export const en = { 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', 'actions.session.aria': 'Session actions for {name}', - 'actions.more': 'More actions', + 'actions.archive': 'Archive', + 'actions.unarchive': 'Unarchive', + 'actions.pin': 'Pin', + 'actions.unpin': 'Unpin', 'actions.newSession': 'New session', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index b3e43d2ec3..e291547031 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -176,20 +176,18 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog portal closeOnPointerLeave anchor={( - - - + )} /> )} - + + + + )} @@ -502,7 +503,7 @@ export function SessionNodeItem({ className={clsx( css.sessionRow, selected && css.selected, menuOpen && css.menuOpen, row.archived && css.archived, - flat && !showStatus && !row.archived && css.flatSessionRowWithoutStatus, + flat && !showStatus && css.flatSessionRowWithoutStatus, drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter, )} role="treeitem" @@ -585,19 +586,17 @@ export function SessionNodeItem({ portal closeOnPointerLeave anchor={( - - - + )} /> - + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-align')).toBe('end') + expect(bubble.style.left).toBe('200px') + } finally { + spy.mockRestore() + } + }) + it('caps the bubble width where the label would otherwise slab across the surface', () => { render( diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index fb7a487585..ce6698be33 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -5,7 +5,7 @@ - button "Plugins" - text: Workspaces - button "Search sessions" -- textbox "Search sessions..." +- textbox "Search session names" - button "View options" - button "Add workspace" - tree "Sessions": diff --git a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md index 14104cb63f..c01c19bc4b 100644 --- a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md @@ -5,7 +5,7 @@ - button "插件" - text: 工作区 - button "搜索会话" -- textbox "搜索会话…" +- textbox "搜索会话名称" - button "视图选项" - button "添加工作区" - tree "会话": diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 9f0c91f1c3..dee04dc9e8 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -5,7 +5,7 @@ - button "Plugins" - text: Workspaces - button "Search sessions" -- textbox "Search sessions..." +- textbox "Search session names" - button "View options" - button "Add workspace" - tree "Sessions": diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 4312288efa..c04c99e14e 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -14,7 +14,6 @@ - paragraph: partial - text: Stopped - button "Copy" -- tooltip "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" From 7951cee0d5c2d7c0ac464e29a4092f8a0ace3a15 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 13:21:37 +0800 Subject: [PATCH 14/42] fix(web): render local images in Markdown previews --- apps/web/tests/document-preview.e2e.ts | 15 ++++++ .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 + .../ui-sidebar-documentpreview/README.zh.md | 2 + .../src/client/markdown/MarkdownBody.tsx | 11 ++-- .../src/client/markdown/path-images.ts | 29 +++++++++++ .../tests/markdown-body.client.spec.tsx | 41 +++++++++++++-- .../tests/markdown-path-images.client.spec.ts | 50 +++++++++++++++++++ .../markdown-registration.client.spec.tsx | 16 +++++- .../web/document-preview/document.expected.md | 1 + 10 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index e2fad15435..31b2fd06b6 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -169,7 +169,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '# Markdown smoke', '', 'Rendered from the workspace.', '', ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(), '# Markdown tail', + '', '![relative image](preview-images/local%20image.png)', + '', `![absolute image](<${join(cwd, 'tiny.png').replaceAll('\\', '/')}>)`, + '', '![reference image][local-image]', '', '[local-image]: preview-images/local%20image.png', ].join('\n') + await mkdir(join(cwd, 'preview-images')) const codeLines = [ ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`), 'const tail = "CODE_TAIL";', @@ -204,6 +208,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'), writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'), writeFile(join(cwd, 'tiny.png'), TINY_PNG), + writeFile(join(cwd, 'preview-images', 'local image.png'), TINY_PNG), writeFile(join(cwd, 'large.svg'), [ '', '', @@ -282,6 +287,15 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1) expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1) const tailHeading = await markdownTail.innerText() + const markdownImages: string[] = [] + for (const alt of ['relative image', 'absolute image', 'reference image']) { + const image = preview.getByRole('img', { name: alt, exact: true }) + await image.scrollIntoViewIfNeeded() + await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true) + const source = new URL(await image.getAttribute('src') ?? '') + expect(source.pathname).toBe('/api/file') + markdownImages.push(alt) + } await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded() await successShot(page, 'markdown') const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) }) @@ -314,6 +328,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '## Markdown', '', `- Heading: ${heading}`, `- Tail loaded by scrolling: ${tailHeading}`, + `- Loaded images: ${markdownImages.join(' | ')}`, `- Viewers: ${markdownViewers.join(' -> ')}`, `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`, ].join('\n')) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6227c0e111..6f0e375dcb 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 2a42b50684239da216c65cd2378256fcf7760299 -README.zh.md: 6e40ceca2f96d1fc2ab1989dfb4985d04584c2aa +README.md: e7360434961ce480d2e97cb2ef41685b8cd8e14f +README.zh.md: 7a60e3a94b931ed50e37214a9c2c63ed5ce6cbbb diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 2a42b50684..e736043496 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -40,6 +40,8 @@ Document implementations register metadata with `ctx.documentPreviews.register({ A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address. +After all text pages load, Markdown images use the authenticated `/api/file` route for absolute file paths and paths relative to the source document's directory. Relative images wait for the Host's absolute document path. URL escapes are decoded once; query and fragment suffixes are excluded from the filename. HTTP(S) images retain their authored URLs, and failed image loads show alt text. Local images require an HTTP(S) application base URL; image files are not added to automatic-refresh dependencies. + ## How it reads diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 6e40ceca2f..7a60e3a94b 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -40,6 +40,8 @@ kind: "package-reference" tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。 +全部文本分页加载完成后,Markdown 图片通过已鉴权的 `/api/file` 路由读取绝对文件路径,以及相对于源文档所在目录的路径。相对图片等待 Host 返回文档绝对路径。URL 转义只解码一次;查询参数与片段后缀不计入文件名。HTTP(S) 图片保留原始 URL,加载失败的图片显示 alt 文本。本地图片要求应用基址为 HTTP(S) URL;图片文件不加入自动刷新依赖。 + ## 怎么读 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx index 7e76f1b5ab..f6164af6f3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -1,9 +1,10 @@ /** One retained Markdown renderer over the document owner's accumulated text. */ import { useMemo } from 'react' import type { ReactNode } from 'react' -import { MarkdownText, type MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { DocumentPreviewProps } from '../document/contract.ts' +import { markdownImageUrl } from './path-images.ts' import type {} from './locales.ts' import css from './MarkdownBody.module.css' @@ -15,7 +16,11 @@ export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMark * @param props - owner-loaded contents and localized primitive labels. * @returns Markdown content, or nothing for a non-text delivery. */ -export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode { +export function MarkdownBody({ content, resourceAddress, useResource, t }: MarkdownBodyProps): ReactNode { + const absolutePath = useResource<'file'>(resourceAddress).value?.absolutePath + const pathImages = useMemo(() => ({ + resolve: value => markdownImageUrl(document.baseURI, absolutePath, value), + }), [absolutePath]) const copyLabel = t('code.copy') const copiedLabel = t('code.copied') const footnotes = t('footnotes') @@ -25,7 +30,7 @@ export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode { if (content.kind !== 'text') return null return (
- +
) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts new file mode 100644 index 0000000000..e70f3cfaf8 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts @@ -0,0 +1,29 @@ +/** Local Markdown image destinations served by the authenticated file route. */ +import { isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' + +/** + * Build a file URL, resolving relative destinations beside the previewed file. + * @param base - document base URI, including any deployment prefix. + * @param documentPath - absolute source path reported by the Host, when available. + * @param destination - authored Markdown image URL; query and fragment are not filename components. + * @returns an HTTP(S) file URL, or undefined for unsupported or malformed destinations. + */ +export function markdownImageUrl(base: string, documentPath: string | undefined, destination: string): string | undefined { + if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined + const suffix = destination.search(/[?#]/u) + let path: string + try { + path = decodeURIComponent(suffix === -1 ? destination : destination.slice(0, suffix)) + } catch (_error) { + // Malformed percent escapes do not identify a local file. + return undefined + } + if (path.length === 0 || path.includes('\0') || /^[/\\]{2}/u.test(path)) return undefined + const windowsDrive = /^[a-z]:[/\\]/iu.test(path) + if (!windowsDrive && /^[a-z][a-z\d+.-]*:/iu.test(path)) return undefined + if (!isAbsoluteWorkspacePath(path)) { + if (documentPath === undefined) return undefined + path = pathPartsOf(documentPath).directory + path + } + return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href +} diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx index 73ba98815a..cd5d9c9b2a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Markdown preview uses one accumulated document across page arrivals and EOF. */ import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { MarkdownBody, type MarkdownBodyProps } from '../src/client/markdown/MarkdownBody.tsx' import { en, zh } from '../src/client/markdown/locales.ts' @@ -20,12 +20,45 @@ function content(pageTexts: readonly string[], eof: boolean): DocumentContent { return { kind: 'text', text: pageTexts.join('\n'), pages, eof } } -// The body reads content and locale only; the other standard seats belong to the slot integration tests. -function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate(en)): MarkdownBodyProps { - return { resourceAddress: 'dsh-resource://file/session/markdown/notes.md', content: value, wrap: false, t } as MarkdownBodyProps +// Unused framework seats belong to the slot integration tests. +function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate(en), absolutePath?: string): MarkdownBodyProps { + return { + resourceAddress: 'dsh-resource://file/session/markdown/notes.md', content: value, wrap: false, t, + useResource: () => ({ + status: absolutePath === undefined ? 'loading' : 'live', + value: absolutePath === undefined ? undefined : { absolutePath, version: 'v1' }, + failure: undefined, + }), + } as MarkdownBodyProps } describe('MarkdownBody', () => { + it('loads local images beside the Host document and retains external images and failure text', () => { + const text = '![relative](images/a.png) ![absolute](/tmp/a.png) ![external](https://example.test/a.png)' + const view = render() + expect(new URL(view.getByAltText('relative').getAttribute('src')!).searchParams.get('path')) + .toBe('/work/guide/images/a.png') + expect(new URL(view.getByAltText('absolute').getAttribute('src')!).searchParams.get('path')).toBe('/tmp/a.png') + expect(view.getByAltText('external').getAttribute('src')).toBe('https://example.test/a.png') + fireEvent.error(view.getByAltText('relative')) + expect(view.getByText('relative').tagName).toBe('SPAN') + }) + + it('resolves reference images at EOF and updates their directory when metadata changes', () => { + const first = '![figure][image]' + const second = '\n[image]: images/a.png' + const view = render() + view.rerender() + expect(view.container.querySelector('img')).toBeNull() + view.rerender() + expect(view.container.querySelector('img')).toBeNull() + for (const directory of ['/work/guide', '/other']) { + view.rerender() + expect(new URL(view.getByAltText('figure').getAttribute('src')!).searchParams.get('path')) + .toBe(`${directory}/images/a.png`) + } + }) + it('renders GFM headings, tables, task lists, strikeout, and localized code and footnote chrome', () => { const text = [ '# Notes', '', '| Item | Value |', '| --- | --- |', '| a | 1 |', '', diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts new file mode 100644 index 0000000000..347105504a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { markdownImageUrl } from '../src/client/markdown/path-images.ts' + +const BASE = 'https://example.test/tools/dsh/' + +describe('Markdown preview image URLs', () => { + it.each([ + ['images/a.png', '/work/guide/images/a.png'], + ['./images/a.png', '/work/guide/./images/a.png'], + ['../a.png', '/work/guide/../a.png'], + ['/tmp/a.png', '/tmp/a.png'], + ['images/中文%20图.png?size=2#detail', '/work/guide/images/中文 图.png'], + ['images/a%23b%3Fc%25.png', '/work/guide/images/a#b?c%.png'], + ])('serves %s through the deployment file route', (destination, path) => { + const url = new URL(markdownImageUrl(BASE, '/work/guide/notes.md', destination)!) + expect(url.origin + url.pathname).toBe(`${BASE}api/file`) + expect(url.searchParams.get('path')).toBe(path) + expect(url.hash).toBe('') + }) + + it.each([ + ['C:\\work\\guide\\notes.md', 'images/a.png', 'C:\\work\\guide\\images/a.png'], + ['C:/work/guide/notes.md', 'C:/images/a.png', 'C:/images/a.png'], + ['C:/work/guide/notes.md', 'C:\\images\\a.png', 'C:\\images\\a.png'], + ])('preserves filesystem spelling from %s', (documentPath, destination, path) => { + expect(new URL(markdownImageUrl(BASE, documentPath, destination)!).searchParams.get('path')).toBe(path) + }) + + it('waits for document metadata only for relative images', () => { + expect(markdownImageUrl(BASE, undefined, 'a.png')).toBeUndefined() + expect(new URL(markdownImageUrl(BASE, undefined, '/tmp/a.png')!).searchParams.get('path')).toBe('/tmp/a.png') + }) + + it.each(['', '#figure', '?size=2', '//cdn.test/a.png', '\\\\server\\a.png', 'file:///tmp/a.png', + 'javascript:alert(1)', 'data:image/png;base64,AAAA', 'https://example.test/a.png', 'C:a.png', '%ZZ.png', 'a%00.png', + ])('does not rewrite unsupported destination %j', (destination) => { + expect(markdownImageUrl(BASE, '/work/notes.md', destination)).toBeUndefined() + }) + + it.each(['about:blank', 'dsh-app://app/', 'file:///app', 'ws://localhost/'])( + 'keeps the file route unavailable for %s', (base) => { + expect(markdownImageUrl(base, '/work/notes.md', 'a.png')).toBeUndefined() + }, + ) + + it('retains the deployment prefix when the base includes an HTML filename', () => { + expect(markdownImageUrl(`${BASE}index.html`, '/work/notes.md', 'a.png')) + .toBe(`${BASE}api/file?path=${encodeURIComponent('/work/a.png')}`) + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx index 1441718b91..fadc7cbf61 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom /** Markdown metadata, deferred slot registration, localization, and unload through the real renderer. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { act } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' +import { apply as resourcesApply, inject as resourcesInject } from '@deepseek-ai/dsh-client-resources/src/client/index.ts' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -47,6 +48,13 @@ describe('Markdown implementation registration', () => { await runtime.sessions.add({ id: sessionId }) const reference = runtime.sessions.retainFor(runtime.ctx, sessionId) await reference.ready + await runtime.mount({ inject: [...resourcesInject], apply: resourcesApply }) + runtime.ctx.effect(() => runtime.ctx.resources.register({ + protocol: 'file', + open: async function* () { + yield { ok: true as const, value: { absolutePath: '/work/guide/notes.md', version: 'v1' } } + }, + })) const feature = await runtime.mount({ inject: ['slots', 'locale', 'documentPreviews'], apply }) expect(previews.getSnapshot().map(definition => definition.id)).toEqual([MARKDOWN_BODY_ID]) const useTabInfo = vi.fn(() => { throw new Error('Markdown rendering does not need tab actions') }) @@ -58,7 +66,7 @@ describe('Markdown implementation registration', () => { {renderSlot('sidebar.right.tab.document', { resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md', - content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true }, + content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```\n\n![diagram](images/a.png)', pages: [], eof: true }, wrap: false, scrollportRef: vi.fn(), addResource: vi.fn(), setResources: vi.fn(), @@ -72,6 +80,10 @@ describe('Markdown implementation registration', () => { const view = runtime.renderRoot() expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined() expect(view.getByRole('button', { name: 'Copy' })).toBeDefined() + await waitFor(() => { + expect(new URL(view.getByAltText('diagram').getAttribute('src')!).searchParams.get('path')) + .toBe('/work/guide/images/a.png') + }) expect(runtime.slots.entries('sidebar.right.tab.document')).toHaveLength(1) const t = locale.bind('documentMarkdown') await act(async () => { locale.setLocale('zh') }) diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 587815d634..e92c8cf01f 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -11,6 +11,7 @@ - Heading: Markdown smoke - Tail loaded by scrolling: Markdown tail +- Loaded images: relative image | absolute image | reference image - Viewers: Markdown -> Code -> Plain text - Same tab: true From d7e523b8b8069962aaebb7141db0d134541dcafa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:49:47 +0800 Subject: [PATCH 15/42] perf(session): traverse stored JSON arrays by index Cold current-generation reopen freezes the complete decoded event graph before publishing shared-frozen history. Compact assistant streams carry 125,000 text values and 124,600 time deltas in the required benchmark; for-in traversal enumerates their array indexes as string property keys. An open-only profile attributes about 4.2 ms to complete event freezing. Use numeric indexing for arrays produced by JSON decoding and the fixed migration catalog. Those arrays contain indexed JSON values only. Keep every Object.freeze call and every nested-object visit, preserve the object-key path including own __proto__ and constructor data, and never infer deep immutability from a shallow Object.isFrozen result. No decoding, admission, relationship validation, or freezing moves past open(). The unchanged five-sample V4 reopen median falls from 22.6 to 17.7 ms on M4 Pro / Node 24.19. Restoring and rebuilding the exact old traversal gives 21.7 ms. First-history and resume also improve; Agent retained heap stays 4.5 MB and every 128 MB case completes. Keep the existing 63 ms gate, fixture dimensions, cold-process setup, and all other budgets unchanged. Local measurements establish removed work; hosted CI remains the required runner verdict. Cover nested opaque arrays and objects, special JSON keys, rejected mutations, and shared event identity through historical V3 and native V4 reads. Record the before/candidate/restored samples and the local-versus-CI limit in the existing performance note. --- ...04-session-open-performance-gate.i18n.yaml | 4 +- ...026-09-04-session-open-performance-gate.md | 16 +++++++ ...-09-04-session-open-performance-gate.zh.md | 16 +++++++ .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 15 +++++-- .../tests/jsonl.spec.ts | 45 +++++++++++++++++++ 8 files changed, 94 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml index f25fc6d416..2eb9f0f8ea 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.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/testing/2026-09-04-session-open-performance-gate.md -2026-09-04-session-open-performance-gate.md: eaf7c6045bca12329d81cf9c968fb191229b0add -2026-09-04-session-open-performance-gate.zh.md: e9bf13460aafbd962a1d15b7a7d240f0b4be6b4e +2026-09-04-session-open-performance-gate.md: 1b2b0aabb79aceb0ac515a1d90ba3e2ba042535c +2026-09-04-session-open-performance-gate.zh.md: 9ad56796aac6310977aedfff0f9188e46cbbf3d7 diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md index eaf7c6045b..1b2b0aabb7 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md @@ -78,6 +78,22 @@ The calibrated source budgets are: | Client-fold delta scaling | 2.5× | 3.125× | | Constrained old space | — | 128 MB | +### Indexed traversal of stored JSON arrays + +The JSONL backend freezes decoded event graphs before memoization. Its native decoder and fixed migration catalog produce acyclic JSON graphs whose arrays contain only indexed values. Numeric array traversal preserves freezing of every nested object and array while avoiding string-key enumeration across the compact stream's 125,000 text entries and 124,600 time deltas. Object traversal still preserves own `__proto__` and `constructor` data. No admission, relationship validation, decoding, or freezing moves beyond the measured open endpoint, and a shallow `Object.isFrozen()` result never skips descendant traversal. + +On an Apple M4 Pro with Node 24.19.0, an open-only CPU profile attributes about 4.2 ms to complete event freezing. Separate fresh-process V4 runs give these results; the final column restores and rebuilds the exact original traversal as a negative control: + +| Reopen measurement | Original traversal | Indexed traversal | Original restored | +|---|---|---|---| +| `open` samples (ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 | +| Median `open` (ms) | 22.6 | 17.7 | 21.7 | +| Median first history (ms) | 35.0 | 30.6 | 33.6 | +| Median Agent resume (ms) | 30.5 | 26.8 | 29.8 | +| Agent retained heap (MB) | 4.5 | 4.5 | 4.5 | + +Each run passes all 17 Session cases, including the 128 MB completion checks. The original implementation remains below 63 ms on this local machine; the restored traversal demonstrates the removed cost, while the hosted benchmark remains the required budget verdict. The workload, time limits, memory limits, cold-process setup, and measurement endpoints are unchanged. The JSONL owner tests cover nested opaque JSON, rejected mutation, and immutable event sharing through historical V3 and current reads. These measurements establish a reopen improvement; they do not establish a consistent first-open improvement or hosted-runner timing. + ## Alternatives considered **Check out the historical commit and compare it on every CI run.** Rejected because a historical checkout requires a separate install, and old and current revisions can assign work to different API phases, adding runtime, dependency, and interface drift. A fixed workload with static budgets calibrated against positive and negative controls is easier to reproduce and review. diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md index e9bf13460a..9ad56796aa 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md @@ -78,6 +78,22 @@ PR #3640 的标准双 CPU 运行 (run 34023970384, job 101461539961)使用 Node | Client fold delta 缩放比 | 2.5× | 3.125× | | 受限 old space | — | 128 MB | +### 按索引遍历持久化 JSON 数组 + +JSONL backend 在 memo 化前冻结解码后的 event graph。原生 decoder 与固定 migration catalog 产出无环 JSON graph,其中数组只包含索引值。按数字索引遍历数组仍会冻结每个嵌套对象和数组,同时避免枚举 compact stream 中 125,000 个文本条目和 124,600 个时间差值的字符串键。对象遍历继续保留自有的 `__proto__` 和 `constructor` 数据。Admission、关系校验、解码与冻结都仍在被测 open 终点前完成;浅层 `Object.isFrozen()` 结果不会跳过后代遍历。 + +在 Apple M4 Pro、Node 24.19.0 上,仅覆盖 open 的 CPU profile 将约 4.2 ms 归因于完整 event 冻结。独立的全新进程 V4 运行得到以下结果;末列恢复并重新构建完全相同的原始遍历,作为负对照: + +| Reopen 测量项 | 原始遍历 | 索引遍历 | 恢复原始遍历 | +|---|---|---|---| +| `open` 样本(ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 | +| `open` 中位数(ms) | 22.6 | 17.7 | 21.7 | +| 首屏历史中位数(ms) | 35.0 | 30.6 | 33.6 | +| Agent resume 中位数(ms) | 30.5 | 26.8 | 29.8 | +| Agent 保留堆(MB) | 4.5 | 4.5 | 4.5 | + +每次运行的全部 17 个 Session 用例均通过,包括 128 MB 完成性检查。原始实现在本机仍低于 63 ms;恢复原始遍历展示了被移除的成本,而托管 benchmark 仍负责给出必须满足的预算结果。工作负载、时间上限、内存上限、冷进程准备与测量终点保持不变。JSONL owner 测试覆盖历史 V3 和当前读取中的嵌套不透明 JSON、修改拒绝及不可变 event 共享。这些测量确认了 reopen 改善,但没有确认一致的 first-open 改善或托管运行器耗时。 + ## 考虑过的替代方案 **每次 CI checkout 历史提交并做相对比较。** 拒绝:历史 checkout 需要独立安装,旧版与当前版还可能把工作放在不同 API 阶段,增加时间、依赖和接口漂移。固定 workload 与经正反例校准的静态预算更容易复现和评审。 diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 97101e98da..80fa6c7755 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 9b2464e6330cfb5c256f18060814330c7637635e -README.zh.md: 22c0adb2d78efc3fee077ec68f8d43de422aa1a9 +README.md: 1570e93abeb0e515fcaa474c566fa3041922f08c +README.zh.md: 032e50c7e25f2a1f1def21c8fb0850e32f6109f3 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 9b2464e633..1570e93abe 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -79,7 +79,7 @@ The current-generation scanner applies the current codec owner’s structural ad ### Reading the logs -`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. +`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Unavailable migration facts report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 22c0adb2d7..032e50c7e2 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -79,7 +79,7 @@ kind: "package-reference" ### 读取日志 -`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 +`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。迁移事实不足会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 771393b73c..6e7e7b5628 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -134,7 +134,7 @@ interface PreparedStoredLog extends StoredLogBase { /** A validated logical log, either durable current state or prepared historical state. */ type StoredLog = CurrentStoredLog | PreparedStoredLog -/** Deep-freeze one acyclic stored JSON event without recursive calls. */ +/** Deep-freeze acyclic stored JSON; its arrays contain only indexed JSON values. */ function freezeStoredEvent(event: SessionEvent): void { const pending: object[] = [event] while (pending.length > 0) { @@ -142,9 +142,16 @@ function freezeStoredEvent(event: SessionEvent): void { // oxlint-disable-next-line typescript/no-non-null-assertion const current = pending.pop()! Object.freeze(current) - for (const key in current) { - const child = (current as Record)[key] - if (child !== null && typeof child === 'object') pending.push(child) + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index += 1) { + const child: unknown = current[index] + if (child !== null && typeof child === 'object') pending.push(child) + } + } else { + for (const key in current) { + const child = (current as Record)[key] + if (child !== null && typeof child === 'object') pending.push(child) + } } } } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index fecfd95c89..d57a4d91a1 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -698,6 +698,51 @@ describe('JsonlSessionPersistence: immutable format generations', () => { afterEach(async () => { await ctx.fiber.dispose() }) + it.each([3, SESSION_FORMAT_VERSION])('shares deeply frozen opaque JSON from format v%s', async (version) => { + type NestedValue = { + values: [null, boolean, number, string, unknown[]] + __proto__: { leaf: number } + constructor: { leaf: number } + } + const data = JSON.parse('{"nested":[[{"values":[null,true,7,"text",[]],"__proto__":{"leaf":1},"constructor":{"leaf":2}}]]}') as { nested: [[NestedValue]] } + const header = meta(`frozen-json-v${version}`, '/work') + const path = generationLogPath(root, header.cwd, header.id, version, 'none') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, [ + { ...toHeaderLine(header), version }, + { type: 'external/frozen-json', seq: 0, time: 1, ignorable: true, data }, + ].map(row => JSON.stringify(row) + '\n').join('')) + + const handle = await ctx.sessionPersistence.open(header.id, 'read') + try { + const read = await handle.read() + expect(read.eventState).toBe('shared-frozen') + expect(read.events).toHaveLength(1) + const event = read.events[0] as SessionEvent + const actual = event.data as unknown as typeof data + const nested = actual.nested[0][0] + expect(actual).toEqual(data) + expect(Object.getPrototypeOf(nested)).toBe(Object.prototype) + expect(Object.hasOwn(nested, '__proto__')).toBe(true) + expect(Object.hasOwn(nested, 'constructor')).toBe(true) + expect([ + event, actual, actual.nested, actual.nested[0], nested, nested.values, + nested.values[4], nested.__proto__, nested.constructor, + ].every(Object.isFrozen)).toBe(true) + expect(Reflect.set(nested.__proto__, 'leaf', 9)).toBe(false) + expect(Reflect.set(nested.constructor, 'leaf', 9)).toBe(false) + expect(() => nested.values[4].push('changed')).toThrow(TypeError) + + const reread = await handle.read() + expect(reread.events).not.toBe(read.events) + expect(reread.events[0]).toBe(event) + expect(reread.events[0]?.data).toBe(actual) + expect(actual).toEqual(data) + } finally { + await handle.close() + } + }) + it('projects a released v0 header through stat and list without reading or mutating its body', async () => { const header = meta('released-v0-metadata', '/work') const sourcePath = historicalLogPath(root, header.cwd, header.id) From df0145271d6cb88c44cd77a5a1b79da58256601d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:23:17 +0800 Subject: [PATCH 16/42] fix(session): preserve opaque checkpoint keys on domain reopen The projection-cache writer preserves lossless JSON, including own keys named __proto__. The z.json reader rebuilds objects and drops those keys, so opening the same stored checkpoint can produce state different from Session replay. This affects embedded result metadata and opaque extension payloads as well as other projection state. Validate with the existing isJsonValue predicate used by the writer's snapshotJsonValue rules. It rejects non-JSON and lossy values without rebuilding valid borrowed records. Keep the stored representation and domain version unchanged: this is a reader correction, not a new cache format. Exercise a fixture captured through the actual pre-fix writer with a real StorageDomain open, rewrite, close and reopen. Preserve nested prototype-named keys and retain negative coverage for invalid JSON values. Document why this validation matches the storage-domain immutable borrowed-value contract. Use neutral arbitrary JSON in the new opaque-state fixture so this reader regression stays independent of Session message representation. Its own prototype-named keys and nested arrays are unchanged in purpose. Confirm the fixture matches the pre-fix StorageDomain writer output and that the old z.json reader fails its key-preservation assertion. No released Session recordings or historical cache fixtures are edited. --- ...sless-projection-checkpoint-json.i18n.yaml | 6 ++ ...-19-lossless-projection-checkpoint-json.md | 22 ++++++ ...-lossless-projection-checkpoint-json.zh.md | 22 ++++++ .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/src/spec.ts | 9 ++- .../tests/fixtures.spec.ts | 56 +++++++++++++- .../tests/fixtures/v7-opaque-session-doc.json | 76 +++++++++++++++++++ 9 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md create mode 100644 .agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md create mode 100644 packages/session/session-projection-cache/tests/fixtures/v7-opaque-session-doc.json diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml new file mode 100644 index 0000000000..c05ca67333 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.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/architecture/2026-09-19-lossless-projection-checkpoint-json.md +2026-09-19-lossless-projection-checkpoint-json.md: 9ee1b428b894469a195d8c7a361868591749e201 +2026-09-19-lossless-projection-checkpoint-json.zh.md: 4eb10c80be6bb99bcd284a1a731da1128179ed82 diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md new file mode 100644 index 0000000000..9ee1b428b8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md @@ -0,0 +1,22 @@ +# Agent Note: Lossless projection checkpoint JSON + +Status: implemented + +English | [中文](2026-09-19-lossless-projection-checkpoint-json.zh.md) + +## Problem + +Projection checkpoints can contain opaque extension data and message metadata. A JSON key named `__proto__` is ordinary recorded data. The Zod JSON parser drops that own key while rebuilding objects, so reopening a valid checkpoint can yield different projection state from replaying the Session log. + +## Decision + +The checkpoint value schema uses the existing `isJsonValue` predicate from `dsh-util-values`. It enforces the same lossless JSON rules as the checkpoint writer's `snapshotJsonValue` without rebuilding valid objects. Validation still rejects non-JSON and lossy values. Storage-domain table values are immutable borrowed records; the validator does not supply a defensive-copy guarantee. + +## Alternatives considered + +- **Keep `z.json()`.** Its object reconstruction removes valid own keys, so a successful validation can still change the checkpoint value. +- **Validate and clone with `snapshotJsonValue`.** This preserves keys but copies already immutable stored values. The read-only predicate matches the storage-domain borrowed-value contract. + +## Consequences + +Every valid own key survives domain reopen, including nested `__proto__` and `constructor` properties. The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md new file mode 100644 index 0000000000..4eb10c80be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md @@ -0,0 +1,22 @@ +# Agent Note: 无损投影检查点 JSON + +Status: implemented + +[English](2026-09-19-lossless-projection-checkpoint-json.md) | 中文 + +## Problem + +投影检查点可以包含不透明扩展数据和消息元数据。名为 `__proto__` 的 JSON 键是普通记录数据。Zod JSON 解析器在重建对象时丢弃该自有键,因此重新打开有效检查点可能得到与回放 Session 日志不同的投影状态。 + +## Decision + +检查点值 schema 使用 `dsh-util-values` 中既有的 `isJsonValue` 谓词。它执行与检查点写入器 `snapshotJsonValue` 相同的无损 JSON 规则,不重建有效对象。校验仍拒绝非 JSON 和有损值。Storage-domain 表值是不可变的借用记录;校验器不提供防御性复制保证。 + +## Alternatives considered + +- **保留 `z.json()`。** 其对象重建会移除有效自有键,因此校验成功仍可能改变检查点值。 +- **用 `snapshotJsonValue` 校验并复制。** 这能保留键,但会复制已经不可变的存储值。只读谓词符合 storage-domain 的借用值约定。 + +## Consequences + +每个有效自有键都在 domain 重新打开后保留,包括嵌套的 `__proto__` 和 `constructor` 属性。存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。 diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 6b96623f7e..630e0b2a4a 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: 3125f50b07f222feb0ef2af5e28abcfae19631a7 -README.zh.md: c98cda187dd898a0b30a9721c0bc7412a91964c5 +README.md: ab2336a7d13dada307322a96fffb9d6900bd5411 +README.zh.md: 20275354ff8c7c5cb6c058899873e1dc5199d37a diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 3125f50b07..ab2336a7d1 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -62,7 +62,7 @@ Three mandatory points always write: session creation persists the seed-derived ### What the cache guarantees -The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. +The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. State validation preserves every own JSON key, including `__proto__` and `constructor` inside opaque metadata. It rejects values that cannot survive a lossless JSON round trip and uses the same rules as checkpoint writes. Upgrades never block startup or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing or older `formatVersion` never matches a current Session and therefore cannot seed hydration. A lifecycle-matching predecessor title remains available only through the listing hint above because title text is invariant across the adjacent Session-format edges and its row still passes the current projection `stateVersion` and schema. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index c98cda187d..20275354ff 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -62,7 +62,7 @@ kind: "package-reference" ### 缓存保证什么 -日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 +日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 状态校验保留所有自有 JSON 键,包括不透明元数据中的 `__proto__` 与 `constructor`。它拒绝无法无损完成 JSON 往返的值,并与检查点写入使用相同规则。 升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失或更旧的 `formatVersion` 绝不匹配当前 Session,因此不能作为 hydrate seed。生命周期匹配的 predecessor title 只能通过上述列表 hint 读取,因为 title 文本在相邻 Session format edge 之间保持不变,并且该 row 仍须通过当前 projection `stateVersion` 与 schema。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index a94ed5a493..12bd558616 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -11,6 +11,8 @@ */ import { z } from 'zod' +import { isJsonValue } from '@deepseek-ai/dsh-util-values' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' @@ -18,8 +20,9 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' /** * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` * minus the two record keys). `val` is the unit's internal state — plain - * JSON by the unit contract; `z.json()` enforces that at the durable - * boundary. A row is never wrong, only possibly stale: `seq` says exactly + * JSON by the unit contract. Validation uses the same lossless JSON rules as + * writes and preserves every state key without cloning. A row is never wrong, + * only possibly stale: `seq` says exactly * how stale, and a `ver` mismatch against the live unit's `stateVersion` * discards it at read time (never a migration). */ @@ -27,7 +30,7 @@ export const checkpointRow = z.object({ ver: z.number().int().nonnegative(), seq: z.number().int().gte(-1).transform((value): SessionSeqCursor => value === -1 ? -1 : SessionSeq(value)), - val: z.json(), + val: z.custom(isJsonValue, { message: 'checkpoint state must be losslessly JSON-serializable' }), }) /** diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 4377fadcf2..a537e3b108 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -9,7 +9,8 @@ * through the real storage stack without becoming a fold shortcut for the * current Session format, then accept a current checkpoint rewrite. A record * that fails schema validation is backed up and skipped instead of failing the - * boot. + * boot. The V7 opaque-state fixture was captured through the real storage + * domain before replacing the checkpoint state's cloning JSON validator. */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -31,7 +32,7 @@ import { apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName, } from '@deepseek-ai/dsh-storage-domain' import SessionProjectionCache from '../src/index.ts' -import { projectionCacheDomainSpec } from '../src/spec.ts' +import { checkpointRow, projectionCacheDomainSpec } from '../src/spec.ts' // Declarations must match the shipped title unit's exactly (the repo-wide // compile face sees both). @@ -95,13 +96,18 @@ function headerFor(id: SessionId, identity: FixtureDoc['record']['identity']): S const contexts: Context[] = [] const roots: string[] = [] -async function harness(root: string) { - roots.push(root) +async function storageHarness(root: string) { + if (!roots.includes(root)) roots.push(root) const ctx = new Context() contexts.push(ctx) await ctx.plugin(Storage) await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) + return ctx +} + +async function harness(root: string) { + const ctx = await storageHarness(root) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(titleUnit) @@ -145,6 +151,48 @@ afterEach(async () => { }) describe('archived version recovery', () => { + it('preserves every opaque key through an archived V7 read, current rewrite, and StorageDomain reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-opaque-')) + const id = SessionId('opaque-fixture') + const archived = await placeDoc(root, id, 'v7-opaque-session-doc.json') + expect(archived.version).toBe(7) + const ctx = await storageHarness(root) + const domain = await ctx.storageDomain.open(projectionCacheDomainSpec) + const record = domain.table('sessions').get(id)! + expect(JSON.stringify(record.rows)).toBe(JSON.stringify(archived.record.rows)) + const rewritten = { ...record, identity: { ...record.identity, formatVersion: SESSION_FORMAT_VERSION } } + await domain.table('sessions').put(id, rewritten) + await ctx.fiber.dispose() + contexts.splice(contexts.indexOf(ctx), 1) + + const reopenedCtx = await storageHarness(root) + const reopened = await reopenedCtx.storageDomain.open(projectionCacheDomainSpec) + const restored = reopened.table('sessions').get(id)! + expect(JSON.stringify(restored)).toBe(JSON.stringify(rewritten)) + expect(JSON.stringify(restored.rows)).toBe(JSON.stringify(archived.record.rows)) + const onDisk = JSON.parse(await readFile(join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`), 'utf8')) as FixtureDoc + expect(onDisk.version).toBe(7) + expect(JSON.stringify(onDisk.record.rows)).toBe(JSON.stringify(archived.record.rows)) + }) + + it.each([ + ['undefined', undefined], ['function', () => {}], ['symbol', Symbol('opaque')], ['bigint', 1n], + ['nonfinite number', Number.NaN], ['infinity', Number.POSITIVE_INFINITY], + ['class instance', new Date(0)], ['map', new Map([['saved', true]])], + ])('rejects non-JSON checkpoint state: %s', (_name, val) => { + expect(checkpointRow.safeParse({ ver: 1, seq: 0, val }).success).toBe(false) + }) + + it('rejects cycles and JSON-lossy nested values without changing valid state objects', () => { + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + for (const val of [cyclic, { nested: { value: undefined } }, [undefined], [, 'hole'], { negativeZero: -0 }]) { + expect(() => checkpointRow.parse({ ver: 1, seq: 0, val })).toThrow() + } + const val = JSON.parse('{"__proto__":{"saved":true},"constructor":{"saved":false},"nested":{"__proto__":[null,true,4,"text"]}}') as Record + expect(checkpointRow.parse({ ver: 1, seq: 0, val }).val).toBe(val) + }) + it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`)) diff --git a/packages/session/session-projection-cache/tests/fixtures/v7-opaque-session-doc.json b/packages/session/session-projection-cache/tests/fixtures/v7-opaque-session-doc.json new file mode 100644 index 0000000000..5bf96ab142 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v7-opaque-session-doc.json @@ -0,0 +1,76 @@ +{ + "version": 7, + "record": { + "identity": { + "formatVersion": 3, + "createdAt": 42, + "isSeeded": false, + "inheritedEventCount": 0 + }, + "rows": { + "opaque-values": { + "ver": 1, + "seq": 0, + "val": { + "__proto__": { + "retained": "state" + }, + "constructor": { + "retained": "state-constructor" + }, + "records": [ + { + "kind": "opaque-record", + "record": { + "category": "sample", + "key": "saved-record", + "entries": [ + { + "kind": "opaque-extension", + "name": "vendor", + "data": { + "__proto__": { + "retained": "extension" + }, + "constructor": { + "retained": "extension-constructor" + }, + "nested": [ + { + "__proto__": { + "retained": "nested" + }, + "value": [ + null, + false, + true, + 0, + 7, + "text" + ] + } + ] + } + } + ], + "metadata": { + "__proto__": { + "retained": "result" + }, + "constructor": { + "retained": "result-constructor" + }, + "metadata": { + "__proto__": { + "retained": "nested-result" + } + } + } + } + } + ] + } + } + } + } +} From cc970738d8129ee2ad1797a69bd081d37fc73cf2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:43:08 +0800 Subject: [PATCH 17/42] docs(session): scope checkpoint JSON preservation to domain reads The cache reader preserves opaque JSON keys, but each projection still parses the returned checkpoint through its own stateSchema before hydration. State that ownership explicitly so documentation does not imply this reader fix also changes projection-defined transformations or cloning behavior. Distinguish the synthetic V7 StorageDomain regression fixture from released cache archives. Describe its read, put and reopen path directly, and group its JSON validation checks separately from archived-version recovery. Translate the Chinese Agent Note headings and keep the paired docs aligned. The note retains the borrowed-value tradeoff and links the independent predecessor-format and lifecycle-identity decision. Production expressions and all test assertions remain unchanged; only contracts, fixture descriptions, test labels and grouping are clarified. The fix does not add another persistence format or broaden migration scope. --- ...ssless-projection-checkpoint-json.i18n.yaml | 4 ++-- ...9-19-lossless-projection-checkpoint-json.md | 4 +++- ...9-lossless-projection-checkpoint-json.zh.md | 12 +++++++----- .../session-projection-cache/README.i18n.yaml | 4 ++-- .../session/session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/src/spec.ts | 4 ++-- .../tests/fixtures.spec.ts | 18 ++++++++++-------- 8 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml index c05ca67333..a66f2afe77 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.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/architecture/2026-09-19-lossless-projection-checkpoint-json.md -2026-09-19-lossless-projection-checkpoint-json.md: 9ee1b428b894469a195d8c7a361868591749e201 -2026-09-19-lossless-projection-checkpoint-json.zh.md: 4eb10c80be6bb99bcd284a1a731da1128179ed82 +2026-09-19-lossless-projection-checkpoint-json.md: 9c7d15dbd2d570eaa544f168d2c2ef882558d5ca +2026-09-19-lossless-projection-checkpoint-json.zh.md: 8c2b720358351af33a6d07888002c65d0cc30363 diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md index 9ee1b428b8..9c7d15dbd2 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md @@ -19,4 +19,6 @@ The checkpoint value schema uses the existing `isJsonValue` predicate from `dsh- ## Consequences -Every valid own key survives domain reopen, including nested `__proto__` and `constructor` properties. The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. +Domain reads preserve every valid own key in checkpoint values, including nested `__proto__` and `constructor` properties. A projection's `stateSchema` still owns its hydration value; opaque fields must use validation that preserves their keys. The borrowed-value validator does not create an independent copy or project to JSON Schema. + +The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a synthetic fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. [Predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md) continue to own cache-version and identity compatibility. diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md index 4eb10c80be..8c2b720358 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md @@ -4,19 +4,21 @@ Status: implemented [English](2026-09-19-lossless-projection-checkpoint-json.md) | 中文 -## Problem +## 问题 投影检查点可以包含不透明扩展数据和消息元数据。名为 `__proto__` 的 JSON 键是普通记录数据。Zod JSON 解析器在重建对象时丢弃该自有键,因此重新打开有效检查点可能得到与回放 Session 日志不同的投影状态。 -## Decision +## 决策 检查点值 schema 使用 `dsh-util-values` 中既有的 `isJsonValue` 谓词。它执行与检查点写入器 `snapshotJsonValue` 相同的无损 JSON 规则,不重建有效对象。校验仍拒绝非 JSON 和有损值。Storage-domain 表值是不可变的借用记录;校验器不提供防御性复制保证。 -## Alternatives considered +## 备选方案 - **保留 `z.json()`。** 其对象重建会移除有效自有键,因此校验成功仍可能改变检查点值。 - **用 `snapshotJsonValue` 校验并复制。** 这能保留键,但会复制已经不可变的存储值。只读谓词符合 storage-domain 的借用值约定。 -## Consequences +## 影响 -每个有效自有键都在 domain 重新打开后保留,包括嵌套的 `__proto__` 和 `constructor` 属性。存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。 +Domain 读取保留检查点值中的每个有效自有键,包括嵌套的 `__proto__` 和 `constructor` 属性。Projection 的 `stateSchema` 仍拥有其 hydration 值;不透明字段必须使用保留其键的校验器。借用值校验器不生成独立副本,也不投影为 JSON Schema。 + +存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的合成 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。[前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md) 继续负责缓存版本与身份兼容性。 diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 630e0b2a4a..9945e2d6dd 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: ab2336a7d13dada307322a96fffb9d6900bd5411 -README.zh.md: 20275354ff8c7c5cb6c058899873e1dc5199d37a +README.md: 0417601210b697983993f770688a4e944d518790 +README.zh.md: 32dcbd93c8f18e504ca3a350586860b2e5cbd5b6 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index ab2336a7d1..0417601210 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -62,7 +62,7 @@ Three mandatory points always write: session creation persists the seed-derived ### What the cache guarantees -The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. State validation preserves every own JSON key, including `__proto__` and `constructor` inside opaque metadata. It rejects values that cannot survive a lossless JSON round trip and uses the same rules as checkpoint writes. +The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. Domain read validation preserves every own JSON key in checkpoint values, including `__proto__` and `constructor` inside opaque metadata. It rejects values that cannot survive a lossless JSON round trip, using the same rules as checkpoint writes. Each projection then validates its hydration state with its own `stateSchema`; fields carrying opaque JSON need a validator that preserves their keys. Upgrades never block startup or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing or older `formatVersion` never matches a current Session and therefore cannot seed hydration. A lifecycle-matching predecessor title remains available only through the listing hint above because title text is invariant across the adjacent Session-format edges and its row still passes the current projection `stateVersion` and schema. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 20275354ff..32dcbd93c8 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -62,7 +62,7 @@ kind: "package-reference" ### 缓存保证什么 -日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 状态校验保留所有自有 JSON 键,包括不透明元数据中的 `__proto__` 与 `constructor`。它拒绝无法无损完成 JSON 往返的值,并与检查点写入使用相同规则。 +日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。Domain 读取校验保留检查点值中的所有自有 JSON 键,包括不透明元数据中的 `__proto__` 与 `constructor`。它拒绝无法无损完成 JSON 往返的值,并与检查点写入使用相同规则。随后,每个 projection 用自己的 `stateSchema` 校验 hydration 状态;承载不透明 JSON 的字段需要使用保留其键的校验器。 升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失或更旧的 `formatVersion` 绝不匹配当前 Session,因此不能作为 hydrate seed。生命周期匹配的 predecessor title 只能通过上述列表 hint 读取,因为 title 文本在相邻 Session format edge 之间保持不变,并且该 row 仍须通过当前 projection `stateVersion` 与 schema。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 12bd558616..c8bcda38b8 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -22,8 +22,8 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' * minus the two record keys). `val` is the unit's internal state — plain * JSON by the unit contract. Validation uses the same lossless JSON rules as * writes and preserves every state key without cloning. A row is never wrong, - * only possibly stale: `seq` says exactly - * how stale, and a `ver` mismatch against the live unit's `stateVersion` + * only possibly stale: `seq` says exactly how stale, and a `ver` mismatch + * against the live unit's `stateVersion` * discards it at read time (never a migration). */ export const checkpointRow = z.object({ diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index a537e3b108..3b3de927d6 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -1,16 +1,16 @@ /** - * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds - * real `session_projcache` media, each produced by driving the named release - * through its own web app (session created over RPC, real model turns, a - * rename): the v3 whole-unit file (published 0.1.1-rc.2), a v4 per-record + * Cross-version recovery and lossless checkpoint JSON validation. The released + * `session_projcache` fixtures were captured through their own web app + * (session created over RPC, real model turns, a rename): the v3 whole-unit + * file (published 0.1.1-rc.2), a v4 per-record * document (published 0.1.2-alpha.3), a published v5 document, and the * v5-stamped lineage-less document reproducing byte-for-byte what the * formerly unguarded legacy bootstrap wrote over v3 records. Each must open * through the real storage stack without becoming a fold shortcut for the * current Session format, then accept a current checkpoint rewrite. A record * that fails schema validation is backed up and skipped instead of failing the - * boot. The V7 opaque-state fixture was captured through the real storage - * domain before replacing the checkpoint state's cloning JSON validator. + * boot. The synthetic V7 fixture comes from the real StorageDomain per-record + * writer and contains opaque keys and arrays, independent of Session messages. */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -150,8 +150,8 @@ afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) }) -describe('archived version recovery', () => { - it('preserves every opaque key through an archived V7 read, current rewrite, and StorageDomain reopen', async () => { +describe('checkpoint JSON preservation', () => { + it('preserves opaque keys through StorageDomain read, put, and reopen', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-opaque-')) const id = SessionId('opaque-fixture') const archived = await placeDoc(root, id, 'v7-opaque-session-doc.json') @@ -192,7 +192,9 @@ describe('archived version recovery', () => { const val = JSON.parse('{"__proto__":{"saved":true},"constructor":{"saved":false},"nested":{"__proto__":[null,true,4,"text"]}}') as Record expect(checkpointRow.parse({ ver: 1, seq: 0, val }).val).toBe(val) }) +}) +describe('archived version recovery', () => { it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`)) From bf29247fad8b0f5e9cbc11186a31e68c92f7087a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:35:16 +0800 Subject: [PATCH 18/42] fix(session): namespace historical V3 extensions with plugin prefixes V3 permits external event, source and content identities and additional request-tool fields. Copying those values into V4 can give an old plugin named tool a core source identity, turn old tool-addition content into a registry instruction, or activate formerly ignored deferLoading and delivery metadata. Rejecting such collisions would instead refuse valid source logs. Keep historical extensions in the existing extensible JSON representation. Prefix unknown content and ignorable event tags with plugin:, preserving all other content fields and opaque event payloads. Prefix external source identities with plugin:plugin: or plugin:source: so wrapper and direct kinds cannot collide even when original names already contain a category prefix. Known first-party mappings remain explicit and unchanged. Prefix extra request-tool field names and block-start field names with plugin:, retaining exact values and existing-prefix doubling. Core schema parameters, arguments, replay state and nested plugin data are not traversed. Keep future-generation delivery records inactive after header promotion. Pin the V3 event vocabulary independently of the current writer and provide an offline verifier against an explicitly selected historical source commit. Attachment authorization and export inspect declared event content only. Team checkpoints preserve all own keys of unknown JSON blocks while still rejecting malformed known content and retired core tool-result blocks. Exercise physical read/write/reopen, prefix injectivity, own __proto__ keys, provider opacity and retained source-identity snapshots. No generic extension block, metadata wrapper or new schema-tooling type is introduced. V4 type declarations, persistence schema, checkpoint and frozen V0-V3 codecs remain unchanged. Canonical tool-result lifting and incomplete child-catalog policy remain separate concerns. Adapted from the extension-preservation work in 7ba7e174dd1 by Tianyi Cui. Co-authored-by: Tianyi Cui <53024+tianyicui@users.noreply.github.com> --- ...9-producer-owned-message-sources.i18n.yaml | 4 +- ...26-09-09-producer-owned-message-sources.md | 2 +- ...09-09-producer-owned-message-sources.zh.md | 2 +- ...7-persistence-attribution-policy.i18n.yaml | 4 +- ...26-09-17-persistence-attribution-policy.md | 2 +- ...09-17-persistence-attribution-policy.zh.md | 2 +- .../adding-a-session-format-version.i18n.yaml | 4 +- .../adding-a-session-format-version.md | 14 ++ .../adding-a-session-format-version.zh.md | 14 ++ docs/session-format-status.i18n.yaml | 4 +- docs/session-format-status.md | 2 + docs/session-format-status.zh.md | 2 + package.json | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/api/session-controller/package.json | 1 + .../api/session-controller/src/commands.ts | 57 +++-- .../commands-queue-attachment.host.spec.ts | 90 +++++++- .../tests/fixtures/serial-created.mjs | 2 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 + packages/experimental/agent-team/README.zh.md | 2 + .../experimental/agent-team/src/projection.ts | 20 +- .../tests/projection-events.spec.ts | 29 +++ .../llm/llm-deepseek/tests/adapter.spec.ts | 18 ++ .../tests/messages/fixtures/input-history.ts | 4 +- .../tests/messages/serialize.spec.ts | 5 + .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 2 + .../session-log-export/README.zh.md | 2 + .../session-log-export/src/archive.ts | 47 ++-- .../tests/archive.host.spec.ts | 70 +++++- .../tests/search-helpers.spec.ts | 1 + .../session-format-catalog/README.i18n.yaml | 4 +- .../session/session-format-catalog/README.md | 2 +- .../session-format-catalog/README.zh.md | 2 +- .../session-format-catalog/src/historical.ts | 6 +- .../tests/catalog.spec.ts | 17 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 28 ++- .../session-format-v3-to-v4/README.zh.md | 28 ++- .../session-format-v3-to-v4/src/content.ts | 102 +++++++++ .../src/extension-identities.ts | 93 ++++++++ .../session-format-v3-to-v4/src/index.ts | 1 + .../session-format-v3-to-v4/src/migration.ts | 16 +- .../session-format-v3-to-v4/src/sources.ts | 48 +++-- .../tests/catalog-completion.spec.ts | 6 +- .../tests/content.spec.ts | 200 ++++++++++++++++++ .../tests/message-sources.spec.ts | 2 +- .../tests/migration.spec.ts | 14 +- .../tests/retired-content.spec.ts | 2 +- .../tests/sources.spec.ts | 40 +++- .../tests/generation.spec.ts | 6 +- .../tests/migration-refusal.spec.ts | 75 ++++++- .../session-snapshot/tests/normalize.spec.ts | 26 ++- pnpm-lock.yaml | 3 + .../advanced/result.json | 12 +- .../advanced/session.1.v4.jsonl | 4 +- .../advanced/session.2.v4.jsonl | 4 +- .../advanced/session.v4.jsonl | 4 +- scripts/verify-v3-event-vocabulary.spec.ts | 124 +++++++++++ scripts/verify-v3-event-vocabulary.ts | 105 +++++++++ 63 files changed, 1221 insertions(+), 182 deletions(-) create mode 100644 packages/session/session-format-v3-to-v4/src/content.ts create mode 100644 packages/session/session-format-v3-to-v4/src/extension-identities.ts create mode 100644 packages/session/session-format-v3-to-v4/tests/content.spec.ts create mode 100644 scripts/verify-v3-event-vocabulary.spec.ts create mode 100644 scripts/verify-v3-event-vocabulary.ts diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml index b049c7a0ce..2ecbfbff83 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.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/architecture/2026-09-09-producer-owned-message-sources.md -2026-09-09-producer-owned-message-sources.md: 484a082607a02d469c63e17d66dc7726b3a5453a -2026-09-09-producer-owned-message-sources.zh.md: 4fc80b66b9f1c918a52e03d636224d363edefb1b +2026-09-09-producer-owned-message-sources.md: f2d3ce0f403bc521b853945a2461210fa33e368b +2026-09-09-producer-owned-message-sources.zh.md: d617de9f07026f00dfc4d83e9b1adb1f36f61d4f diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md index 484a082607..f2d3ce0f40 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md @@ -10,7 +10,7 @@ Released V3 writers stamped every plugin-produced message with the shared wrappe ## Decision -Message sources are producer-owned: the producer identity is the `kind` itself, declared per plugin through `MessageSourceMap` augmentation, and the released `plugin` field no longer exists in current rows. The V3-to-V4 migration rewrites released plugin attribution onto producer kinds with a frozen rename table in `packages/session/session-format-v3-to-v4/src/sources.ts`: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; any other plugin string keeps its own name as the kind. The wrapper drops without losing identity, context-form fields survive, and tool results lift from released user-role wrapper rows into first-class tool-role messages in the same migration. Rename lookup uses only own keys, and reconstruction preserves every own JSON metadata property, including `__proto__`; legal external producer names cannot resolve to JavaScript prototype members. +Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:plugin:` before the complete original name, and unknown direct source kinds receive `plugin:source:`. The distinct namespaces preserve identity without letting an old plugin named `user` acquire the human-user kind or collide with an old direct kind. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data. Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules. diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md index 4fc80b66b9..d617de9f07 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -消息源是生产者归属的:生产者身份就是 `kind` 本身,由各插件通过 `MessageSourceMap` 声明合并,当前行中不再存在已发布的 `plugin` 字段。V3→V4 迁移用 `packages/session/session-format-v3-to-v4/src/sources.ts`中的冻结重命名表把已发布 plugin 归属改写到生产者 kind:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;其它 plugin 字符串保留自身名字作为 kind。包装丢弃时不丢失身份,上下文形态字段保留,工具结果在同一迁移中从已发布的 user-role 包装行提升为一等 tool-role 消息。重命名查找只读取自有键,重建时保留所有自有 JSON 元数据属性,包括 `__proto__`;合法的外部生产者名字不会被解析为 JavaScript 原型成员。 +消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:plugin:`,未知的直接 source kind 则添加 `plugin:source:`。两个命名空间在保留身份的同时,防止名为 `user` 的旧插件获得人类用户 kind,或与旧的直接 kind 冲突。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。 原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.i18n.yaml index d2cae2128b..e2b272908e 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.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/architecture/2026-09-17-persistence-attribution-policy.md -2026-09-17-persistence-attribution-policy.md: 0fbf5ba83a65e71e7fba1c1c223931aa0962b944 -2026-09-17-persistence-attribution-policy.zh.md: 235e4dba19951a2bd34ad397e09f449ee5c4708d +2026-09-17-persistence-attribution-policy.md: 19bebc8bc186f4b2b7e2e561fb6a53ceadcebabf +2026-09-17-persistence-attribution-policy.zh.md: 3561c1d1afca5402cb94b1578603c4f1a2fc89a4 diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md index 0fbf5ba83a..19bebc8bc1 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md +++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md @@ -16,7 +16,7 @@ The tmux-context producer qualifies its location attribution. Its own projection `RequestMessage` accepts a durable `Message` or a user-only `RequestUserInput` with content and no `id` or `source`. The LLM service and provider serializers accept both; Session writes, Agent delivery, and persisted title-request inputs require durable messages. Auto Review's outer prompt and the compaction summarizer's final instruction use request-only inputs and retain their content and freezing. The caller receives no implicit durable conversion. Assistant replay metadata and tool-result correlation retain their existing requirements. -The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its frozen rename and collision rules, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules). +The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its producer mappings and extension namespaces, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules). ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md index 235e4dba19..3561c1d1af 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md @@ -16,7 +16,7 @@ tmux-context 生产者将位置归属标记为符合条件。其自身投影使 `RequestMessage` 接受持久化 `Message`,或仅含 content、没有 `id` 和 `source` 的 user 专用 `RequestUserInput`。LLM 服务和 provider 序列化器接受两者;Session 写入、Agent 交付及持久化标题请求输入要求持久化消息。Auto Review 的外层提示和压缩摘要器的最终指令使用仅供请求使用的输入,并保留内容与冻结行为。调用方不会获得隐式持久化转换。Assistant 回放元数据和工具结果关联保持原有要求。 -已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留冻结的重命名和冲突规则,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。 +已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留生产者映射与扩展命名空间,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。 ## 考虑过的替代方案 diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index 6cdc7c28c8..ef175a001a 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 4ea2f28d3e4164302bb23cad2a69b21f7720a299 -adding-a-session-format-version.zh.md: 4944ef2dc04028d383f355f25f048533afce60ed +adding-a-session-format-version.md: 472bc944261474e913abecff302fcd5cef474d14 +adding-a-session-format-version.zh.md: fe4a97fa3f90027c7914aaa575866bedb4e3a483 diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 4ea2f28d3e..472bc94426 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -109,6 +109,20 @@ pnpm run lint git diff --check ``` + +### Final V3 event vocabulary before V4 publication + +While the published format remains V3 and the integration writer is V4, master can still add valid V3 event types. After each master integration and before the first V4 publication, compare the migration-owned `RELEASED_V3_EVENT_TYPES` with the latest V3-writer commit used by that refresh. Capture its full immutable id while the locally verified `origin/master` still writes V3: + +```sh +V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')" +pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF" +``` + +The verifier reads only that local commit and requires exact event-name equality. It rejects missing V3 names, extra copied names, and a source writer other than V3. In particular, V4-only `developer/message` must not enter the frozen V3 set. Review each difference against the V3 payload, required conversions, target admission, and consumers; adding the name alone does not establish a complete migration. Add the corresponding migration regression before accepting a new source event. + +This command does not fetch or establish remote freshness. The release operator must verify that the selected commit is the latest V3 writer included in the integration; an old remote-tracking ref or passing result for an older pin is insufficient. Record the checked id with release validation. If master already writes V4, reuse the recorded final V3 commit rather than resolving current master. After V4 ships, keep the V3 vocabulary tied to that historical source; later V4 event additions do not update it. Regular static CI has no dependency on a mutable master ref. + ## Dev Note diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 4944ef2dc0..fe4a97fa3f 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -109,6 +109,20 @@ pnpm run lint git diff --check ``` + +### 发布 V4 前确认最终 V3 事件词汇 + +当已发布格式仍为 V3、集成写入方为 V4 时,master 仍可能新增有效的 V3 事件类型。每次集成 master 后及首次发布 V4 前,都要把迁移所有的 `RELEASED_V3_EVENT_TYPES` 与该次刷新使用的最新 V3 写入方提交比较。在已于本地核验的 `origin/master` 仍写入 V3 时,记录其完整、不可变的提交 id: + +```sh +V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')" +pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF" +``` + +校验器只读取该本地提交,并要求事件名完全一致。它拒绝遗漏的 V3 名称、额外复制的名称,以及不写入 V3 的源提交。尤其不能把 V4 专有的 `developer/message` 放入冻结的 V3 集合。逐项核对 V3 payload、必要转换、目标接纳及消费方;只添加名称不能证明迁移完整。接受新源事件前要补上相应迁移回归。 + +此命令不会 fetch,也不能证明远端新鲜度。发布操作者必须确认所选提交是此次集成包含的最新 V3 写入方;陈旧的远端跟踪 ref 或旧 pin 的通过结果均不足。把核验过的 id 记入发布验证。如果 master 已写入 V4,应复用已记录的最终 V3 提交,而不再解析当前 master。V4 发布后,V3 词汇仍绑定该历史源码;后续 V4 事件新增不更新它。常规静态 CI 不依赖可变的 master ref。 + ## 开发备注 diff --git a/docs/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml index 9969adec1a..00b72e5e97 100644 --- a/docs/session-format-status.i18n.yaml +++ b/docs/session-format-status.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 docs/session-format-status.md -session-format-status.md: 7ca2c4b23e6b8f186098d3114ec25d4b17c351a2 -session-format-status.zh.md: 4a6b73a8263d78735bd553b51d3ce01152fc652b +session-format-status.md: 9df313da2fad277254f464a8bdf6a6c8b541aab1 +session-format-status.zh.md: 31e1710632b07a1f64a5bc528bf0827c5af47af9 diff --git a/docs/session-format-status.md b/docs/session-format-status.md index 7ca2c4b23e..9df313da2f 100644 --- a/docs/session-format-status.md +++ b/docs/session-format-status.md @@ -36,6 +36,8 @@ V4 has an accepted compatibility baseline in the [checkpoint](persistence-change Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline. +Before the first V4 publication, every integration of a newer V3-writing master must pass the [explicit V3 vocabulary check](cookbook/adding-a-session-format-version.md#final-v3-vocabulary) against the recorded local source commit. Verify the source pin’s freshness and review new event payload conversions before updating the migration-owned set. After publication, the final V3 vocabulary remains historical and independent of current V4 additions. + ## Release record diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md index 4a6b73a826..31e1710632 100644 --- a/docs/session-format-status.zh.md +++ b/docs/session-format-status.zh.md @@ -36,6 +36,8 @@ V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized 定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。 +首次发布 V4 前,每次集成仍写入 V3 的较新 master 都必须对已记录的本地源提交执行[显式 V3 词汇校验](cookbook/adding-a-session-format-version.zh.md#final-v3-vocabulary)。更新迁移所有的集合前,要核验源 pin 的新鲜度并审查新事件 payload 的转换。发布后,最终 V3 词汇保持为历史定义,与当前 V4 新增事件无关。 + ## 发布记录 diff --git a/package.json b/package.json index 5f150e9fdd..1694e6044e 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,7 @@ "verify-persistence-releases": "tsx scripts/persistence-releases.ts", "verify-persistence-formats": "tsx scripts/persistence-formats.ts", "gen-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts", + "verify-v3-event-vocabulary": "tsx scripts/verify-v3-event-vocabulary.ts", "verify-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts --check", "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8ceb4a8da4..ef274f24bf 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 228ed88ed20b9f9581496468e47079afd1542e4f -README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a +README.md: de20b2bacf0b563404b66f716c3ffdca01cb8058 +README.zh.md: cc94e573b0bc5d52323d06d010c12457749b1914 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 228ed88ed2..de20b2bacf 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -54,6 +54,8 @@ A resume blocked by an existing write handle returns `session/writer-held` with Queue edits replace pending content with non-empty text only. +Attachment authorization inspects only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data cannot authorize attachment reads. + ## Client references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index edebf8bdda..cc94e573b0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -54,6 +54,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 队列编辑仅允许用非空文本替换待处理内容。 +附件授权只检查已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据不能授权读取附件。 + ## Client 引用 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 643c553e0a..e45f6b120d 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -141,6 +141,7 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-format-catalog": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index fa456772de..4323315694 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -620,19 +620,16 @@ function imageBlockIn( if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } + const block = value as { readonly type?: unknown; readonly attachment?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef if (match(ref)) return ref } - if (block.type === 'tool-result') { - const nested = imageBlockIn(block.content, match) - if (nested !== undefined) return nested - } } return undefined } +/** Read only first-party declared content fields; unknown event payloads stay opaque. */ function imageInEvent( event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean, @@ -640,21 +637,45 @@ function imageInEvent( const data = event.data as { readonly content?: unknown readonly message?: { readonly content?: unknown } - readonly inserted?: readonly { readonly content?: unknown }[] + readonly inserted: readonly { readonly content?: unknown }[] + readonly messages: readonly { readonly content?: unknown }[] + readonly summary?: unknown + readonly rawOutput?: unknown } - const direct = imageBlockIn(data.content, match) - if (direct !== undefined) return direct - const message = imageBlockIn(data.message?.content, match) - if (message !== undefined) return message - for (const inserted of data.inserted ?? []) { - const found = imageBlockIn(inserted.content, match) - if (found !== undefined) return found - } - if (event.type === 'assistant/message' || event.type === 'assistant/attempt') { - for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) - if (found !== undefined) return found + // First-party event payloads can be present without their producer plugin mounted. + const type: string = event.type + switch (type) { + case 'user/message': + case 'tool/ptc-dispatch': + return imageBlockIn(data.content, match) + case 'system/message': + case 'developer/message': + case 'tool/result': + case 'team/message/queued': + return imageBlockIn(data.message?.content, match) + case 'agent/inbox/spliced': + case 'session/title-llm-request': { + const messages = type === 'agent/inbox/spliced' ? data.inserted : data.messages + for (const message of messages) { + const found = imageBlockIn(message.content, match) + if (found !== undefined) return found + } + return undefined } + case 'compaction/summary': + return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) + case 'assistant/message': { + const found = imageBlockIn(data.message?.content, match) + if (found !== undefined) return found + break + } + case 'assistant/attempt': break + default: return undefined + } + const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> + for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { + const found = imageBlockIn([chunk.block], match) + if (found !== undefined) return found } return undefined } diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 3f1a45967d..2dd7454c37 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -10,6 +10,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' @@ -316,7 +317,8 @@ function imageRef(id: string): ImageAttachmentRef { } function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - return { type, seq, time: seq + 1, data } as SessionEvent + const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) + return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent } async function persistedController( @@ -348,16 +350,90 @@ async function persistedController( } describe('Session attachment authorization', () => { - it('finds references in direct, message, inserted, nested, and streamed content', async () => { + it.each([ + ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], + ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], + ['session/title-llm-request', 'messages'], ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], + ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { + const ref = imageRef(`${type}-${field}`) + const content = [{ type: 'image', attachment: ref }] + const source = type === 'system/message' ? { kind: 'system-prompt' } + : type === 'tool/result' ? { kind: 'tool', callId: 'declared-call' } : { kind: 'test' } + const data = field === 'message' ? { message: { + id: 'declared-message', role: type.split('/')[0], content, source, + ...type === 'tool/result' ? { toolCallId: 'declared-call' } : {}, + } } + : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } + const events = [event(type, SessionSeq(0), data)] + const saved = JSON.stringify(events) + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController(events, readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) + expect(JSON.stringify(events)).toBe(saved) + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it('ignores unrelated fields of a known message event and its content blocks', async () => { + const ref = imageRef('not-a-content-occurrence') + const content = [{ type: 'image', attachment: ref }] + const stored = event('user/message', SessionSeq(0), { + id: 'metadata', role: 'user', source: { kind: 'user' }, + content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], + message: { content }, inserted: [{ content }], + }) + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController([stored], readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/attachment-invalid' }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it('denies attachment access from a migrated unknown ignorable event', async () => { + const ref = imageRef('opaque-event-image') + const content = [{ type: 'message', message: { + role: 'tool', toolCallId: 'opaque-call', content: [{ type: 'image', attachment: ref }], + } }] + const header = { type: 'session', version: 3, id: 'cold-attachment', createdAt: 1, isSeeded: false, delegationDepth: 0 } + const original = { type: 'external/image-record', seq: 0, time: 1, ignorable: true, data: { + content, message: { content }, inserted: [{ content }], + stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], + } } + const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' }) + historical.decodeRow(original) + expect(historical.finish().header.version).toBe(3) + const reader = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' }) + reader.decodeRow(original) + const converted = reader.finish() + expect(converted.events[0]).toMatchObject({ type: 'plugin:external/image-record', data: original.data }) + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController(converted.events as SessionEvent[], readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it('finds references in direct, message, inserted, and streamed content', async () => { const nested = imageRef('nested') const message = imageRef('message') const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events: SessionEvent[] = [ - { ...event('fixture/direct', SessionSeq(0), { - content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { - type: 'tool-result', content: [{ type: 'image', attachment: nested }], - }], + { ...event('user/message', SessionSeq(0), { + id: 'direct', role: 'user', source: { kind: 'user' }, + content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: nested }], }), ignorable: true as const }, { type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', @@ -451,7 +527,7 @@ describe('Session attachment authorization', () => { ]) { const ref = imageRef(`failure-${thrown.name}`) const fixture = await persistedController( - [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })], + [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], () => Promise.reject(thrown), ) await expectFailure(fixture.controller.attachment({ diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index ee18fd04a1..a2a0bb1b72 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -14,7 +14,7 @@ export function apply(ctx) { id: randomUUID(), role: 'user', content: [{ type: 'text', text: 'Serial agent creation completed.' }], - source: { kind: name }, + source: { kind: `plugin:plugin:${name}` }, }) ready.add(agent) }) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 47dbb4329a..028e62ac57 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 6e2b55018971ec96802de0e5be042d49b45c674a -README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5 +README.md: 280ea4aac70ce68da48d85dac6313c05b78a8c09 +README.zh.md: 8446da495cfc476d3620a0240e4c322604b56d3c diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6e2b550189..280ea4aac7 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -145,6 +145,8 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append. +Mailbox projection and checkpoint admission validate known content fields and preserve unknown plugin content as decoded JSON, including own `__proto__` keys. Unknown block fields remain opaque; retired tool-result content is rejected. + ### Disposal Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index b497d3790e..8446da495c 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -145,6 +145,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。 +邮箱投影和检查点准入校验已知内容字段,并按解码后的 JSON 保留未知插件内容,包括自有 `__proto__` 键。未知块字段保持不透明,退役的 tool-result 内容被拒绝。 + ### Dispose dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。 diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 85fdfd0e0d..6c1a6cb377 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -42,7 +42,7 @@ const imageAttachmentSchema = z.object({ // ContentBlockMap is merge-extensible. Validate every core variant exactly, // while retaining JSON-decoded plugin variants under an unknown type tag. -const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ +const contentBlockSchema: z.ZodType = z.union([ z.object({ type: z.literal('text'), text: z.string() }).strict(), z.object({ type: z.literal('reasoning'), text: z.string() }).strict(), z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(), @@ -52,17 +52,13 @@ const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ name: z.string(), arguments: z.string(), }).strict(), - z.object({ - type: z.literal('tool-result'), - toolCallId: z.string().min(1), - content: z.array(contentBlockSchema), - isError: z.boolean().optional(), - }).strict(), - z.object({ type: z.string().min(1) }).loose().refine( - block => !coreContentBlockTypes.has(block.type), - { message: 'known content block types must match their declared fields' }, - ), -])) as z.ZodType + // Unknown content is decoded JSON; validation must preserve its own extension keys. + z.custom((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const type = (value as { type?: unknown }).type + return typeof type === 'string' && type.length > 0 && !coreContentBlockTypes.has(type) + }), +]) as z.ZodType const teamMemberSnapshotSchema = z.object({ id: sessionIdSchema, diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 5f9728a979..4c1db3c3cd 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,3 +1,4 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' @@ -301,6 +302,34 @@ describe('Agent Teams projection events', () => { expect(pending(state)[0]?.content).toEqual([extension]) }) + it('preserves opaque extension keys through projection and checkpoints', () => { + const opaque = JSON.parse('{"__proto__":{"saved":true},"constructor":{"saved":false},"content":[{"type":"tool-result","toolCallId":"opaque","content":[]}]}') as Record + const content: ContentBlock[] = [{ ...opaque, type: 'plugin:vendor' } as unknown as ContentBlock] + const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content }) }, SessionSeq(0)) + const before = JSON.stringify(queued) + const projected = projectTeam(ROOT, [queued]) + expect(projected.messages[0]?.content).toEqual(content) + const checkpoint = teamProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(projected))) + expect(JSON.stringify(checkpoint)).toBe(JSON.stringify(projected)) + expect(JSON.stringify(queued)).toBe(before) + const block = checkpoint.messages[0]!.content[0] as unknown as Record + expect(Object.hasOwn(block, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(block)).toBe(Object.prototype) + }) + + it.each([ + null, [], 3, + { type: 'tool-result', toolCallId: 'retired', content: [] }, + { type: '' }, + { type: null }, + { type: 'text', text: false }, + ])('rejects malformed or retired current content in events and checkpoints: %j', (block) => { + const saved = message({ content: [block] as unknown as ContentBlock[] }) + const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: saved }, SessionSeq(0)) + expect(() => projectTeam(ROOT, [queued])).toThrow(/team\/message\/queued payload is invalid/) + expect(() => teamProjectionDefinition.stateSchema.parse({ ...project(ROOT, []), messages: [saved] })).toThrow() + }) + it('records unsupported event versions without applying them', () => { const invalid = event('team/task', { version: 1 as 2, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f3d48533ad..ea6ba2bf8f 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -425,6 +425,24 @@ describe('DeepSeekAdapter against a mock server', () => { expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) }) + it('keeps images inside plugin content opaque through adapter.stream', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ + createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:message', + message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, + } as never, + { type: 'text', text: 'context' }, + ] }), + ] })) + expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() + expect(server.fileRequests).toHaveLength(0) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ messages: [{ role: 'user', content: 'context' }] }) + }) + it('falls back to one all-base64 request when Files API resolution fails', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts index ca1d75e50b..229e3ea25b 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts +++ b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts @@ -8,7 +8,7 @@ import { serialize } from '../../../src/protocols/messages/serialize.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - 'messages-input-history-snapshot': { kind: 'messages-input-history-snapshot' } + 'plugin:plugin:messages-input-history-snapshot': { kind: 'plugin:plugin:messages-input-history-snapshot' } } } @@ -20,7 +20,7 @@ export function apply(ctx: Context): void { ctx.on('agent/pre-step', async (_input, next) => { const decision = await next() if (decision.kind !== 'enter') return decision - const source = { kind: name } as const + const source = { kind: `plugin:plugin:${name}` } as const return { ...decision, messages: [ ...decision.messages, createUserMessage({ source, content: [ diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts index 23e5d3c32c..dbb1424056 100644 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts @@ -23,6 +23,11 @@ const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) describe('Messages request conversion', () => { + it('rejects unknown plugin content without interpreting its payload', () => { + expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:text', text: 'opaque' } as never, + ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { for (const type of ['tool-addition', 'tool-removal'] as const) { const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index a2c80bc2d6..f475fe9c87 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87 -README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f +README.md: 7fa004df954260de16a6a151e3110e6adfbc0f09 +README.zh.md: 147ee93b3929473d4e78024ff0b4ccbd040a1220 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 79b8d572a7..7fa004df95 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -59,6 +59,8 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. +Attachment collection reads only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data remain opaque; the exported log retains them unchanged. + ### Failures The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index d622992713..147ee93b39 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -59,6 +59,8 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。 +附件收集只读取已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据保持不透明,导出的日志原样保留它们。 + ### 失败 当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 8728ca9878..3dbf5211e1 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -196,9 +196,8 @@ function fileEntryPath(ref: FileAttachmentRef): string { } /** - * Collect every attachment reference inside one content array, descending into - * nested tool results the way the live attachment route does. - * @param content - an event content array (or nested tool-result content). + * Collect attachment references from one flat content array; all other block fields remain opaque. + * @param content - a declared event content array. * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. */ @@ -213,7 +212,7 @@ function collectAttachmentRefs( while (pending.length > 0) { const value = pending.pop() if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + const block = value as { type?: unknown; attachment?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef images.set(String(ref.attachmentId), ref) @@ -222,16 +221,12 @@ function collectAttachmentRefs( const ref = block.attachment as FileAttachmentRef files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref) } - if (Array.isArray(block.content)) { - for (const item of block.content) pending.push(item) - } } } /** - * Collect every attachment reference one session event carries, across the same - * carriers the live attachment route scans (direct content, message content, - * inserted messages, and completed blocks in embedded Assistant streams). + * Collect references only from declared first-party content fields and completed + * Assistant blocks. Unknown events and unrelated payload fields remain opaque. * @param event - one parsed JSONL event object. * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. @@ -241,18 +236,40 @@ function collectEventAttachmentRefs( images: Map, files: Map, ): void { - const data = (event as { data?: unknown }).data + if (typeof event !== 'object' || event === null || Array.isArray(event)) return + const row = event as { type?: unknown; data?: unknown } + const data = row.data if (typeof data !== 'object' || data === null) return const carrier = data as { content?: unknown message?: { content?: unknown } inserted?: Array<{ content?: unknown }> + messages?: Array<{ content?: unknown }> + summary?: unknown + rawOutput?: unknown stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }> } - collectAttachmentRefs(carrier.content, images, files) - if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) - if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) + switch (row.type) { + case 'user/message': case 'tool/ptc-dispatch': + collectAttachmentRefs(carrier.content, images, files) + return + case 'system/message': case 'developer/message': case 'tool/result': case 'team/message/queued': + collectAttachmentRefs(carrier.message?.content, images, files) + return + case 'agent/inbox/spliced': case 'session/title-llm-request': { + const messages = row.type === 'agent/inbox/spliced' ? carrier.inserted : carrier.messages + for (const message of messages ?? []) collectAttachmentRefs(message.content, images, files) + return + } + case 'compaction/summary': + collectAttachmentRefs(carrier.summary, images, files) + collectAttachmentRefs(carrier.rawOutput, images, files) + return + case 'assistant/message': + collectAttachmentRefs(carrier.message?.content, images, files) + break + case 'assistant/attempt': break + default: return } if (carrier.stream !== undefined) { for (const record of carrier.stream) { diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 973dbe9d91..011896e79b 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -503,6 +503,15 @@ describe('session.export download endpoint', () => { expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('') }) + it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => { + const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }] + const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } }, + { type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n') + const files = await directZipFiles(text) + expect(Object.keys(files)).toEqual([exportLogName]) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text) + }) + it('waits for response pull capacity before reading the next archive entry', async () => { const filler = { type: 'user/message', seq: SessionSeq(2), time: 1000, @@ -797,10 +806,63 @@ describe('session.export download endpoint', () => { await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') }) - it('collects media referenced from nested tool results', async () => { + it.each([ + ['system/message', 'message'], ['developer/message', 'message'], ['team/message/queued', 'message'], + ['tool/ptc-dispatch', 'content'], ['session/title-llm-request', 'messages'], + ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], + ] as const)('exports attachments from the declared %s %s content', async (type, field) => { + const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] + const data = field === 'message' ? { message: { content } } + : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } + const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as unknown as SessionEvent]) + const api = await buildApi({ 'session-root': stored }) + const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/declared-image.png', exportLogName].sort()) + expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) + }) + + it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => { + const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] + const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, data: { + content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], + message: { content }, inserted: [{ content }], + stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }], + } } as unknown as SessionEvent + const stored = log('session-root', undefined, [event]) + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) + const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage }) + const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual([exportLogName]) + expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) + expect(readImage).not.toHaveBeenCalled() + }) + + it('exports an unknown ignorable event without reading attachments from its opaque payload', async () => { + const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } } + const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } } + const content = [{ type: 'message', message: { role: 'tool', toolCallId: 'opaque', content: [image, file] } }] + const opaque = { type: 'plugin:external/image-record', seq: SessionSeq(1), time: 1000, ignorable: true, data: { + content, message: { content }, inserted: [{ content }], + stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }], + } } as unknown as SessionEvent + const stored = log('session-root', undefined, [opaque]) + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) + const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) }) + const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream }) + const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual([exportLogName]) + expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) + expect(readImage).not.toHaveBeenCalled() + expect(readFileStream).not.toHaveBeenCalled() + }) + + it('collects media referenced from flat tool results', async () => { const nested = { - type: 'assistant/message', seq: SessionSeq(2), time: 2000, - data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] }, + type: 'tool/result', seq: SessionSeq(2), time: 2000, + data: { message: { role: 'tool', toolCallId: 'outer', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] } }, } as unknown as SessionEvent const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) }) const response = await toFetchHandler(api).fetch( @@ -818,7 +880,7 @@ describe('session.export download endpoint', () => { data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } }, } as unknown as SessionEvent const inserted = { - type: 'context/inserted', seq: SessionSeq(3), time: 3000, + type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000, data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] }, } as unknown as SessionEvent const attempt = { diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 9f87f266c3..ccbc4430a7 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -46,6 +46,7 @@ describe('session-query semantic extraction', () => { { type: 'reasoning', text: 'thought' }, { type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' }, { type: 'text', text: 'nested' }, + { type: 'plugin:text', text: 'opaque hidden', content: [{ type: 'text', text: 'hidden child' }] } as never, { type: 'future-content', payload: 'hidden' } as never, ] const events: SessionEvent[] = [ diff --git a/packages/session/session-format-catalog/README.i18n.yaml b/packages/session/session-format-catalog/README.i18n.yaml index 129dc4e8af..31246e1ed0 100644 --- a/packages/session/session-format-catalog/README.i18n.yaml +++ b/packages/session/session-format-catalog/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-catalog/README.md -README.md: 0666f13cef0a7f019ddd741666ee8c95f78af2b8 -README.zh.md: b0132d2543f7f5a3a1d9ca5958e5c0593f3320e1 +README.md: bb86bcaffee2c2e17afffd4ce7fbd917bf1c30ee +README.zh.md: 156361ae330967169ae82e204ddeff26c50b05bd diff --git a/packages/session/session-format-catalog/README.md b/packages/session/session-format-catalog/README.md index 0666f13cef..bb86bcaffe 100644 --- a/packages/session/session-format-catalog/README.md +++ b/packages/session/session-format-catalog/README.md @@ -47,7 +47,7 @@ Production historical reads select `{ recovery: 'recoverable', validation: 'tran The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners. -`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 only for collecting child prerequisites without recursively completing their catalogs. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog. +`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 event vocabulary to collect child prerequisites without recursively completing their catalogs. An ignorable V3 extension remains opaque even when the installed writer knows its name. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog. ----- diff --git a/packages/session/session-format-catalog/README.zh.md b/packages/session/session-format-catalog/README.zh.md index b0132d2543..156361ae33 100644 --- a/packages/session/session-format-catalog/README.zh.md +++ b/packages/session/session-format-catalog/README.zh.md @@ -47,7 +47,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform 该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。 -`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 仅还原 V0–V3,用于收集子 Session 前置事实,避免递归补齐其目录。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。 +`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 事件词汇恢复 V0–V3,收集子日志前置事实而不递归补齐其目录。即使已安装写入方认识事件名,可忽略 V3 扩展仍保持不透明。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。 ----- diff --git a/packages/session/session-format-catalog/src/historical.ts b/packages/session/session-format-catalog/src/historical.ts index 9a29c06df6..e04434ce72 100644 --- a/packages/session/session-format-catalog/src/historical.ts +++ b/packages/session/session-format-catalog/src/historical.ts @@ -1,6 +1,6 @@ /** Historical restoration for collecting migration prerequisites without recursively opening current Sessions. */ -import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session' +import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4' import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format' import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1' import { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2' @@ -12,8 +12,8 @@ export const historicalSessionFormatCatalog = createSessionFormatCatalog({ codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec], currentEncoder: releasedV3SessionFormatCodec, migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3], - restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES), - restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES), + restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, RELEASED_V3_EVENT_TYPES), + restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, RELEASED_V3_EVENT_TYPES), restoreCurrentHeader(header) { assertReleasedV3Header(header) return header diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index fe5713cfd5..209f4aee7b 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -222,7 +222,7 @@ describe('first-party Session format catalog', () => { const ignorable = deepFreeze({ ...required, ignorable: true, data: { text: 'tools-code-mode', source: { kind: 'plugin', plugin: 'tools-code-mode' } } }) const accepted = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation }) accepted.decodeRow(ignorable) - expect(accepted.finish().events).toEqual([ignorable]) + expect(accepted.finish().events).toEqual([{ ...ignorable, type: `plugin:${type}` }]) } }) @@ -278,6 +278,21 @@ describe('first-party Session format catalog', () => { }) describe('historical child prerequisite catalog', () => { + it('uses the V3 event vocabulary for ignorable events whose names become known later', () => { + const header = { type: 'session', version: 3, id: 'old-extension', createdAt: 1, isSeeded: false, delegationDepth: 0 } + const rows = [ + { type: 'feedback/record', seq: 0, time: 1, data: { text: 'saved' } }, + { type: 'developer/message', seq: 1, time: 2, ignorable: true, surfaceOp: 'append', sourceEventSeqs: [0], + data: { content: [{ type: 'tool-result', toolCallId: 'opaque', content: [] }], vendor: true } }, + ] + const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' }) + for (const row of rows) historical.decodeRow(row) + expect(historical.finish().events).toEqual(rows) + const current = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' }) + for (const row of rows) current.decodeRow(row) + expect(current.finish().events).toEqual([rows[0], { ...rows[1], type: 'plugin:developer/message' }]) + }) + it('restores V3 and preceding versions without the incoming catalog-completion edge', () => { const header = { type: 'session', version: 2, id: 'historical', createdAt: 1, isSeeded: false, delegationDepth: 0 } expect(historicalSessionFormatCatalog.readHeader(header)).toMatchObject({ status: 'migration-required' }) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 47a4d66707..5f7a913ffa 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 51075fc5bf1032a2438b0372b7092a0bc5b4c119 -README.zh.md: 6a0d94290f177c690971e61cc3159c6de98ebd51 +README.md: 352246595a3da2d5902c51ebf946595c30b0247e +README.zh.md: e2eebbd642122d4e0d71d379f8f342dd8d8b37f4 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 51075fc5bf..352246595a 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -17,6 +17,7 @@ Restore supported released V3 Sessions as V4 without rewriting their stored gene - [V3-to-V4 specification](#v3-to-v4-specification) - [Header and physical framing](#header-and-framing) - [Tool-result representation](#tool-results) + - [Extension data](#extension-data) - [Message-source conversion](#message-sources) - [Parent catalog prerequisites](#parent-catalog) - [Sequence references and inheritance](#sequence-references) @@ -65,7 +66,7 @@ Every restore creates independent Stage state. Compact runs expand as iterables ## V3-to-V4 specification -This edge changes only the named representations below and appends missing catalog facts. It retains each admitted source event's type, time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies. +This edge changes only the named representations below and appends missing catalog facts. It namespaces unknown ignorable event types and retains each admitted source event's time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies. ### Header and physical framing @@ -76,7 +77,7 @@ This edge changes only the named representations below and appends missing catal | V3 physical rows | Released V3 source codec decodes events and compact runs | Source framing and source-event range decoding remain owned by the preceding package. | | V4 physical rows | `releasedV4SessionFormatCodec` reuses released V2 framing with native V4 admission | No V4 event is projected into a V3 semantic validator. Encoding and decoding do not run this incoming migration. | -No preset id, PTC dispatch event tag, file attachment, embedded stream, or physical filename is renamed by this edge. Their earlier migrations and storage owners remain authoritative. +No preset id, PTC dispatch event tag, file attachment, or physical filename is renamed by this edge. Content extensions in embedded streams follow the rules below; stream order and indices remain unchanged. ### Tool-result representation @@ -96,10 +97,15 @@ The only admitted wrapper keys are `type`, `toolCallId`, `content`, and `isError A malformed canonical wrapper raises a format error; an unmapped wrapper extension, nested result, or conflicting target field raises an unsupported-migration error. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany `message.isError: true`. + +### Extension data + +Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType` and retain `type` and `index`; other start-owned field names receive a `plugin:` prefix. Request-tool definitions retain `name`, `description`, and `parameters`; all other own field names receive `plugin:` too, so historical `deferLoading` becomes `plugin:deferLoading` without enabling V4 capabilities. Existing prefixes double, keeping distinct old names distinct. Parameters, arguments, replay state, and plugin content fields are not traversed. These names preserve data identity; they do not load or execute plugins. + ### Message-source conversion -The [message walker](src/sources.ts) visits only these payload positions. It rewrites a source only when `source.kind === 'plugin'`; other source values pass unchanged to target admission. +The [message walker](src/sources.ts) visits only these payload positions. It maps known V3 identities and gives external plugin names and external direct source kinds separate reserved namespaces. | Owning event | Message position | |---|---| @@ -107,9 +113,9 @@ The [message walker](src/sources.ts) visits only these payload positions. It rew | `system/message`, `assistant/message`, `tool/result` | `data.message` | | `agent/inbox/spliced` | Each `data.inserted[]` member | | `session/title-llm-request` | Each `data.messages[]` member | -| `developer/message` | `data.message`; recognized by the shared native walker, never generated by the V3→V4 stage | +| `developer/message` | Native walker only; an unknown ignorable V3 event is namespaced and its payload is not visited | -A plugin source requires a nonempty string `plugin`. Conversion removes that property, replaces `kind`, and preserves every other own JSON property. It does not infer missing producer metadata from message text, tool arguments, plugin configuration, or current files. +A plugin source requires a string `plugin`, including the empty string. Conversion removes that property, replaces `kind`, and preserves every other own JSON property. Direct source kinds must be nonempty strings. Conversion does not infer missing metadata from message text, tool arguments, configuration, or current files. | Exact V3 `plugin` | V4 `kind` | |---|---| @@ -119,11 +125,11 @@ A plugin source requires a nonempty string `plugin`. Conversion removes that pro | `@deepseek-ai/dsh-system-prompt`, on a system-role message | `system-prompt` | | `@deepseek-ai/dsh-system-prompt`, on another role | `runtime-context` | | Same-name first-party producers listed below | The exact plugin string | -| Any other nonempty name outside the reserved collision set | The exact plugin string, including unknown external names | +| Any other plugin name | `plugin:plugin:` followed by the complete original name | The same-name producers are `agent-instructions`, `session-reference`, `team-message`, `goal`, `skill-invocation`, `skill-catalog`, `coordinator`, `subagent-report`, `subagent-settled`, `webhook`, `agent-message`, `model-selection`, `plan-mode`, `time-context`, `tmux-context`, `user-approval`, `repeat-tool-reminder`, `tool-cordis`, `cordis-host-runner`, `tool-goal`, `tool-jobs`, `hooks-codex`, `hooks-claude-code`, `schedule`, and `dsh-session-title-llm`. -The reserved collision names are `plugin`, `user`, `model`, `tool`, `system-prompt`, `tool-registry`, `runtime-context`, `compact-checkpoint`, `ptc-mode`, `compact-basic`, and `auto-review`. A V3 plugin source using one of them is refused instead of acquiring a different current role or producer meaning. Exact rename entries take precedence. Similar strings, `constructor`, and `__proto__` are ordinary external names. +Namespacing prepends `plugin:plugin:` to an external plugin name or `plugin:source:` to an external direct kind. It keeps the complete original string, including an existing prefix. A plugin named `source:x` becomes `plugin:plugin:source:x`, while a direct kind named `x` becomes `plugin:source:x`; the categories cannot collide. Known V3 direct kinds remain unchanged. There is no recursive source search. Captured request text, assistant replay state and streams, tool arguments/content metadata, Team payloads, and arbitrary nested objects remain unchanged unless another explicitly named rule applies. @@ -161,20 +167,20 @@ For a seeded Session, the last `session/end-seed` carrying `inherited: true` ide | Delivery record | Admission and preservation | |---|---| | Any interpreted `session-log-deepseek/delivery-accepted` | Generation must be a nonnegative safe integer; omission identifies V0. | -| V3 source marker claiming generation 4 | Refuse: changing the header must not activate a target-generation watermark. | +| V3 source marker claiming any generation above 3 | Namespace its event type as `plugin:session-log-deepseek/delivery-accepted` and set `ignorable: true`; keep the inactive payload and coordinates unchanged. | | V3 source marker for generation 3 | Require a nonempty Session id and nonnegative safe-integer `throughSeq` before the marker; a foreign id is allowed only before the inherited cut with `parentSession`. | -| Other source generations | Retain their ids, generation, and coordinates without activating them. | +| Source generations below 3 | Retain their ids, generation, and coordinates without activating them. | | Native V4 marker for generation 4 | Apply the same earlier-coordinate and Session-ownership checks using V4 as current. | | Native V4 historical marker, including generation 3 | Retain recorded coordinates and identity; it is not a V4 acceptance watermark. | -No delivery payload is rewritten. The marker's envelope sequence also remains unchanged in this edge. +No delivery payload is rewritten. Namespacing higher-generation records preserves their inactive V3 meaning after this and future header promotions; it does not acknowledge delivery. The marker’s envelope sequence remains unchanged. ### Source audit and refusal V3 physical decoding and header validation run before the stage. The stage checks dense sequences, source cuts, canonical tool-result wrappers, named plugin sources, delivery ownership, and supplied catalog evidence as specified above. It does not run the complete released V3 semantic restorer or copy the V2→V3 event/content allowlist. Complete restoration additionally applies the V4 target rules below; physical parsing, stage conversion, and target restoration are distinct checks. -Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. Unknown required events are refused by vocabulary-aware target restoration; unknown ignorable events remain opaque under their physical framing and any explicitly owned admission rules. No generic source-schema validation or recursive numeric-field inference is implied. +Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. Unknown required events are invalid V3 input; unknown ignorable names become `plugin:` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied. Only the canonical V3 `tool/result` wrapper has a preserving conversion. A retired `tool-result` block in another interpreted position is refused by target admission rather than retained as an invalid V4 block. Native V4 applies the same tag refusal before recoverable suffix suppression. The check covers only these positions: diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 6a0d94290f..e2eebbd642 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -17,6 +17,7 @@ kind: "package-library" - [V3 到 V4 规范](#v3-to-v4-specification) - [Header 与物理分帧](#header-and-framing) - [工具结果表示](#tool-results) + - [扩展数据](#extension-data) - [消息来源转换](#message-sources) - [父目录前置证据](#parent-catalog) - [序号引用与继承](#sequence-references) @@ -65,7 +66,7 @@ const artifact = restore.finish() ## V3 到 V4 规范 -本边只改变下列明确命名的表示,并追加缺失的目录事实。每个获准源事件的 type、time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。 +本边只改变下列明确命名的表示,并追加缺失的目录事实。未知可忽略事件的类型获得命名空间;每个获准源事件的 time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。 ### Header 与物理分帧 @@ -76,7 +77,7 @@ const artifact = restore.finish() | V3 物理行 | 已发布 V3 源 codec 解码事件与紧凑 run | 源分帧和源事件区间解码仍归前一个包所有。 | | V4 物理行 | `releasedV4SessionFormatCodec` 复用已发布 V2 分帧并执行原生 V4 接纳 | 不把 V4 事件投影到 V3 语义校验器。编码和解码不运行此入边迁移。 | -本边不重命名 preset id、PTC dispatch 事件标签、文件附件、内嵌流或物理文件名。对应的早期迁移和存储所有者仍是权威。 +本边不重命名 preset id、PTC dispatch 事件标签、文件附件或物理文件名。内嵌流中的内容扩展遵循下列规则,流顺序和索引保持不变。 ### 工具结果表示 @@ -96,10 +97,15 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp 格式错误的 canonical wrapper 产生格式错误;没有映射的 wrapper 扩展、嵌套结果或冲突目标字段产生不支持迁移错误。转换不修复矛盾的 `data.error`;原生目标校验要求它与 `message.isError: true` 同时出现。 + +### 扩展数据 + +V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,并保留 `type` 和 `index`,其他自有字段名称添加 `plugin:` 前缀。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。 + ### 消息来源转换 -[消息遍历器](src/sources.ts)只访问以下载荷位置。仅在 `source.kind === 'plugin'` 时改写来源;其他来源值原样进入目标接纳。 +[消息遍历器](src/sources.ts) 只访问下列 payload 位置。它映射已知 V3 身份,并为外部插件名与外部直接来源 kind 分配独立的保留命名空间。 | 所属事件 | 消息位置 | |---|---| @@ -107,9 +113,9 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp | `system/message`、`assistant/message`、`tool/result` | `data.message` | | `agent/inbox/spliced` | 每个 `data.inserted[]` 成员 | | `session/title-llm-request` | 每个 `data.messages[]` 成员 | -| `developer/message` | `data.message`;由共享的原生遍历器识别,V3→V4 Stage 从不生成该事件 | +| `developer/message` | 仅由原生遍历器处理;未知可忽略 V3 事件被命名空间化,其 payload 不被访问 | -Plugin 来源要求非空字符串 `plugin`。转换移除该属性、替换 `kind`,并保留其他所有自有 JSON 属性。它不从消息文本、工具参数、插件配置或当前文件推断缺失的生产者元数据。 +插件来源要求字符串 `plugin`,允许空字符串。转换移除该属性、替换 `kind`,并保留其他所有自有 JSON 属性。直接来源 kind 必须为非空字符串。转换不从消息文本、工具参数、配置或当前文件推断缺失元数据。 | 精确的 V3 `plugin` | V4 `kind` | |---|---| @@ -119,11 +125,11 @@ Plugin 来源要求非空字符串 `plugin`。转换移除该属性、替换 `ki | system 角色消息中的 `@deepseek-ai/dsh-system-prompt` | `system-prompt` | | 其他角色中的 `@deepseek-ai/dsh-system-prompt` | `runtime-context` | | 下文列出的同名第一方生产者 | 精确的 plugin 字符串 | -| 保留冲突集合之外的其他非空名称 | 精确的 plugin 字符串,包括未知外部名称 | +| 其他任何插件名 | `plugin:plugin:` 后接完整的原始名称 | 同名生产者为 `agent-instructions`、`session-reference`、`team-message`、`goal`、`skill-invocation`、`skill-catalog`、`coordinator`、`subagent-report`、`subagent-settled`、`webhook`、`agent-message`、`model-selection`、`plan-mode`、`time-context`、`tmux-context`、`user-approval`、`repeat-tool-reminder`、`tool-cordis`、`cordis-host-runner`、`tool-goal`、`tool-jobs`、`hooks-codex`、`hooks-claude-code`、`schedule` 和 `dsh-session-title-llm`。 -保留的冲突名称为 `plugin`、`user`、`model`、`tool`、`system-prompt`、`tool-registry`、`runtime-context`、`compact-checkpoint`、`ptc-mode`、`compact-basic` 和 `auto-review`。使用其中任一名称的 V3 plugin 来源会被拒绝,避免获得不同的当前角色或生产者含义。精确重命名项优先匹配。相似字符串、`constructor` 和 `__proto__` 都是普通外部名称。 +命名空间为外部插件名称添加 `plugin:plugin:`,为外部直接 kind 添加 `plugin:source:`,完整保留包括已有前缀在内的原字符串。名为 `source:x` 的插件变为 `plugin:plugin:source:x`,直接 kind `x` 则变为 `plugin:source:x`,两类身份不会碰撞。已知 V3 直接 kind 保持不变。 来源查找不递归进行。捕获的请求文本、assistant 回放状态与流、工具参数/内容元数据、Team 载荷以及任意嵌套对象均保留,除非另有明确命名的规则适用。 @@ -161,20 +167,20 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe | Delivery 记录 | 接纳与保留 | |---|---| | 任何被解释的 `session-log-deepseek/delivery-accepted` | 代际必须为非负安全整数;省略表示 V0。 | -| 声称 generation 4 的 V3 源 marker | 拒绝:改变 header 不得激活目标代际的 watermark。 | +| V3 源标记声明高于 3 的任意代际 | 事件类型变为 `plugin:session-log-deepseek/delivery-accepted` 并设置 `ignorable: true`;保留未激活的 payload 与坐标。 | | generation 3 的 V3 源 marker | 要求非空 Session id 和早于 marker 的非负安全整数 `throughSeq`;只有在继承截点之前且带 `parentSession` 时才允许其他 Session id。 | -| 其他源代际 | 保留 id、generation 和坐标,不激活它们。 | +| 低于 3 的源代际 | 保留 id、代际与坐标,不激活它们。 | | generation 4 的原生 V4 marker | 以 V4 为当前代际,应用同样的较早坐标和 Session 归属检查。 | | 原生 V4 中的历史 marker,包括 generation 3 | 保留记录的坐标与身份;它不是 V4 接纳 watermark。 | -任何 delivery 载荷均不改写。该边也保留 marker 的信封序号。 +不改写投递 payload。对较高代际记录使用命名空间,保留其在本次及未来 header 升级后的 V3 未激活含义;这不确认投递。标记的信封序号保持不变。 ### 源审计与拒绝 Stage 之前先执行 V3 物理解码与 header 校验。Stage 按上述规范检查连续序号、源截点、canonical 工具结果 wrapper、命名的 plugin 来源、delivery 归属及传入的目录证据。它不运行完整的已发布 V3 语义恢复器,也不复制 V2→V3 的事件/内容允许列表。完整恢复还会应用下述 V4 目标规则;物理解析、Stage 转换与目标恢复是不同的检查。 -只有枚举出的消息与字段会转换。无关事件和任意 JSON 不会因字符串或数字相同而获得新含义。未知 required 事件由支持事件词汇表的目标恢复器拒绝;未知 ignorable 事件在其物理分帧与明确所属的接纳规则下保持不透明。这不意味着通用源 schema 校验或递归数字字段推断。 +只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。未知必需事件不是有效的 V3 输入;未知可忽略事件名称变为 `plugin:`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。 只有 canonical V3 `tool/result` wrapper 具有保留信息的转换。其他被解释位置中的已退役 `tool-result` block 由目标接纳拒绝,不作为无效 V4 block 保留。原生 V4 在 recoverable 后缀抑制前应用同样的标签拒绝。检查只覆盖以下位置: diff --git a/packages/session/session-format-v3-to-v4/src/content.ts b/packages/session/session-format-v3-to-v4/src/content.ts new file mode 100644 index 0000000000..67062e9c17 --- /dev/null +++ b/packages/session/session-format-v3-to-v4/src/content.ts @@ -0,0 +1,102 @@ +/** V3 extension names and fields isolated from current core vocabulary. */ + +import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' +import { mapEventMessages } from './sources.ts' + +const V3_BLOCK_TYPES = new Set(['text', 'reasoning', 'image', 'file', 'tool-call', 'tool-result']) +const START_FIELDS = new Set(['type', 'index', 'blockType']) +const TOOL_SCHEMA_FIELDS = new Set(['name', 'description', 'parameters']) + +/** Prefix extension-owned field names without reinterpreting their values. */ +function namespaceFields(value: SessionFormatJsonObject, fields: ReadonlySet): SessionFormatJsonObject { + if (Object.keys(value).every(key => fields.has(key))) return value + return Object.fromEntries(Object.entries(value).map(([key, item]) => [fields.has(key) ? key : `plugin:${key}`, item])) +} + +function migrateBlock(value: SessionFormatJsonValue, subject: string): SessionFormatJsonValue { + if (!isSessionFormatJsonObject(value) || typeof value['type'] !== 'string') { + throw new SessionFormatError(`${subject} requires content blocks with string type tags`) + } + const type = value['type'] + if (V3_BLOCK_TYPES.has(type)) return value + return { ...value, type: `plugin:${type}` } +} + +/** + * Convert declared content tags while keeping arguments and extension data opaque. + * @param value - released content array. + * @param subject - owning event and content location for diagnostics. + * @returns content in the same order, sharing unchanged blocks and image references. + */ +export function migrateV3Content(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue[] { + if (!Array.isArray(value)) throw new SessionFormatError(`${subject} content must be an array`) + const blocks = value as readonly SessionFormatJsonValue[] + const mapped = blocks.map((block, index) => migrateBlock(block, `${subject}[${index}]`)) + return mapped.every((block, index) => block === value[index]) ? value as SessionFormatJsonValue[] : mapped +} + +function migrateMessage(message: SessionFormatJsonObject, subject: string): SessionFormatJsonObject { + const content = migrateV3Content(message['content'], subject) + return content === message['content'] ? message : { ...message, content } +} + +function migrateChunk(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue | undefined { + if (!isSessionFormatJsonObject(value)) return value + if (value['type'] === 'block-end') { + const block = migrateBlock(value['block'] as SessionFormatJsonValue, `${subject}.block`) + return block === value['block'] ? value : { ...value, block } + } + if (value['type'] !== 'block-start') return value + const original = value['blockType'] + if (typeof original !== 'string') throw new SessionFormatError(`${subject} blockType must be a string`) + const blockType = V3_BLOCK_TYPES.has(original) ? original : `plugin:${original}` + const mapped = namespaceFields(value, START_FIELDS) + return blockType === original ? mapped : { ...mapped, blockType } +} + +/** + * Convert declared V3 extension content, streams, and request-tool extension fields after canonical result lifting. + * @param event - source event after producer attribution conversion. + * @returns the event with namespaced extension tags and fields, retaining all coordinates and values. + */ +export function migrateV3EventContent(event: SessionFormatEvent): SessionFormatEvent { + const subject = `format v3 ${event.type} at seq ${event.seq}` + let mapped = mapEventMessages(event, message => migrateMessage(message, subject)) + if (!isSessionFormatJsonObject(mapped.data)) return mapped + let data = mapped.data + const contentField = (key: string): void => { + const content = migrateV3Content(data[key], `${subject}.${key}`) + if (content !== data[key]) data = { ...data, [key]: content } + } + if (event.type === 'compaction/summary') { + contentField('summary') + if (data['rawOutput'] !== undefined) contentField('rawOutput') + } else if (event.type === 'tool/ptc-dispatch') contentField('content') + else if (event.type === 'team/message/queued' && isSessionFormatJsonObject(data['message'])) { + const message = migrateMessage(data['message'], subject) + if (message !== data['message']) data = { ...data, message } + } + if (event.type === 'request/header' && isSessionFormatJsonObject(data['header'])) { + const header = data['header'] + const tools = header['tools'] + if (Array.isArray(tools)) { + const converted = (tools as readonly SessionFormatJsonValue[]).map((tool) => { + if (!isSessionFormatJsonObject(tool)) return tool + return namespaceFields(tool, TOOL_SCHEMA_FIELDS) + }) + if (converted.some((tool, index) => tool !== tools[index])) data = { ...data, header: { ...header, tools: converted } } + } + } + if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && Array.isArray(data['stream'])) { + const stream = data['stream'] as readonly SessionFormatJsonValue[] + const converted = stream.map((entry, index) => { + if (!isSessionFormatJsonObject(entry) || entry['type'] !== 'chunk') return entry + const chunk = migrateChunk(entry['chunk'], `${subject}.stream[${index}]`) + return chunk === entry['chunk'] ? entry : { ...entry, chunk: chunk as SessionFormatJsonValue } + }) + if (converted.some((entry, index) => entry !== stream[index])) data = { ...data, stream: converted } + } + if (data !== mapped.data) mapped = { ...mapped, data } + return mapped +} diff --git a/packages/session/session-format-v3-to-v4/src/extension-identities.ts b/packages/session/session-format-v3-to-v4/src/extension-identities.ts new file mode 100644 index 0000000000..6a75dcef27 --- /dev/null +++ b/packages/session/session-format-v3-to-v4/src/extension-identities.ts @@ -0,0 +1,93 @@ +/** Fixed V3 event vocabulary and reversible namespaces for historical extension identities. */ + +import { isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format' + +// This historical list must not inherit additions or removals from the current Session event list. +/* jscpd:ignore-start */ +/** First-party event names understood by the released V3 reader, independent of the installed writer. */ +export const RELEASED_V3_EVENT_TYPES: ReadonlySet = new Set([ + 'agent-preset/selected', + 'agent/inbox/spliced', + 'approval/asked', + 'approval/decided', + 'approval/policy', + 'assistant/attempt', + 'assistant/message', + 'command/done', + 'command/run', + 'compaction/end', + 'compaction/prune', + 'compaction/start', + 'compaction/summary', + 'deliverables/presented', + 'feedback/message-delete', + 'feedback/message-put', + 'feedback/record', + 'goal/change', + 'hook/invoked', + 'hook/result', + 'image/offload', + 'llm/retry', + 'llm/retry-started', + 'model/selection', + 'permission/preset', + 'plan/mode', + 'request/context', + 'request/header', + 'sandbox/mode', + 'schedule/change', + 'session-log-deepseek/delivery-accepted', + 'session/end-seed', + 'session/title', + 'session/title-llm-request', + 'step/end', + 'step/start', + 'subagent/catalog', + 'subagent/descriptor', + 'subagent/model-selection-policy', + 'system/message', + 'team/member', + 'team/message/delivered', + 'team/message/queued', + 'team/task', + 'todo/write', + 'tool-workflow/agent-end', + 'tool-workflow/agent-start', + 'tool-workflow/run-end', + 'tool-workflow/run-start', + 'tool/call', + 'tool/ptc-dispatch', + 'tool/ptc-dispatch-start', + 'tool/result', + 'turn/end', + 'turn/start', + 'user/message', + 'web/deepseek-search-llm-request', + 'workspace/changes', +]) +/* jscpd:ignore-end */ + +/** + * Qualify the complete original name with its historical identity namespace. + * @param namespace - owner of the historical identity. + * @param name - complete original source name. + * @returns a namespaced identity retaining the complete original string as its suffix. + */ +export function v3ExtensionIdentity(namespace: 'plugin' | 'source', name: string): string { + return `plugin:${namespace}:${name}` +} + +/** + * Keep unknown events and higher-generation delivery records opaque after header promotion. + * @param event - source event after V3 vocabulary and delivery-generation admission. + * @returns the same event or an ignorable namespaced event retaining its payload and coordinates. + */ +export function namespaceV3OpaqueEvent(event: SessionFormatEvent): SessionFormatEvent { + const inactiveDelivery = event.type === 'session-log-deepseek/delivery-accepted' + && isSessionFormatJsonObject(event.data) && typeof event.data['sessionFormatVersion'] === 'number' + && event.data['sessionFormatVersion'] > 3 + return inactiveDelivery || event['ignorable'] === true && !RELEASED_V3_EVENT_TYPES.has(event.type) + ? { ...event, type: `plugin:${event.type}`, ignorable: true } + : event +} diff --git a/packages/session/session-format-v3-to-v4/src/index.ts b/packages/session/session-format-v3-to-v4/src/index.ts index 7e59150a6c..25254a3294 100644 --- a/packages/session/session-format-v3-to-v4/src/index.ts +++ b/packages/session/session-format-v3-to-v4/src/index.ts @@ -5,3 +5,4 @@ export * from './codec.ts' export * from './migration.ts' export { assertReleasedV4Header, assertReleasedV4Relationships, restoreReleasedV4Artifact } from './validation.ts' export { historicalChildCatalogSource } from './facts.ts' +export { RELEASED_V3_EVENT_TYPES } from './extension-identities.ts' diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index b8059965fa..bd0bd39748 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -3,8 +3,10 @@ import { defineSessionFormatMigration, SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatJsonValue, SessionFormatMigration, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format' import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3' -import { mapEventMessages, rewritePluginSource } from './sources.ts' +import { mapEventMessages, rewriteV3MessageSource } from './sources.ts' import { liftToolResult } from './tool-role.ts' +import { migrateV3EventContent } from './content.ts' +import { namespaceV3OpaqueEvent } from './extension-identities.ts' import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts' import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts' @@ -65,17 +67,17 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } const deliveryId = validateDeliveryAccepted(event, 3) if (event.type === 'session-log-deepseek/delivery-accepted') { - if ((event.data as SessionFormatJsonObject)['sessionFormatVersion'] === 4) { - throw new SessionFormatUnsupportedMigrationError('format v3 delivery marker claims target format v4') - } if (deliveryId !== undefined && deliveryId !== this.input.sourceHeader.id) this.foreignDeliverySeq = event.seq } + const opaque = namespaceV3OpaqueEvent(event) + if (opaque !== event) { context.emitEvent(opaque); return } const rewritten = mapEventMessages(event, (message) => { const source = message['source'] - if (!isSessionFormatJsonObject(source) || source['kind'] !== 'plugin') return message - return { ...message, source: rewritePluginSource(source, event.seq, message['role']) } + if (!isSessionFormatJsonObject(source)) return message + const converted = rewriteV3MessageSource(source, event.seq, message['role']) + return converted === source ? message : { ...message, source: converted } }) - context.emitEvent(liftToolResult(rewritten)) + context.emitEvent(migrateV3EventContent(liftToolResult(rewritten))) } transformRun(run: SessionFormatEventRun, context: SessionFormatMigrationContext): void { diff --git a/packages/session/session-format-v3-to-v4/src/sources.ts b/packages/session/session-format-v3-to-v4/src/sources.ts index 69cee46187..f27fc4e113 100644 --- a/packages/session/session-format-v3-to-v4/src/sources.ts +++ b/packages/session/session-format-v3-to-v4/src/sources.ts @@ -1,7 +1,8 @@ -/** V3 source migration; tool arguments, content and extension payloads remain opaque. */ +/** V3 producer identities with reserved namespaces for external names. */ -import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' +import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' +import { v3ExtensionIdentity } from './extension-identities.ts' /** * Visit only messages carried by first-party event payloads. @@ -44,17 +45,11 @@ const RENAMED_PRODUCERS: Readonly> = Object.freeze({ '@deepseek-ai/dsh-system-prompt': 'runtime-context', }) -/** Current producer kinds whose names are not released V3 plugin identities. */ -const CURRENT_PRODUCER_KINDS: ReadonlySet = new Set([ - 'user', 'model', 'tool', 'system-prompt', 'tool-registry', - 'runtime-context', 'compact-checkpoint', 'ptc-mode', 'compact-basic', - 'agent-instructions', 'session-reference', 'team-message', 'goal', - 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', - 'subagent-settled', 'webhook', 'agent-message', 'model-selection', - 'plan-mode', 'time-context', 'tmux-context', 'user-approval', - 'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal', - 'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule', - 'dsh-session-title-llm', 'auto-review', +/** Native V3 producer kinds whose identity already has a current owner. */ +const V3_NATIVE_PRODUCERS: ReadonlySet = new Set([ + 'user', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', + 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', + 'subagent-settled', 'webhook', 'agent-message', ]) /** First-party V3 plugin identities that intentionally keep their current kind. */ @@ -74,12 +69,7 @@ function producerKind(plugin: string, role: SessionFormatJsonValue | undefined): const renamed = Object.hasOwn(RENAMED_PRODUCERS, plugin) ? RENAMED_PRODUCERS[plugin] : undefined if (renamed !== undefined) return renamed if (RELEASED_SAME_NAME_PRODUCERS.has(plugin)) return plugin - if (CURRENT_PRODUCER_KINDS.has(plugin) || plugin === 'plugin') { - throw new SessionFormatUnsupportedMigrationError( - `V3 plugin source ${JSON.stringify(plugin)} collides with a current producer kind`, - ) - } - return plugin + return v3ExtensionIdentity('plugin', plugin) } /** @@ -95,9 +85,9 @@ export function rewritePluginSource( role: SessionFormatJsonValue | undefined, ): SessionFormatJsonObject { const plugin = source['plugin'] - if (typeof plugin !== 'string' || plugin.length === 0) { + if (typeof plugin !== 'string') { throw new SessionFormatError( - `plugin source at seq ${seq} is not canonical: plugin requires a non-empty string`, + `plugin source at seq ${seq} is not canonical: plugin requires a string`, ) } const kind = producerKind(plugin, role) @@ -106,3 +96,19 @@ export function rewritePluginSource( .filter(([key]) => key !== 'plugin') .map(([key, item]) => [key, key === 'kind' ? kind : item])) } + +/** + * Retain known V3 producer identities and namespace external kinds separately from plugin names. + * @param source - decoded V3 message source. + * @param seq - event sequence for diagnostics. + * @param role - enclosing message role for role-sensitive producer mappings. + * @returns the converted source with every non-identity field preserved. + */ +export function rewriteV3MessageSource( + source: SessionFormatJsonObject, seq: number, role: SessionFormatJsonValue | undefined, +): SessionFormatJsonObject { + const kind = source['kind'] + if (typeof kind !== 'string' || kind.length === 0) throw new SessionFormatError(`message source at seq ${seq} requires a nonempty kind`) + if (kind === 'plugin') return rewritePluginSource(source, seq, role) + return V3_NATIVE_PRODUCERS.has(kind) ? source : { ...source, kind: v3ExtensionIdentity('source', kind) } +} diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 954ef5f968..ee61c33b01 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -106,11 +106,11 @@ describe('V3 parent catalog completion', () => { expect(migrate([catalog], [], false, undefined, true).events).toEqual([catalog]) }) - it('rejects target delivery activation and invalid current delivery ownership', () => { + it('keeps target delivery inactive and rejects invalid current delivery ownership', () => { const preceding = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'prior' } } const delivery = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 1, data: { sessionId: 'other', throughSeq: 0, sessionFormatVersion: 4 } } - expect(() => migrate([preceding, delivery], [])).toThrow('claims target format v4') + expect(migrate([preceding, delivery], []).events).toEqual([preceding, { ...delivery, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }]) expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) @@ -128,7 +128,7 @@ describe('V3 parent catalog completion', () => { }) it.each([null, { version: 99 }])('retains existing catalog extensions without interpreting unavailable descriptors: %j', (descriptor) => { - const data = { ...child, extension: 'retained' } + const data = { ...child, plugin: 'retained' } const source = [{ type: 'subagent/catalog', seq: 0, time: 1, data }] expect(migrate(source, [{ childId: 'child', childCreatedAt: 2, descriptorCount: descriptor === null ? 0 : 1, descriptor }]).events).toEqual(source) }) diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts new file mode 100644 index 0000000000..962de79b40 --- /dev/null +++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' +import { migrateV3Content, migrateV3EventContent } from '../src/content.ts' +import { createSessionFormatV3ToV4, releasedV4SessionFormatCodec } from '../src/index.ts' +import { assertV4MessageSources } from '../src/message-sources.ts' +import { namespaceV3OpaqueEvent } from '../src/extension-identities.ts' + +const image = { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } } +const wrapper = { type: 'future', content: [{ type: 'tool-result', toolCallId: 'opaque', content: [image] }] } +const convertedBlock = { ...wrapper, type: 'plugin:future' } +const user = { id: 'user', role: 'user', source: { kind: 'user' }, content: [wrapper] } +const event = (type: string, data: SessionFormatJsonValue): SessionFormatEvent => ({ type, seq: 0, time: 1, data }) + +function migrate(row: SessionFormatEvent): SessionFormatEvent { + const sourceHeader = { version: 3, id: 'content', createdAt: 0, isSeeded: false, delegationDepth: 0 } + const stage = createSessionFormatV3ToV4([]).createStage({ sourceHeader, targetHeader: { ...sourceHeader, version: 4 }, sourceInheritedEventCount: 0, sourceKind: 'decoded' }) + const collector = new SessionFormatEventCollector() + stage.transformEvent(row, collector) + stage.finish(collector) + return collector.values[0]! +} + +describe('V3 content conversion', () => { + it('preserves own extension keys without changing opaque values or source content', () => { + const old = JSON.parse('{"type":"vendor","metadata":{"tag":"old"},"__proto__":{"saved":true},"constructor":{"saved":false}}') as SessionFormatJsonObject + const actual = migrateV3Content([old, image], 'sample') + expect(actual).toEqual([{ ...old, type: 'plugin:vendor' }, image]) + const data = actual[0] as SessionFormatJsonObject + expect(Object.hasOwn(data, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(data)).toBe(Object.prototype) + expect(actual[1]).toBe(image) + expect(old['type']).toBe('vendor') + }) + + it.each(['message', 'extension', 'tool-addition', 'tool-removal', '', 'future', 'plugin:future'])('keeps the unknown V3 tag %j opaque', (type) => { + const block = { type, content: [wrapper], message: { role: 'tool', content: [wrapper] }, data: wrapper } + expect(migrateV3Content([block], 'sample')).toEqual([{ ...block, type: `plugin:${type}` }]) + }) + + it('shares unchanged leaves and ignores JSON that is not a declared content slot', () => { + const content = [{ type: 'text', text: 'saved', metadata: wrapper }, { type: 'tool-call', id: 'call', name: 'echo', arguments: JSON.stringify(wrapper) }, image] + expect(migrateV3Content(content, 'sample')).toBe(content) + const row = event('user/message', { ...user, content, replayState: wrapper, schema: { examples: [wrapper] } }) + expect(migrateV3EventContent(row)).toBe(row) + const opaque = event('future', { content: [wrapper] }) + expect(migrateV3EventContent(opaque)).toBe(opaque) + const scalar = event('future', null) + expect(migrateV3EventContent(scalar)).toBe(scalar) + }) + + it.each([ + ['user/message', user, { ...user, content: [convertedBlock] }], + ['system/message', { message: user }, { message: { ...user, content: [convertedBlock] } }], + ['assistant/message', { message: user }, { message: { ...user, content: [convertedBlock] } }], + ['agent/inbox/spliced', { inserted: [user] }, { inserted: [{ ...user, content: [convertedBlock] }] }], + ['session/title-llm-request', { messages: [user] }, { messages: [{ ...user, content: [convertedBlock] }] }], + ['compaction/summary', { summary: [wrapper], rawOutput: [wrapper] }, { summary: [convertedBlock], rawOutput: [convertedBlock] }], + ['compaction/summary', { summary: [] }, { summary: [] }], + ['tool/ptc-dispatch', { content: [wrapper] }, { content: [convertedBlock] }], + ['team/message/queued', { message: user }, { message: { ...user, content: [convertedBlock] } }], + ['team/message/queued', { message: { ...user, content: [] } }, { message: { ...user, content: [] } }], + ['team/message/queued', { message: null }, { message: null }], + ] as const)('converts declared %s content without changing envelope coordinates', (type, data, expected) => { + const row = { + ...event(type, data), seq: 3, sourceEventSeqs: [0, 2], surfaceOp: { op: 'replace', startSeq: 0, endSeq: 2 }, extra: { content: [wrapper] }, + } as SessionFormatEvent + const before = structuredClone(row) + expect(migrateV3EventContent(row)).toEqual({ ...row, data: expected }) + expect(row).toEqual(before) + }) + + it.each(['assistant/message', 'assistant/attempt'])('converts raw %s start/end blocks and preserves start-owned fields', (type) => { + const chunk = (value: SessionFormatJsonValue): SessionFormatJsonObject => ({ type: 'chunk', chunk: value, elapsedMs: 7 }) + const stream = [ + chunk({ type: 'block-start', index: 0, blockType: 'future', extensionName: 'old', metadata: { old: true }, custom: wrapper }), + chunk({ type: 'block-end', index: 0, block: wrapper }), + chunk({ type: 'block-start', index: 1, blockType: 'message' }), + chunk({ type: 'block-end', index: 1, block: { type: 'message', content: [wrapper] } }), + chunk({ type: 'block-start', index: 2, blockType: 'text' }), + chunk({ type: 'block-start', index: 3, blockType: 'text', metadata: { original: true } }), + chunk({ type: 'block-end', index: 2, block: { type: 'text', text: 'after' } }), + chunk({ type: 'text-delta', index: 2, text: 'after' }), + chunk(null), null, { type: 'annotation', data: wrapper }, + ] + const actual = migrateV3EventContent(event(type, { message: { ...user, content: [] }, stream })) + expect(actual.data).toEqual({ message: { ...user, content: [] }, stream: [ + chunk({ type: 'block-start', index: 0, blockType: 'plugin:future', 'plugin:extensionName': 'old', 'plugin:metadata': { old: true }, 'plugin:custom': wrapper }), + chunk({ type: 'block-end', index: 0, block: convertedBlock }), + chunk({ type: 'block-start', index: 1, blockType: 'plugin:message' }), + chunk({ type: 'block-end', index: 1, block: { type: 'plugin:message', content: [wrapper] } }), + stream[4], chunk({ type: 'block-start', index: 3, blockType: 'text', 'plugin:metadata': { original: true } }), + ...stream.slice(6), + ] }) + const unchanged = event(type, { message: { ...user, content: [] }, stream: stream.slice(6) }) + expect(migrateV3EventContent(unchanged)).toBe(unchanged) + }) + + it.each([false, true])('keeps V3 request-tool extensions opaque with deferLoading=%s', (deferLoading) => { + const parameters = { type: 'object', properties: {}, examples: [wrapper], deferLoading: true } + const extras = JSON.parse('{"metadata":{"saved":true},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject + const tool = { name: 'echo', description: 'Echo', parameters, deferLoading, ...extras } + const row = event('request/header', { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, tools: [tool] } }) + const converted = migrate(row) + expect(converted.data).toEqual({ reason: 'initial', header: { + config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters, 'plugin:deferLoading': deferLoading, 'plugin:metadata': extras['metadata'], 'plugin:__proto__': extras['__proto__'], 'plugin:constructor': extras['constructor'] }], + } }) + expect(() => releasedV4SessionFormatCodec.encodeEvent(converted)).not.toThrow() + const header = (converted.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject + const actual = (header['tools'] as SessionFormatJsonObject[])[0]! + expect(actual['parameters']).toBe(parameters) + expect(Object.hasOwn(actual, 'plugin:__proto__')).toBe(true) + expect(Object.getPrototypeOf(actual)).toBe(Object.prototype) + expect(tool['deferLoading']).toBe(deferLoading) + }) + + it('keeps original prefixed keys distinct from unprefixed tool and stream extras', () => { + const extra = JSON.parse('{"deferLoading":true,"plugin:deferLoading":false,"metadata":{"saved":1},"plugin:metadata":{"saved":2},"__proto__":{"saved":3},"plugin:__proto__":{"saved":4}}') as SessionFormatJsonObject + const expected = { + 'plugin:deferLoading': true, 'plugin:plugin:deferLoading': false, + 'plugin:metadata': { saved: 1 }, 'plugin:plugin:metadata': { saved: 2 }, + 'plugin:__proto__': { saved: 3 }, 'plugin:plugin:__proto__': { saved: 4 }, + } + const tool = { name: 'echo', description: 'Echo', parameters: { metadata: extra }, ...extra } + const request = migrateV3EventContent(event('request/header', { header: { tools: [tool] } })) + expect(request.data).toEqual({ header: { tools: [{ name: 'echo', description: 'Echo', parameters: tool.parameters, ...expected }] } }) + const start = { type: 'block-start', index: 7, blockType: 'plugin:text', ...extra } + const stream = migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: start }] })) + expect(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { type: 'block-start', index: 7, blockType: 'plugin:plugin:text', ...expected } }] }) + expect(extra['deferLoading']).toBe(true) + expect(Object.hasOwn(extra, '__proto__')).toBe(true) + }) + + it('retains native V4 capability validation independently of historical extras', () => { + const header = { config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters: {}, deferLoading: false }] } + expect(() => releasedV4SessionFormatCodec.encodeEvent(event('request/header', { header }))).toThrow(/deferLoading must be true/) + expect(() => releasedV4SessionFormatCodec.encodeEvent(migrate(event('request/header', { header })))).not.toThrow() + }) + + it('shares V3 request tools with no extension fields and leaves malformed tools decoder-owned', () => { + for (const header of [null, {}, { tools: null }, { tools: [{ name: 'echo', description: 'Echo', parameters: {} }, null] }]) { + const row = event('request/header', { header }) + expect(migrateV3EventContent(row)).toBe(row) + } + }) + + it('leaves missing message sources for native admission to reject', () => { + const row = event('user/message', { id: 'missing-source', role: 'user', content: [] }) + const converted = migrate(row) + expect(converted).toBe(row) + expect(() => { assertV4MessageSources(converted) }).toThrow(/producer-owned source kind/) + }) + + it('rejects non-array content at the durable conversion boundary', () => { + expect(() => migrateV3Content({}, 'sample')).toThrow(/content must be an array/) + }) + + it.each([null, {}, { type: 4 }])('rejects type-invalid content %j', (block) => { + expect(() => migrateV3Content([block], 'sample')).toThrow(/string type tags/) + }) + + it('rejects a stream start without a string blockType', () => { + expect(() => migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: { type: 'block-start', index: 0 } }] }))).toThrow(/blockType must be a string/) + }) +}) + +describe('native V4 opaque content', () => { + it.each(['system', 'user'])('round trips %s extension content', (role) => { + const message = { ...user, role, source: { kind: role === 'system' ? 'system-prompt' : 'user' }, content: [convertedBlock, { type: 'plugin:future', content: [wrapper] }] } + const row = event(`${role}/message`, role === 'user' ? message : { turn: 1, step: 1, message }) + const physical = releasedV4SessionFormatCodec.encodeEvent(row) + const collector = new SessionFormatEventCollector() + releasedV4SessionFormatCodec.createDecoder({ type: 'session', version: 4, id: 'native', createdAt: 0, isSeeded: false, delegationDepth: 0 }, 'strict').decodeRow(physical, collector) + expect(collector.values).toEqual([row]) + }) +}) + +describe('unknown V3 event identities', () => { + it.each(['developer/message', 'plugin:developer/message', 'future', '\ud800'])('namespaces %j while retaining opaque data and envelope fields', (type) => { + const row = { ...event(type, { message: user, content: [wrapper] }), ignorable: true, sourceEventSeqs: [0], surfaceOp: 'append', extra: { saved: true } } as SessionFormatEvent + const expected = { ...row, type: `plugin:${type}` } + expect(migrate(row)).toEqual(expected) + expect(expected.data).toBe(row.data) + expect(namespaceV3OpaqueEvent(expected).type).not.toBe(expected.type) + }) + + it('leaves incomplete delivery payloads for generation validation', () => { + for (const data of [null, {}, { sessionFormatVersion: '4' }]) { + const row = event('session-log-deepseek/delivery-accepted', data) + expect(namespaceV3OpaqueEvent(row)).toBe(row) + } + }) + + it('keeps required unknown event identity for the source reader to reject', () => { + const unknown = event('future', {}) + expect(namespaceV3OpaqueEvent(unknown)).toBe(unknown) + const known = event('turn/start', { turn: 1 }) + expect(namespaceV3OpaqueEvent(known)).toBe(known) + }) +}) diff --git a/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts b/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts index 52198013d6..e1608bf4e9 100644 --- a/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts @@ -19,7 +19,7 @@ function restore(source: unknown, version: 3 | 4 = 4) { describe('V4 source admission', () => { it.each(['constructor', 'toString', '__proto__'])('migrates external producer %s without interpreting inherited object keys', (plugin) => { const before = JSON.parse(`{"kind":"plugin","plugin":"${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject - const expected = JSON.parse(`{"kind":"${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject + const expected = JSON.parse(`{"kind":"plugin:plugin:${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject const migrated = restore(before, 3) expect(migrated.events[0]?.data).toMatchObject({ source: expected }) expect(Object.hasOwn((migrated.events[0]?.data as { source: object }).source, '__proto__')).toBe(true) diff --git a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts index 741807837a..46688cca75 100644 --- a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts @@ -86,10 +86,14 @@ describe('V3 to V4 source preservation', () => { expect(() => migrate([fact, { ...seed, data: {} }], { ...header, isSeeded: true })).toThrow('inherited event count') }) - it('refuses a target-generation delivery without changing predecessor or future delivery values', () => { - expect(() => migrate([fact, delivery(4)])).toThrow('claims target format v4') - expect(() => restore([fact, delivery(4)])).toThrow('claims target format v4') - for (const version of [undefined, 0, 1, 2, 3, 5]) { + it('keeps higher-generation delivery records inactive without changing prior generation markers', () => { + for (const version of [4, 5, 99]) { + const marker = delivery(version) + const expected = { ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true } + expect(migrate([fact, marker]).events[1]).toEqual(expected) + expect(restore([fact, marker]).events[1]).toEqual(expected) + } + for (const version of [undefined, 0, 1, 2, 3]) { const marker = delivery(version) expect(migrate([fact, marker]).events[1]).toBe(marker) } @@ -122,7 +126,7 @@ describe('V3 to V4 source preservation', () => { type: 'external/opaque', seq: 0, time: -5, ignorable: true, data: { nested: { seq: 40 }, content: ['opaque'] }, surfaceOp: { future: { ref: 9 } }, } - expect(restore([opaque]).events).toEqual([opaque]) + expect(restore([opaque]).events).toEqual([{ ...opaque, type: 'plugin:external/opaque' }]) const { ignorable: _ignorable, ...required } = opaque expect(() => restore([required])).toThrow('unknown event type') expect(() => restore([{ ...fact, time: 1.5 }])).toThrow('time') diff --git a/packages/session/session-format-v3-to-v4/tests/retired-content.spec.ts b/packages/session/session-format-v3-to-v4/tests/retired-content.spec.ts index 68f70c9d38..9bffaced8a 100644 --- a/packages/session/session-format-v3-to-v4/tests/retired-content.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/retired-content.spec.ts @@ -107,6 +107,6 @@ describe('retired tool-result content admission', () => { recovery: 'strict', validation: 'current', }) migrated.decodeRow(opaque) - expect(migrated.finish().events).toEqual([opaque]) + expect(migrated.finish().events).toEqual([{ ...opaque, type: 'plugin:future/opaque' }]) }) }) diff --git a/packages/session/session-format-v3-to-v4/tests/sources.spec.ts b/packages/session/session-format-v3-to-v4/tests/sources.spec.ts index 2a24b264fe..4d118191da 100644 --- a/packages/session/session-format-v3-to-v4/tests/sources.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/sources.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' -import { mapEventMessages, rewritePluginSource } from '../src/sources.ts' +import { mapEventMessages, rewritePluginSource, rewriteV3MessageSource } from '../src/sources.ts' function event(type: string, data: SessionFormatJsonObject): SessionFormatEvent { return { type, seq: 0, time: 1, data } @@ -59,19 +59,19 @@ describe('rewritePluginSource', () => { expect(rewritePluginSource({ kind: 'plugin', plugin: 'tools-code-mode' }, 1, 'user')).toEqual({ kind: 'ptc-mode' }) expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'system')).toEqual({ kind: 'system-prompt' }) expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'user')).toEqual({ kind: 'runtime-context' }) - expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'external', extra: true }) + expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'plugin:plugin:external', extra: true }) }) - it('refuses a plugin field that is not a non-empty string', () => { - const plugins: readonly SessionFormatJsonValue[] = [null, '', 7] + it('refuses a plugin field that is not a string', () => { + const plugins: readonly SessionFormatJsonValue[] = [null, 7] for (const plugin of plugins) { expect(() => rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toThrow(/not canonical/) } }) - it('refuses external plugin names that collide with current producer kinds', () => { + it('namespaces external plugin names while retaining released first-party producers', () => { for (const plugin of ['plugin', 'user', 'model', 'tool', 'system-prompt', 'runtime-context', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'auto-review']) { - expect(() => rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toThrow(/collides with a current producer kind/) + expect(rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toEqual({ kind: `plugin:plugin:${plugin}` }) } expect(rewritePluginSource({ kind: 'plugin', plugin: 'agent-instructions', form: 'instructions', changes: [] }, 3, 'user')) .toEqual({ kind: 'agent-instructions', form: 'instructions', changes: [] }) @@ -79,3 +79,31 @@ describe('rewritePluginSource', () => { expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 3, 'system')).toEqual({ kind: 'system-prompt' }) }) }) + +describe('external V3 source identities', () => { + it.each(['', 'plugin:x', 'source:x', 'message', 'x/y', '\ud800', '__proto__'])('separates plugin and direct identity %j', (name) => { + expect(rewritePluginSource({ kind: 'plugin', plugin: name }, 1, 'user')).toEqual({ kind: `plugin:plugin:${name}` }) + if (name.length > 0) { + expect(rewriteV3MessageSource({ kind: name, payload: { kind: 'plugin', plugin: name } }, 1, 'user')) + .toEqual({ kind: `plugin:source:${name}`, payload: { kind: 'plugin', plugin: name } }) + } + }) + + it('keeps plugin and direct-source categories distinct across prefixed names', () => { + const plugin = rewriteV3MessageSource({ kind: 'plugin', plugin: 'source:x' }, 1, 'user') + const direct = rewriteV3MessageSource({ kind: 'x' }, 1, 'user') + expect(plugin).toEqual({ kind: 'plugin:plugin:source:x' }) + expect(direct).toEqual({ kind: 'plugin:source:x' }) + expect(plugin).not.toEqual(direct) + }) + + it('preserves native V3 sources and converts plugin wrappers', () => { + const source = { kind: 'tool', callId: 'call', extra: true } + expect(rewriteV3MessageSource(source, 1, 'user')).toBe(source) + expect(rewriteV3MessageSource({ kind: 'plugin', plugin: 'compact' }, 1, 'user')).toEqual({ kind: 'compact-checkpoint' }) + }) + + it.each([null, 0, ''])('rejects type-invalid source kind %j', (kind) => { + expect(() => rewriteV3MessageSource({ kind }, 1, 'user')).toThrow(/requires a nonempty kind/) + }) +}) diff --git a/packages/session/session-persistence-jsonl/tests/generation.spec.ts b/packages/session/session-persistence-jsonl/tests/generation.spec.ts index fa7ef5408b..d9f1c297cc 100644 --- a/packages/session/session-persistence-jsonl/tests/generation.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/generation.spec.ts @@ -361,7 +361,7 @@ describe('JSONL immutable generation publication', () => { { type: 'user/message', seq: 3, time: 5, surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2], data: { id: 'summary', role: 'user', content: [{ type: 'text', text: 'summary' }], - source: { kind: 'plugin', plugin: 'summary-fixture' }, + source: { kind: 'plugin', plugin: 'summary-fixture', form: 'notice', summary: 'summary-fixture' }, } }, { type: 'request/header', seq: 4, time: 6, data: { header: { config, system: '', tools: [], adapterDefaults: {} }, reason: 'initial', @@ -377,7 +377,9 @@ describe('JSONL immutable generation publication', () => { expect.objectContaining({ type: 'system/message', seq: 2, surfaceOp: 'append', data: expect.objectContaining({ message: expect.objectContaining({ role: 'system', content: [] }) as unknown }) as unknown }) as unknown, ...events.slice(2).map(event => event.seq === 3 ? { ...event, seq: 4, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3], - data: { ...event.data as Record, source: { kind: 'summary-fixture' } } } + data: { ...event.data as Record, source: { + kind: 'plugin:plugin:summary-fixture', form: 'notice', summary: 'summary-fixture', + } } } : event.seq === 4 ? { ...event, seq: 5, data: { header: { config }, reason: 'initial' } } : { ...event, seq: event.seq + 1 }), ] diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index c73b7e5483..d60e48cb56 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { createSessionFormatCatalogWithChildren } from '@deepseek-ai/dsh-session-format-catalog' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -248,6 +249,28 @@ async function expectOnlyGenerations(paths: readonly string[]) { } describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compression, access }) => { + async function expectV3Conversion( + rows: readonly SessionFormatJsonObject[], inspect: (events: readonly SessionEvent[]) => void, + ) { + const path = await store(3, compression, rows) + const original = await observe(path) + for (const mode of [access, 'read'] as const) { + const ctx = await mount(compression) + const handle = await ctx.sessionPersistence.open(id, mode) + try { + expect(handle.header.version).toBe(SESSION_FORMAT_VERSION) + const restored = await handle.read() + expect(restored.events.map(event => ({ seq: event.seq, time: event.time }))) + .toEqual(rows.map((_, seq) => ({ seq, time: 1001 + seq }))) + inspect(restored.events) + } finally { + await handle.close() + } + } + expect(await observe(path)).toEqual(original) + await expectOnlyGenerations(access === 'read' ? [path] : [path, generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, compression)]) + } + it.each(migrationRefusals)('refuses V2 $name without publishing or discarding a tail', async ({ tail, diagnostic }) => { const path = await store(2, compression, [...prefix, tail]) const original = await observe(path) @@ -266,17 +289,51 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres } }) - it('refuses a V3 watermark claiming V4 without publishing a successor', async () => { + it.each([4, 99])('preserves a V3 watermark claiming V%s as an opaque extension through native reopening', async (sessionFormatVersion) => { const marker = { type: 'session-log-deepseek/delivery-accepted', data: { - sessionId: id, throughSeq: 0, sessionFormatVersion: 4, + sessionId: id, throughSeq: 0, sessionFormatVersion, } } - const path = await store(3, compression, [...releasedPrefix, marker]) - const original = await observe(path) - const ctx = await mount(compression) - await expectRefusal(ctx, access, path, - 'format v3 delivery marker claims target format v4; source v3 artifact remains unchanged (raw log: ' + path + ')') - expect(await observe(path)).toEqual(original) - await expectOnlyGenerations([path]) + await expectV3Conversion([...releasedPrefix, marker], (events) => { + expect(events).toEqual([ + ...nativePrefix, + { ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }, + ].map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) + }) + }) + + it.each([false, true])('preserves V3 tool-definition deferLoading=%s and extension fields under plugin-prefixed keys', async (deferLoading) => { + const parameters = { + type: 'object', properties: { deferLoading: { type: 'boolean' } }, + default: { type: 'tool-result', toolCallId: 'opaque-call', content: [{ type: 'text', text: 'schema data' }] }, + } + const fields = JSON.parse('{"metadata":{"saved":"original metadata"},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject + const tool = { name: 'example', description: 'Saved tool definition.', parameters, deferLoading, ...fields } + const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'initial' } } + const targetTool = { + name: 'example', description: 'Saved tool definition.', parameters, + 'plugin:deferLoading': deferLoading, 'plugin:metadata': fields['metadata'], 'plugin:__proto__': fields['__proto__'], 'plugin:constructor': fields['constructor'], + } + await expectV3Conversion([...releasedPrefix.slice(0, -1), request], (events) => { + const expected = [...nativePrefix.slice(0, -1), { + ...request, data: { ...request.data, header: { config, tools: [targetTool] } }, + }] + expect(events).toEqual(expected.map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) + const migrated = events.find(event => event.type === 'request/header')?.data.header.tools?.[0] + expect(Object.hasOwn(migrated!, 'plugin:__proto__')).toBe(true) + expect(Object.getPrototypeOf(migrated!)).toBe(Object.prototype) + expect(Object.hasOwn(migrated!, 'deferLoading')).toBe(false) + }) + }) + + it('leaves a V3 tool definition with only declared fields unchanged', async () => { + const tool = { name: 'example', description: 'Saved tool definition.', parameters: { type: 'object' } } + const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'initial' } } + await expectV3Conversion([...releasedPrefix.slice(0, -1), request], (events) => { + expect(events).toEqual([...nativePrefix.slice(0, -1), request] + .map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) + const migrated = events.find(event => event.type === 'request/header')?.data.header.tools?.[0] + expect(Object.hasOwn(migrated!, 'metadata')).toBe(false) + }) }) it.each(['tool/code-dispatch-start', 'tool/code-dispatch'])('keeps %s unsupported beyond a damaged native suffix', async (type) => { diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index c752cd9df5..e63ac98802 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -755,13 +755,13 @@ describe('normalizeSessionSnapshot', () => { .toThrow('session snapshot must start with a session header') }) - function deliveryLog(version: number, deliveryVersion = version): string { + function deliveryLog(version: number, deliveryVersion = version, id = 'delivery'): string { return [ - { type: 'session', version, id: 'delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 }, + { type: 'session', version, id, createdAt: 1, isSeeded: false, delegationDepth: 0 }, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, - data: { sessionId: 'delivery', throughSeq: 0, sessionFormatVersion: deliveryVersion }, + data: { sessionId: id, throughSeq: 0, sessionFormatVersion: deliveryVersion }, }, ].map(record => JSON.stringify(record)).join('\n') + '\n' } @@ -792,9 +792,23 @@ describe('normalizeSessionSnapshot', () => { expect(staleCurrent).toContain('"sessionFormatVersion":3') }) - it('refuses a source marker claiming the migration target before creating comparison tokens', () => { - expect(() => normalizeSessionSnapshots([deliveryLog(3, 4)], ctx, { nativeWriterOutput: true })) - .toThrow('format v3 delivery marker claims target format v4') + it('keeps a higher-generation V3 delivery opaque before creating comparison tokens', () => { + const source = deliveryLog(3, 4, 'recorded-session') + const target = prepareSessionSnapshotFixtureForComparison(source) + const marker = JSON.parse(target.trimEnd().split('\n').at(-1)!) as unknown + expect(marker).toEqual({ + type: 'plugin:session-log-deepseek/delivery-accepted', seq: 1, time: 2, ignorable: true, + data: { sessionId: 'recorded-session', throughSeq: 0, sessionFormatVersion: 4 }, + }) + const [historical, migrated, native] = normalizeSessionSnapshots( + [source, target, deliveryLog(SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION, 'recorded-session')], ctx, { nativeWriterOutput: true }, + ) + expect(historical).toBe(migrated) + expect(historical).not.toBe(native) + expect(historical).toContain('"type":"plugin:session-log-deepseek/delivery-accepted"') + expect(historical).toContain('"throughSeq":0,"sessionFormatVersion":4') + expect(historical).not.toContain('{{sourceSessionFormatVersion}}') + expect(native).toContain('"sessionFormatVersion":"{{sourceSessionFormatVersion}}"') }) it('keeps captured generations and delivery lookalikes numeric in versioned inputs', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd03c89885..19396a2bbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1142,6 +1142,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-format-catalog': + specifier: workspace:^ + version: link:../../session/session-format-catalog '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 095fcd2f65..ab11dec8ff 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -102,7 +102,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -1783,7 +1783,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -3029,7 +3029,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } } ] @@ -3219,7 +3219,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -3822,7 +3822,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } } ] @@ -4028,7 +4028,7 @@ } ], "source": { - "kind": "serial-created-fixture" + "kind": "plugin:plugin:serial-created-fixture" } }, "surfaceOp": "append" diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl index 0002b43606..2bd289a8aa 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl @@ -2,7 +2,7 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} @@ -10,7 +10,7 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl index 05338dd916..dfe74b75a6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl @@ -2,7 +2,7 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} @@ -10,7 +10,7 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl index 55745f88d3..db9c9bce93 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl @@ -2,14 +2,14 @@ {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"} diff --git a/scripts/verify-v3-event-vocabulary.spec.ts b/scripts/verify-v3-event-vocabulary.spec.ts new file mode 100644 index 0000000000..c322a97b1f --- /dev/null +++ b/scripts/verify-v3-event-vocabulary.spec.ts @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4' +import { verifyV3EventVocabulary } from './verify-v3-event-vocabulary.ts' + +const roots: string[] = [] +const writerPath = 'packages/core/session/src/types.ts' +const eventsPath = 'packages/core/session/src/known-event-types.ts' +const names = [...RELEASED_V3_EVENT_TYPES] + +function write(root: string, path: string, content: string): void { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, content) +} + +function git(root: string, args: readonly string[]): string { + return execFileSync('git', [ + '-c', 'user.name=V3 vocabulary fixture', '-c', 'user.email=v3-fixture@example.invalid', + '-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${join(root, 'empty-hooks')}`, ...args, + ], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +function fixture(options: { writer?: string; events?: string } = {}): { root: string; sourceRef: string } { + const root = mkdtempSync(join(tmpdir(), 'dsh-v3-vocabulary-')) + roots.push(root) + write(root, writerPath, options.writer ?? 'export const SESSION_FORMAT_VERSION = 3 as const\n') + write(root, eventsPath, options.events ?? `export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set(${JSON.stringify(names)})\n`) + git(root, ['init', '--quiet']) + git(root, ['add', '.']) + git(root, ['commit', '--quiet', '-m', 'V3 source fixture']) + return { root, sourceRef: git(root, ['rev-parse', 'HEAD']) } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('explicit V3 source vocabulary verification', () => { + it('checks a pinned V3 writer independently of later V4 commits', () => { + const { root, sourceRef } = fixture() + write(root, writerPath, 'export const SESSION_FORMAT_VERSION = 4 as const\n') + write(root, eventsPath, `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`) + git(root, ['add', '.']) + git(root, ['commit', '--quiet', '-m', 'Later V4 writer with developer events']) + expect(verifyV3EventVocabulary(root, sourceRef)).toEqual({ sourceRef, eventCount: names.length }) + }) + + it('rejects new V3 names even when the target writer has not classified them yet', () => { + const { root, sourceRef } = fixture({ + events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'new-v3/required', 'new-v3/ignorable'])})\n`, + }) + expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('Missing V3 names: new-v3/ignorable, new-v3/required') + expect(verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'new-v3/required', 'new-v3/ignorable'])).eventCount) + .toBe(names.length + 2) + }) + + it('rejects a V4 source instead of treating developer/message as a V3 event', () => { + const { root, sourceRef } = fixture({ + writer: 'export const SESSION_FORMAT_VERSION = 4 as const\n', + events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`, + }) + expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('must be a V3 writer') + }) + + it('rejects a V4-only name accidentally copied into the migration-owned V3 set', () => { + const { root, sourceRef } = fixture() + expect(() => verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'developer/message']))) + .toThrow('Extra migration names: developer/message') + }) + + it.each(['HEAD', 'origin/master', 'dsh-v0.1.5-alpha.1', 'a'.repeat(10), '', '--help'])('rejects a mutable or incomplete source pin %j', (ref) => { + expect(() => verifyV3EventVocabulary(tmpdir(), ref)).toThrow('full immutable commit id') + }) + + it('reports a source object missing from the local repository without fetching', () => { + const { root } = fixture() + expect(() => verifyV3EventVocabulary(root, '0'.repeat(40))).toThrow('not available locally') + }) + + it('rejects tree objects and reports missing generated files at the source pin', () => { + const { root, sourceRef } = fixture() + expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', `${sourceRef}^{tree}`]))) + .toThrow('must name a commit object') + git(root, ['rm', eventsPath]) + git(root, ['commit', '--quiet', '-m', 'Source without generated event inventory']) + expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', 'HEAD']))).toThrow('not available locally') + }) + + it('accepts harmless parentheses and reordered generated event names', () => { + const { root, sourceRef } = fixture({ + writer: 'export const SESSION_FORMAT_VERSION = (3 as const)\n', + events: `export const KNOWN_SESSION_EVENT_TYPES = (new Set(${JSON.stringify([...names].reverse())}))\n`, + }) + expect(verifyV3EventVocabulary(root, sourceRef).eventCount).toBe(names.length) + }) + + it.each([ + 'const SESSION_FORMAT_VERSION = 3\n', + 'export const SESSION_FORMAT_VERSION: number\n', + 'export const SESSION_FORMAT_VERSION = 3, SESSION_FORMAT_VERSION = 3\n', + 'export const SESSION_FORMAT_VERSION = "3"\n', + ])('rejects an ambiguous or nonliteral writer declaration %j', (writer) => { + const { root, sourceRef } = fixture({ writer }) + expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow() + }) + + it.each([ + 'export const OTHER = new Set([])\n', + 'export const KNOWN_SESSION_EVENT_TYPES = []\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Map([])\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Set()\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Set(undefined)\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Set([...otherNames])\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Set([computeName()])\n', + 'export const KNOWN_SESSION_EVENT_TYPES = new Set(["duplicate", "duplicate"])\n', + ])('rejects unsupported generated declarations without evaluating them: %j', (events) => { + const { root, sourceRef } = fixture({ events }) + expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow() + }) +}) diff --git a/scripts/verify-v3-event-vocabulary.ts b/scripts/verify-v3-event-vocabulary.ts new file mode 100644 index 0000000000..1faf882555 --- /dev/null +++ b/scripts/verify-v3-event-vocabulary.ts @@ -0,0 +1,105 @@ +/** Compare the V3 migration vocabulary with an explicitly pinned local V3 writer. */ + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import ts from 'typescript' +import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4' +import { isEntry } from './release/process.ts' + +const WRITER_PATH = 'packages/core/session/src/types.ts' +const EVENTS_PATH = 'packages/core/session/src/known-event-types.ts' +const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u + +function sourceAt(root: string, sourceRef: string, path: string): string { + try { + return execFileSync('git', ['show', `${sourceRef}:${path}`], { + cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, + }) + } catch (error) { + throw new Error(`V3 source ${sourceRef}:${path} is not available locally; prepare the verified source pin before running this check`, { cause: error }) + } +} + +function initializer(source: string, path: string, name: string): ts.Expression { + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const declarations = file.statements.flatMap(statement => + ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) + ? statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name) + : []) + let value = declarations.length === 1 ? declarations[0]?.initializer : undefined + if (value === undefined) throw new Error(`${path}: expected one initialized export ${name}`) + while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression + return value +} + +function sourceEventTypes(source: string): ReadonlySet { + const value = initializer(source, EVENTS_PATH, 'KNOWN_SESSION_EVENT_TYPES') + const entries = ts.isNewExpression(value) && value.arguments?.length === 1 ? value.arguments[0] : undefined + if (!ts.isNewExpression(value) || !ts.isIdentifier(value.expression) || value.expression.text !== 'Set' + || entries === undefined || !ts.isArrayLiteralExpression(entries)) { + throw new Error(`${EVENTS_PATH}: KNOWN_SESSION_EVENT_TYPES must be a literal Set`) + } + const names = entries.elements.map((element) => { + if (!ts.isStringLiteral(element)) throw new Error(`${EVENTS_PATH}: event names must be string literals`) + return element.text + }) + const result = new Set(names) + if (result.size !== names.length) throw new Error(`${EVENTS_PATH}: duplicate event name`) + return result +} + +/** Result tied to the exact historical commit that was inspected. */ +export interface V3EventVocabularyCheck { + readonly sourceRef: string + readonly eventCount: number +} + +/** + * Verify the complete source vocabulary without consulting remote or current-writer event lists. + * @param root - Git checkout containing the already available source commit. + * @param sourceRef - full immutable commit id for the V3 writer used by the release integration. + * @param expected - migration-owned V3 event names to check. + * @returns the checked source pin and event count; does not establish remote freshness. + * @throws when the pin is missing, does not write V3, or its event names differ. + */ +export function verifyV3EventVocabulary( + root: string, sourceRef: string, expected: ReadonlySet = RELEASED_V3_EVENT_TYPES, +): V3EventVocabularyCheck { + if (!COMMIT_ID.test(sourceRef)) throw new Error('--source-ref requires a full immutable commit id, not a branch, tag, or abbreviated id') + let objectType: string + try { + objectType = execFileSync('git', ['cat-file', '-t', sourceRef], { + cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, + }).trim() + } catch (error) { + throw new Error(`V3 source commit ${sourceRef} is not available locally; prepare the verified source pin before running this check`, { cause: error }) + } + if (objectType !== 'commit') throw new Error('--source-ref must name a commit object') + const writer = initializer(sourceAt(root, sourceRef, WRITER_PATH), WRITER_PATH, 'SESSION_FORMAT_VERSION') + if (!ts.isNumericLiteral(writer) || Number(writer.text) !== 3) { + throw new Error(`source ${sourceRef} must be a V3 writer; keep V4-only events out of RELEASED_V3_EVENT_TYPES`) + } + const actual = sourceEventTypes(sourceAt(root, sourceRef, EVENTS_PATH)) + const missing = [...actual].filter(name => !expected.has(name)).sort() + const extra = [...expected].filter(name => !actual.has(name)).sort() + if (missing.length > 0 || extra.length > 0) { + throw new Error([ + `V3 event vocabulary differs from source ${sourceRef}; review source payload conversion before updating the migration-owned set.`, + `Missing V3 names: ${missing.join(', ') || '(none)'}`, + `Extra migration names: ${extra.join(', ') || '(none)'}`, + ].join('\n')) + } + return { sourceRef, eventCount: actual.size } +} + +function main(): void { + const { values } = parseArgs({ options: { 'source-ref': { type: 'string' } }, allowPositionals: false }) + if (values['source-ref'] === undefined) throw new Error('usage: verify-v3-event-vocabulary --source-ref ') + const result = verifyV3EventVocabulary(resolve(process.cwd()), values['source-ref']) + console.log(`verify-v3-event-vocabulary: ${result.eventCount} names match V3 writer ${result.sourceRef}; remote freshness was not checked.`) +} + +if (isEntry(import.meta.url)) main() From 2833ff566ee7ae3ab707df1d947e618b9fbd57e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:45:59 +0800 Subject: [PATCH 19/42] test(session): expect prefixed V3 opaque event identities on JSONL open The JSONL retired-content admission matrix covers both historical V3 input and native V4 input across read/write and plain/zstd storage. V3 migration now prefixes unknown ignorable event identities with plugin:, while native V4 reads retain their stored identity. The shared expectation still required the unchanged V3 event tag and caused four deterministic CI failures. Select the expected event type by input generation while retaining the full payload, envelope, and immutable source-byte assertions. This corrects the test for the intended migration without weakening native admission or changing runtime behavior. The focused 20-case matrix now passes; restoring the old expectation reproduces the same four failures locally. --- .../tests/retired-content-admission.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/session/session-persistence-jsonl/tests/retired-content-admission.spec.ts b/packages/session/session-persistence-jsonl/tests/retired-content-admission.spec.ts index eb7368af5e..d46b759df3 100644 --- a/packages/session/session-persistence-jsonl/tests/retired-content-admission.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/retired-content-admission.spec.ts @@ -50,7 +50,8 @@ describe.each(modes)('retired content in JSONL ($compression, $access)', ({ comp it.each([3, 4] as const)('preserves an unknown ignorable V%s payload without interpreting its content', async (version) => { const { ctx, path, bytes, event } = await stored(version, true, false) const handle = await ctx.sessionPersistence.open(id, access) - try { expect((await handle.read()).events).toEqual([event]) } finally { await handle.close() } + const expected = version === 3 ? { ...event, type: 'plugin:future/opaque' } : event + try { expect((await handle.read()).events).toEqual([expected]) } finally { await handle.close() } expect(await readFile(path)).toEqual(bytes) }) }) From 28b209867f17070c9e17239f880c058c354fb320 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:40:20 +0800 Subject: [PATCH 20/42] fix(session): preserve historical stream-start extension field names V3 and V4 declare the same block-start fields: type, index and blockType. The migration's identifier policy can qualify an extension blockType, but prefixing every additional field has no current V4 field collision to solve. That rewrite changed captured keys to anticipate hypothetical future fields. Retain every non-blockType field and value exactly, including already prefixed names and own JSON keys. Keep the approved blockType identifier prefix and the separate ToolSchema field policy unchanged. Update the local conversion tests to verify both known and extension starts retain metadata and to use the actual raw-record time field. Later field-specific conversion belongs to a concrete source/target change instead of this edge. --- .../session/session-format-v3-to-v4/README.i18n.yaml | 4 ++-- packages/session/session-format-v3-to-v4/README.md | 2 +- packages/session/session-format-v3-to-v4/README.zh.md | 2 +- .../session/session-format-v3-to-v4/src/content.ts | 4 +--- .../session-format-v3-to-v4/tests/content.spec.ts | 10 +++++----- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 5f7a913ffa..7259494569 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 352246595a3da2d5902c51ebf946595c30b0247e -README.zh.md: e2eebbd642122d4e0d71d379f8f342dd8d8b37f4 +README.md: 0cbdc50675085733fc5a8028e5456e29ec65f695 +README.zh.md: 29764dd973a61b6ee43e5c98a3908df29b3b803c diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 352246595a..0cbdc50675 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -100,7 +100,7 @@ A malformed canonical wrapper raises a format error; an unmapped wrapper extensi ### Extension data -Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType` and retain `type` and `index`; other start-owned field names receive a `plugin:` prefix. Request-tool definitions retain `name`, `description`, and `parameters`; all other own field names receive `plugin:` too, so historical `deferLoading` becomes `plugin:deferLoading` without enabling V4 capabilities. Existing prefixes double, keeping distinct old names distinct. Parameters, arguments, replay state, and plugin content fields are not traversed. These names preserve data identity; they do not load or execute plugins. +Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType`; every other field retains its original key and value. Request-tool definitions retain `name`, `description`, and `parameters`; all other own field names receive `plugin:` too, so historical `deferLoading` becomes `plugin:deferLoading` without enabling V4 capabilities. Existing prefixes double, keeping distinct old names distinct. Parameters, arguments, replay state, and plugin content fields are not traversed. These names preserve data identity; they do not load or execute plugins. ### Message-source conversion diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index e2eebbd642..29764dd973 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -100,7 +100,7 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp ### 扩展数据 -V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,并保留 `type` 和 `index`,其他自有字段名称添加 `plugin:` 前缀。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。 +V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,其他字段的原始键和值均保留。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。 ### 消息来源转换 diff --git a/packages/session/session-format-v3-to-v4/src/content.ts b/packages/session/session-format-v3-to-v4/src/content.ts index 67062e9c17..0dd45b228f 100644 --- a/packages/session/session-format-v3-to-v4/src/content.ts +++ b/packages/session/session-format-v3-to-v4/src/content.ts @@ -5,7 +5,6 @@ import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValu import { mapEventMessages } from './sources.ts' const V3_BLOCK_TYPES = new Set(['text', 'reasoning', 'image', 'file', 'tool-call', 'tool-result']) -const START_FIELDS = new Set(['type', 'index', 'blockType']) const TOOL_SCHEMA_FIELDS = new Set(['name', 'description', 'parameters']) /** Prefix extension-owned field names without reinterpreting their values. */ @@ -51,8 +50,7 @@ function migrateChunk(value: SessionFormatJsonValue | undefined, subject: string const original = value['blockType'] if (typeof original !== 'string') throw new SessionFormatError(`${subject} blockType must be a string`) const blockType = V3_BLOCK_TYPES.has(original) ? original : `plugin:${original}` - const mapped = namespaceFields(value, START_FIELDS) - return blockType === original ? mapped : { ...mapped, blockType } + return blockType === original ? value : { ...value, blockType } } /** diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts index 962de79b40..12c2f30804 100644 --- a/packages/session/session-format-v3-to-v4/tests/content.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts @@ -71,7 +71,7 @@ describe('V3 content conversion', () => { }) it.each(['assistant/message', 'assistant/attempt'])('converts raw %s start/end blocks and preserves start-owned fields', (type) => { - const chunk = (value: SessionFormatJsonValue): SessionFormatJsonObject => ({ type: 'chunk', chunk: value, elapsedMs: 7 }) + const chunk = (value: SessionFormatJsonValue): SessionFormatJsonObject => ({ type: 'chunk', chunk: value, time: 7 }) const stream = [ chunk({ type: 'block-start', index: 0, blockType: 'future', extensionName: 'old', metadata: { old: true }, custom: wrapper }), chunk({ type: 'block-end', index: 0, block: wrapper }), @@ -85,11 +85,11 @@ describe('V3 content conversion', () => { ] const actual = migrateV3EventContent(event(type, { message: { ...user, content: [] }, stream })) expect(actual.data).toEqual({ message: { ...user, content: [] }, stream: [ - chunk({ type: 'block-start', index: 0, blockType: 'plugin:future', 'plugin:extensionName': 'old', 'plugin:metadata': { old: true }, 'plugin:custom': wrapper }), + chunk({ type: 'block-start', index: 0, blockType: 'plugin:future', extensionName: 'old', metadata: { old: true }, custom: wrapper }), chunk({ type: 'block-end', index: 0, block: convertedBlock }), chunk({ type: 'block-start', index: 1, blockType: 'plugin:message' }), chunk({ type: 'block-end', index: 1, block: { type: 'plugin:message', content: [wrapper] } }), - stream[4], chunk({ type: 'block-start', index: 3, blockType: 'text', 'plugin:metadata': { original: true } }), + stream[4], stream[5], ...stream.slice(6), ] }) const unchanged = event(type, { message: { ...user, content: [] }, stream: stream.slice(6) }) @@ -114,7 +114,7 @@ describe('V3 content conversion', () => { expect(tool['deferLoading']).toBe(deferLoading) }) - it('keeps original prefixed keys distinct from unprefixed tool and stream extras', () => { + it('prefixes tool extension keys while preserving stream-start extra keys', () => { const extra = JSON.parse('{"deferLoading":true,"plugin:deferLoading":false,"metadata":{"saved":1},"plugin:metadata":{"saved":2},"__proto__":{"saved":3},"plugin:__proto__":{"saved":4}}') as SessionFormatJsonObject const expected = { 'plugin:deferLoading': true, 'plugin:plugin:deferLoading': false, @@ -126,7 +126,7 @@ describe('V3 content conversion', () => { expect(request.data).toEqual({ header: { tools: [{ name: 'echo', description: 'Echo', parameters: tool.parameters, ...expected }] } }) const start = { type: 'block-start', index: 7, blockType: 'plugin:text', ...extra } const stream = migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: start }] })) - expect(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { type: 'block-start', index: 7, blockType: 'plugin:plugin:text', ...expected } }] }) + expect(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { ...start, blockType: 'plugin:plugin:text' } }] }) expect(extra['deferLoading']).toBe(true) expect(Object.hasOwn(extra, '__proto__')).toBe(true) }) From fc74dbf4ea60cfa4ef460eb36d122b21a4999125 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:44:47 +0800 Subject: [PATCH 21/42] fix(session): limit delivery migration checks to the V4 target A V3 delivery marker claiming generation 4 would become active when its header advances to V4. The integration baseline already refused that case. Renaming every higher-generation marker instead introduced a general conversion policy for future formats that this V3-to-V4 edge does not own. Restore the targeted generation 4 refusal. Keep other delivery event names, payloads and coordinates unchanged, including generations 5 and 99, which remain inactive in V4. Their future activation checks belong to a concrete later format edge. Preserve the separate plugin-event identifier policy. Cover read/write opens for both plain and zstd storage: target-generation refusal preserves source bytes and publishes no successor, while later markers survive V4 publication and native reopening unchanged. Update the snapshot-normalizer cases and document the alpha converter coverage limit: unsupported histories may fail without source mutation, and later converter fixes may expand support without a new version when V4 output stays compatible. --- .../session-format-v3-to-v4/README.i18n.yaml | 4 ++-- .../session/session-format-v3-to-v4/README.md | 7 +++--- .../session-format-v3-to-v4/README.zh.md | 7 +++--- .../src/extension-identities.ts | 10 +++------ .../session-format-v3-to-v4/src/migration.ts | 3 +++ .../tests/catalog-completion.spec.ts | 4 ++-- .../tests/migration.spec.ts | 13 +++++------ .../tests/migration-refusal.spec.ts | 20 ++++++++++++----- .../session-snapshot/tests/normalize.spec.ts | 22 +++++++++---------- 9 files changed, 49 insertions(+), 41 deletions(-) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 7259494569..b0d7211e2b 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 0cbdc50675085733fc5a8028e5456e29ec65f695 -README.zh.md: 29764dd973a61b6ee43e5c98a3908df29b3b803c +README.md: 703a7be2c57f728f84ba5bb36ab2e16cb1a90e0c +README.zh.md: 709b5ecd476a19e2c8971db7546371d328fb0811 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 0cbdc50675..703a7be2c5 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -167,13 +167,13 @@ For a seeded Session, the last `session/end-seed` carrying `inherited: true` ide | Delivery record | Admission and preservation | |---|---| | Any interpreted `session-log-deepseek/delivery-accepted` | Generation must be a nonnegative safe integer; omission identifies V0. | -| V3 source marker claiming any generation above 3 | Namespace its event type as `plugin:session-log-deepseek/delivery-accepted` and set `ignorable: true`; keep the inactive payload and coordinates unchanged. | +| V3 source marker claiming generation 4 | Refuse: advancing the header must not activate a target-generation watermark. | | V3 source marker for generation 3 | Require a nonempty Session id and nonnegative safe-integer `throughSeq` before the marker; a foreign id is allowed only before the inherited cut with `parentSession`. | -| Source generations below 3 | Retain their ids, generation, and coordinates without activating them. | +| Other source generations, including values above 4 | Retain their event type, payload, and coordinates unchanged; they remain inactive in V4. | | Native V4 marker for generation 4 | Apply the same earlier-coordinate and Session-ownership checks using V4 as current. | | Native V4 historical marker, including generation 3 | Retain recorded coordinates and identity; it is not a V4 acceptance watermark. | -No delivery payload is rewritten. Namespacing higher-generation records preserves their inactive V3 meaning after this and future header promotions; it does not acknowledge delivery. The marker’s envelope sequence remains unchanged. +No delivery payload or event type is rewritten. Higher-version migrations own any future activation checks; this edge checks only promotion to V4. The marker’s envelope sequence remains unchanged. ### Source audit and refusal @@ -326,6 +326,7 @@ The edge preserves the recorded request prefix. Provider cache availability and +- **Historical converter coverage** — Unsupported source forms may fail without publishing a successor or changing the source. First-party recordings do not enumerate third-party extensions. Later converter fixes may add support after V4 publication if their V4 output remains compatible. Interpreting extra stream-start fields and preparing future delivery generations require a concrete format change. - **Accepted V4 transition** — the [checkpoint](../../../docs/session-format-status.md#finalization-record) protects the accepted history. Backward-compatible additions can remain V4 through new acknowledgements; breaking changes require a successor. Already-written V4 files do not rerun this incoming edge, and historical inputs remain intact. - **V5 prerequisite readers** — V4 child evidence currently goes through the installed catalog. A future writer must bind fixed-generation V4 prerequisite reading before changing that catalog. The exported V4 restorer supplies generation-owned checks; full common message admission additionally uses installed Session validation. - **Nested historical tool results** — migration refuses results containing another tool-result wrapper because flattening loses its call identity and error status. The original generation remains intact and no V4 successor is published; those histories need a preserving conversion before they can resume. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 29764dd973..709b5ecd47 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -167,13 +167,13 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe | Delivery 记录 | 接纳与保留 | |---|---| | 任何被解释的 `session-log-deepseek/delivery-accepted` | 代际必须为非负安全整数;省略表示 V0。 | -| V3 源标记声明高于 3 的任意代际 | 事件类型变为 `plugin:session-log-deepseek/delivery-accepted` 并设置 `ignorable: true`;保留未激活的 payload 与坐标。 | +| V3 源 marker 声明 generation 4 | 拒绝:提升 header 不得激活目标代际的 watermark。 | | generation 3 的 V3 源 marker | 要求非空 Session id 和早于 marker 的非负安全整数 `throughSeq`;只有在继承截点之前且带 `parentSession` 时才允许其他 Session id。 | -| 低于 3 的源代际 | 保留 id、代际与坐标,不激活它们。 | +| 其他源代际,包括高于 4 的值 | 原样保留事件类型、payload 与坐标;它们在 V4 中仍未激活。 | | generation 4 的原生 V4 marker | 以 V4 为当前代际,应用同样的较早坐标和 Session 归属检查。 | | 原生 V4 中的历史 marker,包括 generation 3 | 保留记录的坐标与身份;它不是 V4 接纳 watermark。 | -不改写投递 payload。对较高代际记录使用命名空间,保留其在本次及未来 header 升级后的 V3 未激活含义;这不确认投递。标记的信封序号保持不变。 +不改写投递 payload 或事件类型。未来的激活检查归对应的更高版本迁移所有,本边只检查向 V4 的提升。标记的信封序号保持不变。 ### 源审计与拒绝 @@ -326,6 +326,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 +- **历史转换器覆盖范围** — 未支持的源表示可能拒绝迁移,不发布后继文件,也不修改源文件。一方录制不等于第三方扩展全集。V4 发布后,只要输出仍兼容 V4,后续转换器修复就可以增加支持。解释流起始块的额外字段或处理未来投递代际,应以具体格式变更为依据。 - **已接受 V4 转换**——[检查点](../../../docs/session-format-status.zh.md#finalization-record)保护已接受历史。向后兼容的新增可以通过新的确认记录保留 V4;破坏性变更要求后继版本。已写入的 V4 文件不会重跑此入边,历史输入保持不变。 - **V5 前置读取器**——V4 子日志证据目前经过已安装目录。后续写入器在改变该目录前,须绑定固定代际的 V4 前置读取。导出的 V4 恢复器提供代际自有检查;完整的通用消息接纳还使用已安装的 Session 校验。 - **历史嵌套工具结果**——迁移会拒绝包含另一层 tool-result 包装的结果,因为展平会丢失其调用身份和错误状态。原始代际保持不变,也不会发布 V4 后继;这些历史需要能保留信息的转换才能继续运行。 diff --git a/packages/session/session-format-v3-to-v4/src/extension-identities.ts b/packages/session/session-format-v3-to-v4/src/extension-identities.ts index 6a75dcef27..95b2b4f1b4 100644 --- a/packages/session/session-format-v3-to-v4/src/extension-identities.ts +++ b/packages/session/session-format-v3-to-v4/src/extension-identities.ts @@ -1,6 +1,5 @@ /** Fixed V3 event vocabulary and reversible namespaces for historical extension identities. */ -import { isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format' // This historical list must not inherit additions or removals from the current Session event list. @@ -79,15 +78,12 @@ export function v3ExtensionIdentity(namespace: 'plugin' | 'source', name: string } /** - * Keep unknown events and higher-generation delivery records opaque after header promotion. - * @param event - source event after V3 vocabulary and delivery-generation admission. + * Keep unknown ignorable events opaque after header promotion. + * @param event - original V3 event; this incoming identity conversion is applied once. * @returns the same event or an ignorable namespaced event retaining its payload and coordinates. */ export function namespaceV3OpaqueEvent(event: SessionFormatEvent): SessionFormatEvent { - const inactiveDelivery = event.type === 'session-log-deepseek/delivery-accepted' - && isSessionFormatJsonObject(event.data) && typeof event.data['sessionFormatVersion'] === 'number' - && event.data['sessionFormatVersion'] > 3 - return inactiveDelivery || event['ignorable'] === true && !RELEASED_V3_EVENT_TYPES.has(event.type) + return event['ignorable'] === true && !RELEASED_V3_EVENT_TYPES.has(event.type) ? { ...event, type: `plugin:${event.type}`, ignorable: true } : event } diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index bd0bd39748..ff5d362b74 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -67,6 +67,9 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } const deliveryId = validateDeliveryAccepted(event, 3) if (event.type === 'session-log-deepseek/delivery-accepted') { + if ((event.data as SessionFormatJsonObject)['sessionFormatVersion'] === 4) { + throw new SessionFormatUnsupportedMigrationError('format v3 delivery marker claims target format v4') + } if (deliveryId !== undefined && deliveryId !== this.input.sourceHeader.id) this.foreignDeliverySeq = event.seq } const opaque = namespaceV3OpaqueEvent(event) diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index ee61c33b01..a99c5d3400 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -106,11 +106,11 @@ describe('V3 parent catalog completion', () => { expect(migrate([catalog], [], false, undefined, true).events).toEqual([catalog]) }) - it('keeps target delivery inactive and rejects invalid current delivery ownership', () => { + it('refuses target delivery activation and invalid current delivery ownership', () => { const preceding = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'prior' } } const delivery = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 1, data: { sessionId: 'other', throughSeq: 0, sessionFormatVersion: 4 } } - expect(migrate([preceding, delivery], []).events).toEqual([preceding, { ...delivery, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }]) + expect(() => migrate([preceding, delivery], [])).toThrow('claims target format v4') expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) diff --git a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts index 46688cca75..0fe648b98f 100644 --- a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts @@ -86,16 +86,13 @@ describe('V3 to V4 source preservation', () => { expect(() => migrate([fact, { ...seed, data: {} }], { ...header, isSeeded: true })).toThrow('inherited event count') }) - it('keeps higher-generation delivery records inactive without changing prior generation markers', () => { - for (const version of [4, 5, 99]) { - const marker = delivery(version) - const expected = { ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true } - expect(migrate([fact, marker]).events[1]).toEqual(expected) - expect(restore([fact, marker]).events[1]).toEqual(expected) - } - for (const version of [undefined, 0, 1, 2, 3]) { + it('refuses target-generation delivery while retaining other generations unchanged', () => { + expect(() => migrate([fact, delivery(4)])).toThrow('claims target format v4') + expect(() => restore([fact, delivery(4)])).toThrow('claims target format v4') + for (const version of [undefined, 0, 1, 2, 3, 5, 99]) { const marker = delivery(version) expect(migrate([fact, marker]).events[1]).toBe(marker) + expect(restore([fact, marker]).events[1]).toEqual(marker) } }) diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index d60e48cb56..348e9dd692 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -289,15 +289,25 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres } }) - it.each([4, 99])('preserves a V3 watermark claiming V%s as an opaque extension through native reopening', async (sessionFormatVersion) => { + it('refuses a V3 watermark claiming V4 without publishing a successor', async () => { + const marker = { type: 'session-log-deepseek/delivery-accepted', data: { + sessionId: id, throughSeq: 0, sessionFormatVersion: 4, + } } + const path = await store(3, compression, [...releasedPrefix, marker]) + const original = await observe(path) + const ctx = await mount(compression) + await expectRefusal(ctx, access, path, + 'format v3 delivery marker claims target format v4; source v3 artifact remains unchanged (raw log: ' + path + ')') + expect(await observe(path)).toEqual(original) + await expectOnlyGenerations([path]) + }) + + it.each([5, 99])('retains a V3 watermark claiming V%s unchanged through native reopening', async (sessionFormatVersion) => { const marker = { type: 'session-log-deepseek/delivery-accepted', data: { sessionId: id, throughSeq: 0, sessionFormatVersion, } } await expectV3Conversion([...releasedPrefix, marker], (events) => { - expect(events).toEqual([ - ...nativePrefix, - { ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }, - ].map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) + expect(events).toEqual([...nativePrefix, marker].map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) }) }) diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index e63ac98802..aaf53b8244 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -792,23 +792,23 @@ describe('normalizeSessionSnapshot', () => { expect(staleCurrent).toContain('"sessionFormatVersion":3') }) - it('keeps a higher-generation V3 delivery opaque before creating comparison tokens', () => { - const source = deliveryLog(3, 4, 'recorded-session') + it('refuses source delivery claiming V4 before creating comparison tokens', () => { + expect(() => normalizeSessionSnapshots([deliveryLog(3, 4)], ctx, { nativeWriterOutput: true })) + .toThrow('format v3 delivery marker claims target format v4') + }) + + it('retains future delivery generations without renaming their event identity', () => { + const source = deliveryLog(3, 99, 'recorded-session') const target = prepareSessionSnapshotFixtureForComparison(source) const marker = JSON.parse(target.trimEnd().split('\n').at(-1)!) as unknown expect(marker).toEqual({ - type: 'plugin:session-log-deepseek/delivery-accepted', seq: 1, time: 2, ignorable: true, - data: { sessionId: 'recorded-session', throughSeq: 0, sessionFormatVersion: 4 }, + type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, + data: { sessionId: 'recorded-session', throughSeq: 0, sessionFormatVersion: 99 }, }) - const [historical, migrated, native] = normalizeSessionSnapshots( - [source, target, deliveryLog(SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION, 'recorded-session')], ctx, { nativeWriterOutput: true }, - ) + const [historical, migrated] = normalizeSessionSnapshots([source, target], ctx, { nativeWriterOutput: true }) expect(historical).toBe(migrated) - expect(historical).not.toBe(native) - expect(historical).toContain('"type":"plugin:session-log-deepseek/delivery-accepted"') - expect(historical).toContain('"throughSeq":0,"sessionFormatVersion":4') + expect(historical).toContain('"sessionFormatVersion":99') expect(historical).not.toContain('{{sourceSessionFormatVersion}}') - expect(native).toContain('"sessionFormatVersion":"{{sourceSessionFormatVersion}}"') }) it('keeps captured generations and delivery lookalikes numeric in versioned inputs', () => { From 11ef33b3368d04c0f75ccd02729475b827be7fe8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:48:44 +0800 Subject: [PATCH 22/42] fix(session): reject unknown required V3 events before target interpretation The physical V3 codec admits an unclassified event envelope without deciding whether its type is required. The V3-to-V4 stage then used shared message walkers and target vocabulary, so an invalid required developer/message could acquire the V4 developer role although historical V3 restoration rejects it. Generic required unknown events were also left to the wrong version's reader. After preserving explicitly ignorable extensions, require every remaining source type to belong to the fixed V3 vocabulary before interpreting payloads. This is a fail-loud check for invalid or nonconforming source data, not new compatibility handling for third-party events, which carry ignorable:true. The approved plugin identifier conversion and native V4 readers are unchanged. Exercise a complete developer-event sequence through direct migration, historical/current catalogs, ignorable conversion and native V4 restoration; add a generic unknown type. Both negative controls accepted the invalid source before this guard. Plain/zstd read/write regressions verify refusal preserves the source generation and never publishes a partial V4 successor. --- .../session-format-v3-to-v4/README.i18n.yaml | 4 +-- .../session/session-format-v3-to-v4/README.md | 2 +- .../session-format-v3-to-v4/README.zh.md | 2 +- .../session-format-v3-to-v4/src/migration.ts | 7 +++- .../tests/content.spec.ts | 2 +- .../tests/migration.spec.ts | 33 +++++++++++++++++++ .../tests/migration-refusal.spec.ts | 16 +++++++++ 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index b0d7211e2b..ee96b84c2b 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 703a7be2c57f728f84ba5bb36ab2e16cb1a90e0c -README.zh.md: 709b5ecd476a19e2c8971db7546371d328fb0811 +README.md: fdb6c580f91dc89a73555289c6ddaa911afac414 +README.zh.md: eb25fea52b923915c8e73e2f93e9f57c8afa847f diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 703a7be2c5..fdb6c580f9 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -180,7 +180,7 @@ No delivery payload or event type is rewritten. Higher-version migrations own an V3 physical decoding and header validation run before the stage. The stage checks dense sequences, source cuts, canonical tool-result wrappers, named plugin sources, delivery ownership, and supplied catalog evidence as specified above. It does not run the complete released V3 semantic restorer or copy the V2→V3 event/content allowlist. Complete restoration additionally applies the V4 target rules below; physical parsing, stage conversion, and target restoration are distinct checks. -Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. Unknown required events are invalid V3 input; unknown ignorable names become `plugin:` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied. +Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. The stage rejects unknown required V3 event types before interpreting their payloads or consulting the V4 vocabulary, including names that V4 recognizes. Unknown ignorable names become `plugin:` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied. Only the canonical V3 `tool/result` wrapper has a preserving conversion. A retired `tool-result` block in another interpreted position is refused by target admission rather than retained as an invalid V4 block. Native V4 applies the same tag refusal before recoverable suffix suppression. The check covers only these positions: diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 709b5ecd47..eb25fea52b 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -180,7 +180,7 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe Stage 之前先执行 V3 物理解码与 header 校验。Stage 按上述规范检查连续序号、源截点、canonical 工具结果 wrapper、命名的 plugin 来源、delivery 归属及传入的目录证据。它不运行完整的已发布 V3 语义恢复器,也不复制 V2→V3 的事件/内容允许列表。完整恢复还会应用下述 V4 目标规则;物理解析、Stage 转换与目标恢复是不同的检查。 -只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。未知必需事件不是有效的 V3 输入;未知可忽略事件名称变为 `plugin:`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。 +只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。Stage 在解释载荷或查询 V4 词汇之前拒绝 V3 未知必需事件,包括 V4 已认识的名称。未知可忽略事件名称变为 `plugin:`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。 只有 canonical V3 `tool/result` wrapper 具有保留信息的转换。其他被解释位置中的已退役 `tool-result` block 由目标接纳拒绝,不作为无效 V4 block 保留。原生 V4 在 recoverable 后缀抑制前应用同样的标签拒绝。检查只覆盖以下位置: diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index ff5d362b74..b0898381de 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -6,7 +6,7 @@ import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3 import { mapEventMessages, rewriteV3MessageSource } from './sources.ts' import { liftToolResult } from './tool-role.ts' import { migrateV3EventContent } from './content.ts' -import { namespaceV3OpaqueEvent } from './extension-identities.ts' +import { namespaceV3OpaqueEvent, RELEASED_V3_EVENT_TYPES } from './extension-identities.ts' import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts' import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts' @@ -74,6 +74,11 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } const opaque = namespaceV3OpaqueEvent(event) if (opaque !== event) { context.emitEvent(opaque); return } + if (!RELEASED_V3_EVENT_TYPES.has(event.type)) { + throw new SessionFormatUnsupportedMigrationError( + `format v3 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`, + ) + } const rewritten = mapEventMessages(event, (message) => { const source = message['source'] if (!isSessionFormatJsonObject(source)) return message diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts index 12c2f30804..ecc3b6348e 100644 --- a/packages/session/session-format-v3-to-v4/tests/content.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts @@ -191,7 +191,7 @@ describe('unknown V3 event identities', () => { } }) - it('keeps required unknown event identity for the source reader to reject', () => { + it('leaves required unknown event identities for the migration stage to reject', () => { const unknown = event('future', {}) expect(namespaceV3OpaqueEvent(unknown)).toBe(unknown) const known = event('turn/start', { turn: 1 }) diff --git a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts index 0fe648b98f..0c4b64d959 100644 --- a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts @@ -40,6 +40,39 @@ function restoreHistorical(events: readonly SessionFormatEvent[], sourceHeader: } describe('V3 to V4 source preservation', () => { + it('rejects required V3 developer events while preserving ignorable events and native V4 admission', () => { + const rows: SessionFormatEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'developer/message', seq: 2, time: 3, surfaceOp: 'append', data: { + turn: 1, step: 1, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [] }, + } }, + { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const before = structuredClone(rows) + expect(() => migrate(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2') + expect(() => restore(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2') + expect(() => restoreHistorical(rows, header)).toThrow('unknown event type') + const ignorable = rows.map(row => row.type === 'developer/message' ? { ...row, ignorable: true } : row) + expect(restore(ignorable).events).toEqual(ignorable.map(row => + row.type === 'developer/message' ? { ...row, type: 'plugin:developer/message' } : row)) + const native = sessionFormatCatalog.createRestore({ type: 'session', ...header, version: 4 }, { + recovery: 'strict', validation: 'current', + }) + for (const row of rows) native.decodeRow(row) + expect(native.finish().events).toEqual(rows) + expect(rows).toEqual(before) + }) + + it('rejects generic required V3 extensions before target vocabulary admission', () => { + const required = { ...fact, type: 'external/required' } + expect(() => migrate([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0') + expect(() => restore([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0') + const ignorable = { ...required, ignorable: true } + expect(restore([ignorable]).events).toEqual([{ ...ignorable, type: 'plugin:external/required' }]) + }) + it('changes only the header version and retains event objects, payloads, timestamps, and coordinates', () => { const rows = [fact, delivery(3)] const before = JSON.stringify({ header, rows }) diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index 348e9dd692..5ccf2bb41e 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -302,6 +302,22 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres await expectOnlyGenerations([path]) }) + it.each(['developer/message', 'external/required'])('refuses invalid required V3 %s before publishing', async (type) => { + const row = { type, surfaceOp: 'append', data: { + turn: 1, step: 1, message: { + id: 'invalid-v3', role: 'developer', source: { kind: 'tool-registry' }, content: [], + }, + } } + const path = await store(3, compression, [...releasedPrefix, row]) + const original = await observe(path) + const ctx = await mount(compression) + await expectRefusal(ctx, access, path, + `format v3 contains unknown event type ${JSON.stringify(type)} at seq ${releasedPrefix.length}` + + '; source v3 artifact remains unchanged (raw log: ' + path + ')') + expect(await observe(path)).toEqual(original) + await expectOnlyGenerations([path]) + }) + it.each([5, 99])('retains a V3 watermark claiming V%s unchanged through native reopening', async (sessionFormatVersion) => { const marker = { type: 'session-log-deepseek/delivery-accepted', data: { sessionId: id, throughSeq: 0, sessionFormatVersion, From d5302e33f83a70fb82323b81fd95c191a2cb5b03 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:55:03 +0800 Subject: [PATCH 23/42] refactor(session): separate attachment carrier policy from V4 migration The event-type allowlist and opaque-payload attachment policy change live Session authorization/export behavior independently of a format upgrade. They belong in a focused PR against master, where supported native V3 nested tool-result content must also be handled deliberately. Restore the session-controller and archive implementation, tests and README pairs to this layer's parent. Remove the test-only session-format-catalog dependency and its lock entry with the migration-specific attachment tests. The original changes are captured from immutable commit 85737364be9 for the standalone work; migration identifier conversion and reader checks are intact. This extraction keeps the integration stack's existing attachment behavior until the separate policy change is reviewed. It makes no Session-format, schema or version change. --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 - packages/api/session-controller/README.zh.md | 2 - packages/api/session-controller/package.json | 1 - .../api/session-controller/src/commands.ts | 57 ++++-------- .../commands-queue-attachment.host.spec.ts | 90 ++----------------- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 2 - .../session-log-export/README.zh.md | 2 - .../session-log-export/src/archive.ts | 47 ++++------ .../tests/archive.host.spec.ts | 70 +-------------- pnpm-lock.yaml | 3 - 12 files changed, 48 insertions(+), 236 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index ef274f24bf..8ceb4a8da4 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: de20b2bacf0b563404b66f716c3ffdca01cb8058 -README.zh.md: cc94e573b0bc5d52323d06d010c12457749b1914 +README.md: 228ed88ed20b9f9581496468e47079afd1542e4f +README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index de20b2bacf..228ed88ed2 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -54,8 +54,6 @@ A resume blocked by an existing write handle returns `session/writer-held` with Queue edits replace pending content with non-empty text only. -Attachment authorization inspects only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data cannot authorize attachment reads. - ## Client references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index cc94e573b0..edebf8bdda 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -54,8 +54,6 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 队列编辑仅允许用非空文本替换待处理内容。 -附件授权只检查已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据不能授权读取附件。 - ## Client 引用 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e45f6b120d..643c553e0a 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -141,7 +141,6 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-format-catalog": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 4323315694..fa456772de 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -620,16 +620,19 @@ function imageBlockIn( if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown } + const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef if (match(ref)) return ref } + if (block.type === 'tool-result') { + const nested = imageBlockIn(block.content, match) + if (nested !== undefined) return nested + } } return undefined } -/** Read only first-party declared content fields; unknown event payloads stay opaque. */ function imageInEvent( event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean, @@ -637,46 +640,22 @@ function imageInEvent( const data = event.data as { readonly content?: unknown readonly message?: { readonly content?: unknown } - readonly inserted: readonly { readonly content?: unknown }[] - readonly messages: readonly { readonly content?: unknown }[] - readonly summary?: unknown - readonly rawOutput?: unknown + readonly inserted?: readonly { readonly content?: unknown }[] } - // First-party event payloads can be present without their producer plugin mounted. - const type: string = event.type - switch (type) { - case 'user/message': - case 'tool/ptc-dispatch': - return imageBlockIn(data.content, match) - case 'system/message': - case 'developer/message': - case 'tool/result': - case 'team/message/queued': - return imageBlockIn(data.message?.content, match) - case 'agent/inbox/spliced': - case 'session/title-llm-request': { - const messages = type === 'agent/inbox/spliced' ? data.inserted : data.messages - for (const message of messages) { - const found = imageBlockIn(message.content, match) - if (found !== undefined) return found - } - return undefined - } - case 'compaction/summary': - return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) - case 'assistant/message': { - const found = imageBlockIn(data.message?.content, match) - if (found !== undefined) return found - break - } - case 'assistant/attempt': break - default: return undefined - } - const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> - for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + const message = imageBlockIn(data.message?.content, match) + if (message !== undefined) return message + for (const inserted of data.inserted ?? []) { + const found = imageBlockIn(inserted.content, match) if (found !== undefined) return found } + if (event.type === 'assistant/message' || event.type === 'assistant/attempt') { + for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) { + const found = imageBlockIn([chunk.block], match) + if (found !== undefined) return found + } + } return undefined } diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 2dd7454c37..3f1a45967d 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -10,7 +10,6 @@ import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' -import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' @@ -317,8 +316,7 @@ function imageRef(id: string): ImageAttachmentRef { } function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) - return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent + return { type, seq, time: seq + 1, data } as SessionEvent } async function persistedController( @@ -350,90 +348,16 @@ async function persistedController( } describe('Session attachment authorization', () => { - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], - ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], - ['session/title-llm-request', 'messages'], ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { - const ref = imageRef(`${type}-${field}`) - const content = [{ type: 'image', attachment: ref }] - const source = type === 'system/message' ? { kind: 'system-prompt' } - : type === 'tool/result' ? { kind: 'tool', callId: 'declared-call' } : { kind: 'test' } - const data = field === 'message' ? { message: { - id: 'declared-message', role: type.split('/')[0], content, source, - ...type === 'tool/result' ? { toolCallId: 'declared-call' } : {}, - } } - : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } - const events = [event(type, SessionSeq(0), data)] - const saved = JSON.stringify(events) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(events, readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) - expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) - expect(JSON.stringify(events)).toBe(saved) - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('ignores unrelated fields of a known message event and its content blocks', async () => { - const ref = imageRef('not-a-content-occurrence') - const content = [{ type: 'image', attachment: ref }] - const stored = event('user/message', SessionSeq(0), { - id: 'metadata', role: 'user', source: { kind: 'user' }, - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([stored], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid' }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('denies attachment access from a migrated unknown ignorable event', async () => { - const ref = imageRef('opaque-event-image') - const content = [{ type: 'message', message: { - role: 'tool', toolCallId: 'opaque-call', content: [{ type: 'image', attachment: ref }], - } }] - const header = { type: 'session', version: 3, id: 'cold-attachment', createdAt: 1, isSeeded: false, delegationDepth: 0 } - const original = { type: 'external/image-record', seq: 0, time: 1, ignorable: true, data: { - content, message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], - } } - const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' }) - historical.decodeRow(original) - expect(historical.finish().header.version).toBe(3) - const reader = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' }) - reader.decodeRow(original) - const converted = reader.finish() - expect(converted.events[0]).toMatchObject({ type: 'plugin:external/image-record', data: original.data }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(converted.events as SessionEvent[], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('finds references in direct, message, inserted, and streamed content', async () => { + it('finds references in direct, message, inserted, nested, and streamed content', async () => { const nested = imageRef('nested') const message = imageRef('message') const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events: SessionEvent[] = [ - { ...event('user/message', SessionSeq(0), { - id: 'direct', role: 'user', source: { kind: 'user' }, - content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: nested }], + { ...event('fixture/direct', SessionSeq(0), { + content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { + type: 'tool-result', content: [{ type: 'image', attachment: nested }], + }], }), ignorable: true as const }, { type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', @@ -527,7 +451,7 @@ describe('Session attachment authorization', () => { ]) { const ref = imageRef(`failure-${thrown.name}`) const fixture = await persistedController( - [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], + [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })], () => Promise.reject(thrown), ) await expectFailure(fixture.controller.attachment({ diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index f475fe9c87..a2c80bc2d6 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: 7fa004df954260de16a6a151e3110e6adfbc0f09 -README.zh.md: 147ee93b3929473d4e78024ff0b4ccbd040a1220 +README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87 +README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 7fa004df95..79b8d572a7 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -59,8 +59,6 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. -Attachment collection reads only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data remain opaque; the exported log retains them unchanged. - ### Failures The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 147ee93b39..d622992713 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -59,8 +59,6 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。 -附件收集只读取已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据保持不透明,导出的日志原样保留它们。 - ### 失败 当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 3dbf5211e1..8728ca9878 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -196,8 +196,9 @@ function fileEntryPath(ref: FileAttachmentRef): string { } /** - * Collect attachment references from one flat content array; all other block fields remain opaque. - * @param content - a declared event content array. + * Collect every attachment reference inside one content array, descending into + * nested tool results the way the live attachment route does. + * @param content - an event content array (or nested tool-result content). * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. */ @@ -212,7 +213,7 @@ function collectAttachmentRefs( while (pending.length > 0) { const value = pending.pop() if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown } + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef images.set(String(ref.attachmentId), ref) @@ -221,12 +222,16 @@ function collectAttachmentRefs( const ref = block.attachment as FileAttachmentRef files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref) } + if (Array.isArray(block.content)) { + for (const item of block.content) pending.push(item) + } } } /** - * Collect references only from declared first-party content fields and completed - * Assistant blocks. Unknown events and unrelated payload fields remain opaque. + * Collect every attachment reference one session event carries, across the same + * carriers the live attachment route scans (direct content, message content, + * inserted messages, and completed blocks in embedded Assistant streams). * @param event - one parsed JSONL event object. * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. @@ -236,40 +241,18 @@ function collectEventAttachmentRefs( images: Map, files: Map, ): void { - if (typeof event !== 'object' || event === null || Array.isArray(event)) return - const row = event as { type?: unknown; data?: unknown } - const data = row.data + const data = (event as { data?: unknown }).data if (typeof data !== 'object' || data === null) return const carrier = data as { content?: unknown message?: { content?: unknown } inserted?: Array<{ content?: unknown }> - messages?: Array<{ content?: unknown }> - summary?: unknown - rawOutput?: unknown stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }> } - switch (row.type) { - case 'user/message': case 'tool/ptc-dispatch': - collectAttachmentRefs(carrier.content, images, files) - return - case 'system/message': case 'developer/message': case 'tool/result': case 'team/message/queued': - collectAttachmentRefs(carrier.message?.content, images, files) - return - case 'agent/inbox/spliced': case 'session/title-llm-request': { - const messages = row.type === 'agent/inbox/spliced' ? carrier.inserted : carrier.messages - for (const message of messages ?? []) collectAttachmentRefs(message.content, images, files) - return - } - case 'compaction/summary': - collectAttachmentRefs(carrier.summary, images, files) - collectAttachmentRefs(carrier.rawOutput, images, files) - return - case 'assistant/message': - collectAttachmentRefs(carrier.message?.content, images, files) - break - case 'assistant/attempt': break - default: return + collectAttachmentRefs(carrier.content, images, files) + if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) + if (carrier.inserted !== undefined) { + for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) } if (carrier.stream !== undefined) { for (const record of carrier.stream) { diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 011896e79b..973dbe9d91 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -503,15 +503,6 @@ describe('session.export download endpoint', () => { expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('') }) - it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => { - const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }] - const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } }, - { type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n') - const files = await directZipFiles(text) - expect(Object.keys(files)).toEqual([exportLogName]) - expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text) - }) - it('waits for response pull capacity before reading the next archive entry', async () => { const filler = { type: 'user/message', seq: SessionSeq(2), time: 1000, @@ -806,63 +797,10 @@ describe('session.export download endpoint', () => { await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') }) - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['team/message/queued', 'message'], - ['tool/ptc-dispatch', 'content'], ['session/title-llm-request', 'messages'], - ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('exports attachments from the declared %s %s content', async (type, field) => { - const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] - const data = field === 'message' ? { message: { content } } - : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } - const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as unknown as SessionEvent]) - const api = await buildApi({ 'session-root': stored }) - const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/declared-image.png', exportLogName].sort()) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - }) - - it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => { - const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] - const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, data: { - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }], - } } as unknown as SessionEvent - const stored = log('session-root', undefined, [event]) - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) - const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage }) - const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual([exportLogName]) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - expect(readImage).not.toHaveBeenCalled() - }) - - it('exports an unknown ignorable event without reading attachments from its opaque payload', async () => { - const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } } - const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } } - const content = [{ type: 'message', message: { role: 'tool', toolCallId: 'opaque', content: [image, file] } }] - const opaque = { type: 'plugin:external/image-record', seq: SessionSeq(1), time: 1000, ignorable: true, data: { - content, message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }], - } } as unknown as SessionEvent - const stored = log('session-root', undefined, [opaque]) - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) - const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) }) - const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream }) - const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root')) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual([exportLogName]) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - expect(readImage).not.toHaveBeenCalled() - expect(readFileStream).not.toHaveBeenCalled() - }) - - it('collects media referenced from flat tool results', async () => { + it('collects media referenced from nested tool results', async () => { const nested = { - type: 'tool/result', seq: SessionSeq(2), time: 2000, - data: { message: { role: 'tool', toolCallId: 'outer', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] } }, + type: 'assistant/message', seq: SessionSeq(2), time: 2000, + data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] }, } as unknown as SessionEvent const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) }) const response = await toFetchHandler(api).fetch( @@ -880,7 +818,7 @@ describe('session.export download endpoint', () => { data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } }, } as unknown as SessionEvent const inserted = { - type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000, + type: 'context/inserted', seq: SessionSeq(3), time: 3000, data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] }, } as unknown as SessionEvent const attempt = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19396a2bbb..fd03c89885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1142,9 +1142,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-format-catalog': - specifier: workspace:^ - version: link:../../session/session-format-catalog '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence From 88897a16c6b00304f1b2d43155c747db0be6e938 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:56:51 +0800 Subject: [PATCH 24/42] refactor(session): separate Team opaque JSON preservation from migration Retaining own keys in the Team projection/checkpoint parser fixes a generic JSON reconstruction defect. It is independent of V3-to-V4 identifier conversion and can be reviewed directly against master, where recursive V3 tool-result content is still valid. Restore the Team implementation, tests and paired README to this layer's parent after the standalone owner captured the exact delta from 85737364be9. The standalone port retains V3 validators and applies only the non-cloning unknown-JSON repair. Migration content handling for team/message/queued and all approved namespace, delivery and source-admission rules remain intact. This removes an independent runtime/parser policy from the format stack; it does not change any Session schema, codec, version or stored source file. --- .../experimental/agent-team/README.i18n.yaml | 4 +-- packages/experimental/agent-team/README.md | 2 -- packages/experimental/agent-team/README.zh.md | 2 -- .../experimental/agent-team/src/projection.ts | 20 ++++++++----- .../tests/projection-events.spec.ts | 29 ------------------- 5 files changed, 14 insertions(+), 43 deletions(-) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 028e62ac57..47dbb4329a 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 280ea4aac70ce68da48d85dac6313c05b78a8c09 -README.zh.md: 8446da495cfc476d3620a0240e4c322604b56d3c +README.md: 6e2b55018971ec96802de0e5be042d49b45c674a +README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 280ea4aac7..6e2b550189 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -145,8 +145,6 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append. -Mailbox projection and checkpoint admission validate known content fields and preserve unknown plugin content as decoded JSON, including own `__proto__` keys. Unknown block fields remain opaque; retired tool-result content is rejected. - ### Disposal Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 8446da495c..b497d3790e 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -145,8 +145,6 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。 -邮箱投影和检查点准入校验已知内容字段,并按解码后的 JSON 保留未知插件内容,包括自有 `__proto__` 键。未知块字段保持不透明,退役的 tool-result 内容被拒绝。 - ### Dispose dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。 diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 6c1a6cb377..85fdfd0e0d 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -42,7 +42,7 @@ const imageAttachmentSchema = z.object({ // ContentBlockMap is merge-extensible. Validate every core variant exactly, // while retaining JSON-decoded plugin variants under an unknown type tag. -const contentBlockSchema: z.ZodType = z.union([ +const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ z.object({ type: z.literal('text'), text: z.string() }).strict(), z.object({ type: z.literal('reasoning'), text: z.string() }).strict(), z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(), @@ -52,13 +52,17 @@ const contentBlockSchema: z.ZodType = z.union([ name: z.string(), arguments: z.string(), }).strict(), - // Unknown content is decoded JSON; validation must preserve its own extension keys. - z.custom((value) => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const type = (value as { type?: unknown }).type - return typeof type === 'string' && type.length > 0 && !coreContentBlockTypes.has(type) - }), -]) as z.ZodType + z.object({ + type: z.literal('tool-result'), + toolCallId: z.string().min(1), + content: z.array(contentBlockSchema), + isError: z.boolean().optional(), + }).strict(), + z.object({ type: z.string().min(1) }).loose().refine( + block => !coreContentBlockTypes.has(block.type), + { message: 'known content block types must match their declared fields' }, + ), +])) as z.ZodType const teamMemberSnapshotSchema = z.object({ id: sessionIdSchema, diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 4c1db3c3cd..5f9728a979 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,4 +1,3 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' @@ -302,34 +301,6 @@ describe('Agent Teams projection events', () => { expect(pending(state)[0]?.content).toEqual([extension]) }) - it('preserves opaque extension keys through projection and checkpoints', () => { - const opaque = JSON.parse('{"__proto__":{"saved":true},"constructor":{"saved":false},"content":[{"type":"tool-result","toolCallId":"opaque","content":[]}]}') as Record - const content: ContentBlock[] = [{ ...opaque, type: 'plugin:vendor' } as unknown as ContentBlock] - const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content }) }, SessionSeq(0)) - const before = JSON.stringify(queued) - const projected = projectTeam(ROOT, [queued]) - expect(projected.messages[0]?.content).toEqual(content) - const checkpoint = teamProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(projected))) - expect(JSON.stringify(checkpoint)).toBe(JSON.stringify(projected)) - expect(JSON.stringify(queued)).toBe(before) - const block = checkpoint.messages[0]!.content[0] as unknown as Record - expect(Object.hasOwn(block, '__proto__')).toBe(true) - expect(Object.getPrototypeOf(block)).toBe(Object.prototype) - }) - - it.each([ - null, [], 3, - { type: 'tool-result', toolCallId: 'retired', content: [] }, - { type: '' }, - { type: null }, - { type: 'text', text: false }, - ])('rejects malformed or retired current content in events and checkpoints: %j', (block) => { - const saved = message({ content: [block] as unknown as ContentBlock[] }) - const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: saved }, SessionSeq(0)) - expect(() => projectTeam(ROOT, [queued])).toThrow(/team\/message\/queued payload is invalid/) - expect(() => teamProjectionDefinition.stateSchema.parse({ ...project(ROOT, []), messages: [saved] })).toThrow() - }) - it('records unsupported event versions without applying them', () => { const invalid = event('team/task', { version: 1 as 2, From 4d9c4e7a128b8685cfe2cc1f9d28d538d04e5a1a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:05:28 +0800 Subject: [PATCH 25/42] fix(session): reject retired tool results in V4 Team checkpoints Central V4 event admission already rejects tool-result content in Team queue records, but projection checkpoints use the Team state schema directly. After restoring the baseline Team parser during extraction, the production SessionProjectionRegistry.restore() path accepted a decoded native V4 checkpoint containing the retired wrapper without reading an event. Remove only that V4-incompatible schema arm. Keep the existing unknown-JSON parser unchanged; its independent own-key preservation fix remains in the standalone master PR, where recursive V3 tool results are still valid. Exercise the real checkpoint restoration recipe with a valid text state and an invalid retired-result state, plus queued-event projection. The new checkpoint rejection failed before this change; central event admission and the complete Team owner suite pass afterward with 100% projection coverage. No Session schema, format version or projection state version is changed: the rejected value is already outside the native V4 content vocabulary. --- .../experimental/agent-team/README.i18n.yaml | 4 +-- packages/experimental/agent-team/README.md | 2 ++ packages/experimental/agent-team/README.zh.md | 2 ++ .../experimental/agent-team/src/projection.ts | 10 ++----- .../tests/projection-events.spec.ts | 28 +++++++++++++++++-- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 47dbb4329a..74dbfe69df 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 6e2b55018971ec96802de0e5be042d49b45c674a -README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5 +README.md: 31ea956d2e39dfe25410929be2a574b4c3f07a7d +README.zh.md: 03344285ceaee965c4154f939f22b592c833840b diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6e2b550189..31ea956d2e 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -145,6 +145,8 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append. +Native V4 Team event and checkpoint admission reject retired `tool-result` content before it can enter mailbox state. Historical conversion belongs to the Session-format migration; the Team projection does not convert old wrappers. + ### Disposal Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index b497d3790e..03344285ce 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -145,6 +145,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。 +原生 V4 的 Team 事件及检查点准入会拒绝退役的 `tool-result` 内容,防止它进入邮箱状态。历史转换由 Session 格式迁移负责,Team 投影不转换旧包装。 + ### Dispose dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。 diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 85fdfd0e0d..d5dff9784c 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -40,8 +40,8 @@ const imageAttachmentSchema = z.object({ name: z.string().optional(), }).strict() -// ContentBlockMap is merge-extensible. Validate every core variant exactly, -// while retaining JSON-decoded plugin variants under an unknown type tag. +// Validate the listed variants; retired tool-result tags cannot enter the +// merge-extensible fallback for JSON-decoded plugin content. const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ z.object({ type: z.literal('text'), text: z.string() }).strict(), z.object({ type: z.literal('reasoning'), text: z.string() }).strict(), @@ -52,12 +52,6 @@ const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ name: z.string(), arguments: z.string(), }).strict(), - z.object({ - type: z.literal('tool-result'), - toolCallId: z.string().min(1), - content: z.array(contentBlockSchema), - isError: z.boolean().optional(), - }).strict(), z.object({ type: z.string().min(1) }).loose().refine( block => !coreContentBlockTypes.has(block.type), { message: 'known content block types must match their declared fields' }, diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 5f9728a979..e5443d4135 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' @@ -82,6 +84,28 @@ function message(overrides: Partial = {}): TeamMessageSnaps } describe('Agent Teams projection events', () => { + it('rejects retired tool-result content when restoring a native V4 Team checkpoint', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + const session = ctx.sessions.create(ROOT) + const restore = (val: unknown) => ctx.sessionProjections.restore({ + agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val }, + }, [], SessionLogOffset(0), session.header, SessionLogOffset(0)) + const valid = { ...project(ROOT, []), messages: [message()] } + expect(restore(JSON.parse(JSON.stringify(valid))).checkpoint['agentTeam']?.val).toEqual(valid) + const retiredMessage = message({ content: [{ + type: 'tool-result', toolCallId: 'retired', content: [{ type: 'text', text: 'old result' }], + }] as unknown as TeamMessageSnapshot['content'] }) + const retired = { ...valid, messages: [retiredMessage] } + expect(() => restore(JSON.parse(JSON.stringify(retired)))).toThrow() + expect(() => projectTeam(ROOT, [event('team/message/queued', { + version: 2, teamId: TEAM, message: retiredMessage, + }, SessionSeq(0))])).toThrow(/team\/message\/queued payload is invalid/) + }) + it('projects current-team records independently from inherited records', () => { const records: SessionEvent[] = [ event('team/member', { version: 2, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)), From 696aa5f97cf7a7627d24239e41fe2e228702eadf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:16:41 +0800 Subject: [PATCH 26/42] docs(session): describe retained source kinds by historical admission The preserved direct-source set is the predecessor migration's accepted set without the plugin wrapper. It includes older coordinator and subagent-report identities even though those names have no current producer declaration. Describe that historical admission fact instead of implying every retained identity has a live V4 owner. No mapping or runtime behavior changes. --- packages/session/session-format-v3-to-v4/src/sources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session/session-format-v3-to-v4/src/sources.ts b/packages/session/session-format-v3-to-v4/src/sources.ts index f27fc4e113..592598a25e 100644 --- a/packages/session/session-format-v3-to-v4/src/sources.ts +++ b/packages/session/session-format-v3-to-v4/src/sources.ts @@ -45,7 +45,7 @@ const RENAMED_PRODUCERS: Readonly> = Object.freeze({ '@deepseek-ai/dsh-system-prompt': 'runtime-context', }) -/** Native V3 producer kinds whose identity already has a current owner. */ +/** Direct source kinds admitted by V2-to-V3, excluding the retired plugin wrapper. */ const V3_NATIVE_PRODUCERS: ReadonlySet = new Set([ 'user', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', From ec2c4f2a42ddff833274056a6e709e56c98f1b22 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:18:57 +0800 Subject: [PATCH 27/42] test(session): isolate vocabulary fixture Git commands from caller paths The new vocabulary fixture helper set cwd to a private repository but still inherited GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE. A negative control with two disposable repositories demonstrated that git add/commit changed the foreign repository's HEAD instead of the intended fixture repository. Remove only those three location variables from the child environment. Leave the caller's environment untouched and retain the existing identity, hook and signing overrides. The regression checks the foreign HEAD and work tree remain unchanged while the intended target commits its private file. This is confined to the test helper; it adds no production Git abstraction or Session-format behavior. --- scripts/verify-v3-event-vocabulary.spec.ts | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/verify-v3-event-vocabulary.spec.ts b/scripts/verify-v3-event-vocabulary.spec.ts index c322a97b1f..fbac2d0439 100644 --- a/scripts/verify-v3-event-vocabulary.spec.ts +++ b/scripts/verify-v3-event-vocabulary.spec.ts @@ -17,11 +17,13 @@ function write(root: string, path: string, content: string): void { writeFileSync(target, content) } -function git(root: string, args: readonly string[]): string { +function git(root: string, args: readonly string[], environment: NodeJS.ProcessEnv = process.env): string { + const env = Object.fromEntries(Object.entries(environment).filter(([key]) => + !['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'].includes(key.toUpperCase()))) return execFileSync('git', [ '-c', 'user.name=V3 vocabulary fixture', '-c', 'user.email=v3-fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${join(root, 'empty-hooks')}`, ...args, - ], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + ], { cwd: root, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() } function fixture(options: { writer?: string; events?: string } = {}): { root: string; sourceRef: string } { @@ -40,6 +42,26 @@ afterEach(() => { }) describe('explicit V3 source vocabulary verification', () => { + it('keeps fixture writes inside their own repository despite inherited Git location variables', () => { + const target = fixture() + const foreign = fixture() + write(target.root, 'target-only.txt', 'target') + write(foreign.root, 'foreign-only.txt', 'foreign') + const before = git(foreign.root, ['status', '--porcelain']) + const environment = { + ...process.env, + GIT_DIR: join(foreign.root, '.git'), + GIT_WORK_TREE: foreign.root, + GIT_INDEX_FILE: join(foreign.root, '.git', 'foreign-index'), + } + git(target.root, ['add', '--all'], environment) + git(target.root, ['commit', '--quiet', '-m', 'Target fixture update'], environment) + expect(git(foreign.root, ['rev-parse', 'HEAD'])).toBe(foreign.sourceRef) + expect(git(foreign.root, ['status', '--porcelain'])).toBe(before) + expect(git(target.root, ['show', 'HEAD:target-only.txt'])).toBe('target') + expect(environment.GIT_DIR).toBe(join(foreign.root, '.git')) + }) + it('checks a pinned V3 writer independently of later V4 commits', () => { const { root, sourceRef } = fixture() write(root, writerPath, 'export const SESSION_FORMAT_VERSION = 4 as const\n') From d5a109c60f51e6ed9ae9092efaffd827fe88de89 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:11:45 +0800 Subject: [PATCH 28/42] fix(session): preserve direct V3 source kinds during wrapper conversion Convert an unknown legacy plugin wrapper such as {kind: 'plugin', plugin: 'acme'} into {kind: 'plugin:acme'}, retaining the complete plugin name after one prefix. Keep the existing explicit first-party producer renames and preserve all non-identity metadata. A direct source already records its producer in kind. Reclassifying unknown direct kinds into plugin:source: needlessly changes that attribution and forces plugin consumers to adopt new identifiers even though V4 accepts their existing representation. Return every valid direct source unchanged, including already-prefixed names. Remove the obsolete direct-kind table and two-category identity helper rather than adding collision classification or another mapping layer. Update the source conversion examples, codec/restore regressions, and native fixture producers together. Refresh only the owning Python advanced scenario's unshipped V4 outputs; preserve historical V0-V3 recordings and their generation selection. Event names, content tags, and ToolSchema migration remain separate concerns. Refs #2692 --- ...9-producer-owned-message-sources.i18n.yaml | 4 ++-- ...26-09-09-producer-owned-message-sources.md | 4 ++-- ...09-09-producer-owned-message-sources.zh.md | 4 ++-- .../tests/fixtures/serial-created.mjs | 2 +- .../tests/messages/fixtures/input-history.ts | 4 ++-- .../session-format-v3-to-v4/README.i18n.yaml | 4 ++-- .../session/session-format-v3-to-v4/README.md | 6 +++--- .../session-format-v3-to-v4/README.zh.md | 6 +++--- .../src/extension-identities.ts | 12 +---------- .../session-format-v3-to-v4/src/sources.ts | 16 ++++---------- .../tests/message-sources.spec.ts | 9 +++++++- .../tests/sources.spec.ts | 21 +++++++------------ .../tests/generation.spec.ts | 2 +- .../advanced/result.json | 12 +++++------ .../advanced/session.1.v4.jsonl | 4 ++-- .../advanced/session.2.v4.jsonl | 4 ++-- .../advanced/session.v4.jsonl | 4 ++-- 17 files changed, 50 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml index 2ecbfbff83..1514b7c6d3 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.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/architecture/2026-09-09-producer-owned-message-sources.md -2026-09-09-producer-owned-message-sources.md: f2d3ce0f403bc521b853945a2461210fa33e368b -2026-09-09-producer-owned-message-sources.zh.md: d617de9f07026f00dfc4d83e9b1adb1f36f61d4f +2026-09-09-producer-owned-message-sources.md: 83f8cb9368b516b7b6fde8717f9e5f8e9d4f7d4e +2026-09-09-producer-owned-message-sources.zh.md: f5cdfc48286f7b92883938c8e316a7751580f52c diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md index f2d3ce0f40..83f8cb9368 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md @@ -6,11 +6,11 @@ English | [中文](2026-09-09-producer-owned-message-sources.zh.md) ## Problem -Released V3 writers stamped every plugin-produced message with the shared wrapper `{ kind: 'plugin', plugin: '' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper. +Released V3 plugin attribution uses the shared wrapper `{ kind: 'plugin', plugin: '' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper. ## Decision -Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:plugin:` before the complete original name, and unknown direct source kinds receive `plugin:source:`. The distinct namespaces preserve identity without letting an old plugin named `user` acquire the human-user kind or collide with an old direct kind. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data. +Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:` before the complete original name, while direct source kinds retain their original names. The prefix keeps a plugin named `user` separate from the human-user kind; this edge leaves direct attribution unchanged. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data. Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules. diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md index d617de9f07..f5cdfc4828 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md @@ -6,11 +6,11 @@ Status: implemented ## Problem -已发布的 V3 写入方把每个插件产出的消息都盖上共享包装`{ kind: 'plugin', plugin: '' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。 +已发布 V3 的插件归属使用共享包装 `{ kind: 'plugin', plugin: '' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。 ## Decision -消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:plugin:`,未知的直接 source kind 则添加 `plugin:source:`。两个命名空间在保留身份的同时,防止名为 `user` 的旧插件获得人类用户 kind,或与旧的直接 kind 冲突。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。 +消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:`,直接 source kind 则保留原名。该前缀将名为 `user` 的插件与人类用户 kind 区分开;本边保持直接归属不变。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。 原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。 diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index a2a0bb1b72..c4ed2df17c 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -14,7 +14,7 @@ export function apply(ctx) { id: randomUUID(), role: 'user', content: [{ type: 'text', text: 'Serial agent creation completed.' }], - source: { kind: `plugin:plugin:${name}` }, + source: { kind: `plugin:${name}` }, }) ready.add(agent) }) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts index 229e3ea25b..ce98439370 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts +++ b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts @@ -8,7 +8,7 @@ import { serialize } from '../../../src/protocols/messages/serialize.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - 'plugin:plugin:messages-input-history-snapshot': { kind: 'plugin:plugin:messages-input-history-snapshot' } + 'plugin:messages-input-history-snapshot': { kind: 'plugin:messages-input-history-snapshot' } } } @@ -20,7 +20,7 @@ export function apply(ctx: Context): void { ctx.on('agent/pre-step', async (_input, next) => { const decision = await next() if (decision.kind !== 'enter') return decision - const source = { kind: `plugin:plugin:${name}` } as const + const source = { kind: `plugin:${name}` } as const return { ...decision, messages: [ ...decision.messages, createUserMessage({ source, content: [ diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index ee96b84c2b..580ad5a450 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: fdb6c580f91dc89a73555289c6ddaa911afac414 -README.zh.md: eb25fea52b923915c8e73e2f93e9f57c8afa847f +README.md: 815261d1f41361fa892526c3ebc8217a00b4652f +README.zh.md: 1e8abb6682b86ffd58292798cf2a35b4a0fb58ff diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index fdb6c580f9..815261d1f4 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -105,7 +105,7 @@ Unknown V3 content tags become `plugin:`; all other fields remain ### Message-source conversion -The [message walker](src/sources.ts) visits only these payload positions. It maps known V3 identities and gives external plugin names and external direct source kinds separate reserved namespaces. +The [message walker](src/sources.ts) visits only these payload positions. It converts legacy plugin wrappers and retains direct source kinds. | Owning event | Message position | |---|---| @@ -125,11 +125,11 @@ A plugin source requires a string `plugin`, including the empty string. Conversi | `@deepseek-ai/dsh-system-prompt`, on a system-role message | `system-prompt` | | `@deepseek-ai/dsh-system-prompt`, on another role | `runtime-context` | | Same-name first-party producers listed below | The exact plugin string | -| Any other plugin name | `plugin:plugin:` followed by the complete original name | +| Any other plugin name | `plugin:` followed by the complete original name | The same-name producers are `agent-instructions`, `session-reference`, `team-message`, `goal`, `skill-invocation`, `skill-catalog`, `coordinator`, `subagent-report`, `subagent-settled`, `webhook`, `agent-message`, `model-selection`, `plan-mode`, `time-context`, `tmux-context`, `user-approval`, `repeat-tool-reminder`, `tool-cordis`, `cordis-host-runner`, `tool-goal`, `tool-jobs`, `hooks-codex`, `hooks-claude-code`, `schedule`, and `dsh-session-title-llm`. -Namespacing prepends `plugin:plugin:` to an external plugin name or `plugin:source:` to an external direct kind. It keeps the complete original string, including an existing prefix. A plugin named `source:x` becomes `plugin:plugin:source:x`, while a direct kind named `x` becomes `plugin:source:x`; the categories cannot collide. Known V3 direct kinds remain unchanged. +The complete plugin string is retained after `plugin:`: a plugin named `acme` becomes `plugin:acme`. Direct sources, including unknown and already-prefixed kinds, keep their original kind and every own JSON field. There is no recursive source search. Captured request text, assistant replay state and streams, tool arguments/content metadata, Team payloads, and arbitrary nested objects remain unchanged unless another explicitly named rule applies. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index eb25fea52b..1e8abb6682 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -105,7 +105,7 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 ### 消息来源转换 -[消息遍历器](src/sources.ts) 只访问下列 payload 位置。它映射已知 V3 身份,并为外部插件名与外部直接来源 kind 分配独立的保留命名空间。 +[消息遍历器](src/sources.ts) 只访问下列 payload 位置。它转换旧插件包装,并保留直接来源 kind。 | 所属事件 | 消息位置 | |---|---| @@ -125,11 +125,11 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 | system 角色消息中的 `@deepseek-ai/dsh-system-prompt` | `system-prompt` | | 其他角色中的 `@deepseek-ai/dsh-system-prompt` | `runtime-context` | | 下文列出的同名第一方生产者 | 精确的 plugin 字符串 | -| 其他任何插件名 | `plugin:plugin:` 后接完整的原始名称 | +| 其他任何插件名 | `plugin:` 后接完整的原始名称 | 同名生产者为 `agent-instructions`、`session-reference`、`team-message`、`goal`、`skill-invocation`、`skill-catalog`、`coordinator`、`subagent-report`、`subagent-settled`、`webhook`、`agent-message`、`model-selection`、`plan-mode`、`time-context`、`tmux-context`、`user-approval`、`repeat-tool-reminder`、`tool-cordis`、`cordis-host-runner`、`tool-goal`、`tool-jobs`、`hooks-codex`、`hooks-claude-code`、`schedule` 和 `dsh-session-title-llm`。 -命名空间为外部插件名称添加 `plugin:plugin:`,为外部直接 kind 添加 `plugin:source:`,完整保留包括已有前缀在内的原字符串。名为 `source:x` 的插件变为 `plugin:plugin:source:x`,直接 kind `x` 则变为 `plugin:source:x`,两类身份不会碰撞。已知 V3 直接 kind 保持不变。 +完整的插件字符串保留在 `plugin:` 之后:名为 `acme` 的插件变为 `plugin:acme`。直接来源保留原 kind 和每个自有 JSON 字段,包括未知或已有前缀的 kind。 来源查找不递归进行。捕获的请求文本、assistant 回放状态与流、工具参数/内容元数据、Team 载荷以及任意嵌套对象均保留,除非另有明确命名的规则适用。 diff --git a/packages/session/session-format-v3-to-v4/src/extension-identities.ts b/packages/session/session-format-v3-to-v4/src/extension-identities.ts index 95b2b4f1b4..20b9c732ed 100644 --- a/packages/session/session-format-v3-to-v4/src/extension-identities.ts +++ b/packages/session/session-format-v3-to-v4/src/extension-identities.ts @@ -1,4 +1,4 @@ -/** Fixed V3 event vocabulary and reversible namespaces for historical extension identities. */ +/** Fixed V3 event vocabulary and namespaced historical opaque events. */ import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format' @@ -67,16 +67,6 @@ export const RELEASED_V3_EVENT_TYPES: ReadonlySet = new Set([ ]) /* jscpd:ignore-end */ -/** - * Qualify the complete original name with its historical identity namespace. - * @param namespace - owner of the historical identity. - * @param name - complete original source name. - * @returns a namespaced identity retaining the complete original string as its suffix. - */ -export function v3ExtensionIdentity(namespace: 'plugin' | 'source', name: string): string { - return `plugin:${namespace}:${name}` -} - /** * Keep unknown ignorable events opaque after header promotion. * @param event - original V3 event; this incoming identity conversion is applied once. diff --git a/packages/session/session-format-v3-to-v4/src/sources.ts b/packages/session/session-format-v3-to-v4/src/sources.ts index 592598a25e..3c6a288613 100644 --- a/packages/session/session-format-v3-to-v4/src/sources.ts +++ b/packages/session/session-format-v3-to-v4/src/sources.ts @@ -1,8 +1,7 @@ -/** V3 producer identities with reserved namespaces for external names. */ +/** Released V3 plugin-source conversion and declared message traversal. */ import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' -import { v3ExtensionIdentity } from './extension-identities.ts' /** * Visit only messages carried by first-party event payloads. @@ -45,13 +44,6 @@ const RENAMED_PRODUCERS: Readonly> = Object.freeze({ '@deepseek-ai/dsh-system-prompt': 'runtime-context', }) -/** Direct source kinds admitted by V2-to-V3, excluding the retired plugin wrapper. */ -const V3_NATIVE_PRODUCERS: ReadonlySet = new Set([ - 'user', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', - 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', - 'subagent-settled', 'webhook', 'agent-message', -]) - /** First-party V3 plugin identities that intentionally keep their current kind. */ const RELEASED_SAME_NAME_PRODUCERS: ReadonlySet = new Set([ 'agent-instructions', 'session-reference', 'team-message', 'goal', @@ -69,7 +61,7 @@ function producerKind(plugin: string, role: SessionFormatJsonValue | undefined): const renamed = Object.hasOwn(RENAMED_PRODUCERS, plugin) ? RENAMED_PRODUCERS[plugin] : undefined if (renamed !== undefined) return renamed if (RELEASED_SAME_NAME_PRODUCERS.has(plugin)) return plugin - return v3ExtensionIdentity('plugin', plugin) + return `plugin:${plugin}` } /** @@ -98,7 +90,7 @@ export function rewritePluginSource( } /** - * Retain known V3 producer identities and namespace external kinds separately from plugin names. + * Convert released plugin wrappers while retaining every direct source kind and its metadata. * @param source - decoded V3 message source. * @param seq - event sequence for diagnostics. * @param role - enclosing message role for role-sensitive producer mappings. @@ -110,5 +102,5 @@ export function rewriteV3MessageSource( const kind = source['kind'] if (typeof kind !== 'string' || kind.length === 0) throw new SessionFormatError(`message source at seq ${seq} requires a nonempty kind`) if (kind === 'plugin') return rewritePluginSource(source, seq, role) - return V3_NATIVE_PRODUCERS.has(kind) ? source : { ...source, kind: v3ExtensionIdentity('source', kind) } + return source } diff --git a/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts b/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts index e1608bf4e9..28566a4c3b 100644 --- a/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/message-sources.spec.ts @@ -19,7 +19,7 @@ function restore(source: unknown, version: 3 | 4 = 4) { describe('V4 source admission', () => { it.each(['constructor', 'toString', '__proto__'])('migrates external producer %s without interpreting inherited object keys', (plugin) => { const before = JSON.parse(`{"kind":"plugin","plugin":"${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject - const expected = JSON.parse(`{"kind":"plugin:plugin:${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject + const expected = JSON.parse(`{"kind":"plugin:${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject const migrated = restore(before, 3) expect(migrated.events[0]?.data).toMatchObject({ source: expected }) expect(Object.hasOwn((migrated.events[0]?.data as { source: object }).source, '__proto__')).toBe(true) @@ -27,6 +27,13 @@ describe('V4 source admission', () => { expect(before['kind']).toBe('plugin') }) + it.each(['acme', 'plugin:acme', 'source:acme', '__proto__'])('retains direct source kind %s through migration and native restore', (kind) => { + const source = { kind, extra: { kind: 'plugin', plugin: 'opaque' } } + const migrated = restore(source, 3) + expect(migrated.events[0]?.data).toMatchObject({ source }) + expect(restore(source)).toEqual(migrated) + }) + it('rejects missing, malformed, empty, and retired source kinds', () => { for (const source of [null, [], {}, { kind: '' }, { kind: 1 }, { kind: 'plugin', plugin: 'compact' }]) { expect(() => restore(source)).toThrow(/producer-owned source kind/) diff --git a/packages/session/session-format-v3-to-v4/tests/sources.spec.ts b/packages/session/session-format-v3-to-v4/tests/sources.spec.ts index 4d118191da..83a54b2262 100644 --- a/packages/session/session-format-v3-to-v4/tests/sources.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/sources.spec.ts @@ -59,7 +59,7 @@ describe('rewritePluginSource', () => { expect(rewritePluginSource({ kind: 'plugin', plugin: 'tools-code-mode' }, 1, 'user')).toEqual({ kind: 'ptc-mode' }) expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'system')).toEqual({ kind: 'system-prompt' }) expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'user')).toEqual({ kind: 'runtime-context' }) - expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'plugin:plugin:external', extra: true }) + expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'plugin:external', extra: true }) }) it('refuses a plugin field that is not a string', () => { @@ -71,7 +71,7 @@ describe('rewritePluginSource', () => { it('namespaces external plugin names while retaining released first-party producers', () => { for (const plugin of ['plugin', 'user', 'model', 'tool', 'system-prompt', 'runtime-context', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'auto-review']) { - expect(rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toEqual({ kind: `plugin:plugin:${plugin}` }) + expect(rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toEqual({ kind: `plugin:${plugin}` }) } expect(rewritePluginSource({ kind: 'plugin', plugin: 'agent-instructions', form: 'instructions', changes: [] }, 3, 'user')) .toEqual({ kind: 'agent-instructions', form: 'instructions', changes: [] }) @@ -81,20 +81,13 @@ describe('rewritePluginSource', () => { }) describe('external V3 source identities', () => { - it.each(['', 'plugin:x', 'source:x', 'message', 'x/y', '\ud800', '__proto__'])('separates plugin and direct identity %j', (name) => { - expect(rewritePluginSource({ kind: 'plugin', plugin: name }, 1, 'user')).toEqual({ kind: `plugin:plugin:${name}` }) - if (name.length > 0) { - expect(rewriteV3MessageSource({ kind: name, payload: { kind: 'plugin', plugin: name } }, 1, 'user')) - .toEqual({ kind: `plugin:source:${name}`, payload: { kind: 'plugin', plugin: name } }) - } + it.each(['', 'acme', 'plugin:x', 'source:x', 'message', 'x/y', '\ud800', '__proto__'])('prefixes the complete plugin name %j', (name) => { + expect(rewritePluginSource({ kind: 'plugin', plugin: name }, 1, 'user')).toEqual({ kind: `plugin:${name}` }) }) - it('keeps plugin and direct-source categories distinct across prefixed names', () => { - const plugin = rewriteV3MessageSource({ kind: 'plugin', plugin: 'source:x' }, 1, 'user') - const direct = rewriteV3MessageSource({ kind: 'x' }, 1, 'user') - expect(plugin).toEqual({ kind: 'plugin:plugin:source:x' }) - expect(direct).toEqual({ kind: 'plugin:source:x' }) - expect(plugin).not.toEqual(direct) + it.each(['acme', 'plugin:acme', 'source:acme', 'message', 'x/y', '\ud800', '__proto__'])('preserves direct source kind %j and its metadata', (kind) => { + const source = { kind, payload: { kind: 'plugin', plugin: kind } } + expect(rewriteV3MessageSource(source, 1, 'user')).toBe(source) }) it('preserves native V3 sources and converts plugin wrappers', () => { diff --git a/packages/session/session-persistence-jsonl/tests/generation.spec.ts b/packages/session/session-persistence-jsonl/tests/generation.spec.ts index d9f1c297cc..4d398dca4b 100644 --- a/packages/session/session-persistence-jsonl/tests/generation.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/generation.spec.ts @@ -378,7 +378,7 @@ describe('JSONL immutable generation publication', () => { ...events.slice(2).map(event => event.seq === 3 ? { ...event, seq: 4, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3], data: { ...event.data as Record, source: { - kind: 'plugin:plugin:summary-fixture', form: 'notice', summary: 'summary-fixture', + kind: 'plugin:summary-fixture', form: 'notice', summary: 'summary-fixture', } } } : event.seq === 4 ? { ...event, seq: 5, data: { header: { config }, reason: 'initial' } } : { ...event, seq: event.seq + 1 }), ] diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index ab11dec8ff..e73cfda81d 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -102,7 +102,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -1783,7 +1783,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -3029,7 +3029,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } } ] @@ -3219,7 +3219,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } }, "surfaceOp": "append" @@ -3822,7 +3822,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } } ] @@ -4028,7 +4028,7 @@ } ], "source": { - "kind": "plugin:plugin:serial-created-fixture" + "kind": "plugin:serial-created-fixture" } }, "surfaceOp": "append" diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl index 2bd289a8aa..5dbb0f995e 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl @@ -2,7 +2,7 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} @@ -10,7 +10,7 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl index dfe74b75a6..7cad0f6b2c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl @@ -2,7 +2,7 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} @@ -10,7 +10,7 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl index db9c9bce93..0efb2ccbf6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl @@ -2,14 +2,14 @@ {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"} From c6f6aa32938a5dbe41b91b9a5906817ebe0b3ac5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:15:38 +0800 Subject: [PATCH 29/42] fix(session): preserve V3 tool-schema metadata keys The V3 writer on master declares and emits only name, description and parameters. deferLoading is a first-class V4 persistence addition whose provider behavior is not implemented yet. Prefixing every historical extra ToolSchema key changes ordinary plugin metadata without a format need. Preserve all ordinary tool-definition keys and JSON values unchanged, including vendor metadata, own __proto__ properties, already-prefixed keys and nested parameters. Remove the general key-renaming mapper. Reject only an own top-level deferLoading field in V3 input: there is no established V3 meaning to translate, and the alpha converter may refuse unsupported source forms rather than invent compatibility semantics. Leave native V4's optional-true declaration, admission and provider refusal behavior intact. The regression tests fail on the old mapper for metadata preservation and unsupported-field refusal. Physical plain/zstd read/write tests verify source bytes and identity remain unchanged and no V4 successor appears on refusal; controls preserve nested parameters.deferLoading and native V4 true values. No Session format/schema change, frozen codec edit, or historical recording version bump is needed. --- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 4 +- .../session-format-v3-to-v4/README.zh.md | 4 +- .../session-format-v3-to-v4/src/content.ts | 31 ++++------ .../tests/content.spec.ts | 60 +++++++++++-------- .../tests/migration-refusal.spec.ts | 32 ++++++---- 6 files changed, 75 insertions(+), 60 deletions(-) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 580ad5a450..48963c4516 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 815261d1f41361fa892526c3ebc8217a00b4652f -README.zh.md: 1e8abb6682b86ffd58292798cf2a35b4a0fb58ff +README.md: 5a6e912de13511f63ae71b3cf90027fffebe0b10 +README.zh.md: 3c13375ae1fc9fcc21e4eee24256794e7ef595c9 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 815261d1f4..5a6e912de1 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -100,7 +100,9 @@ A malformed canonical wrapper raises a format error; an unmapped wrapper extensi ### Extension data -Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType`; every other field retains its original key and value. Request-tool definitions retain `name`, `description`, and `parameters`; all other own field names receive `plugin:` too, so historical `deferLoading` becomes `plugin:deferLoading` without enabling V4 capabilities. Existing prefixes double, keeping distinct old names distinct. Parameters, arguments, replay state, and plugin content fields are not traversed. These names preserve data identity; they do not load or execute plugins. +Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType`; every other field retains its original key and value. An existing prefix on a content tag is prefixed again, keeping distinct old names distinct. Arguments, replay state, and plugin content fields are not traversed. These names do not load or execute plugins. + +Request-tool definitions retain their own field names and values, including ordinary extension metadata. A definition with its own top-level `deferLoading` field refuses V3 migration: the field is defined only in V4, so this edge assigns it no historical meaning. Nested parameter data is unchanged. Native V4 admission of `deferLoading: true` is unchanged. ### Message-source conversion diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 1e8abb6682..3c13375ae1 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -100,7 +100,9 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp ### 扩展数据 -V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,其他字段的原始键和值均保留。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。 +V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,其他字段的原始键和值均保留。内容标签的已有前缀会再次添加,使不同旧名称保持不同。不遍历实参、回放状态和插件内容字段。这些名称不会加载或执行插件。 + +请求工具定义保留自有字段的原始名称和值,包括普通扩展元数据。若定义拥有顶层 `deferLoading` 字段,则拒绝 V3 迁移:该字段仅在 V4 中定义,因此本迁移边不为它赋予历史含义。嵌套参数数据保持不变。原生 V4 对 `deferLoading: true` 的接纳规则不变。 ### 消息来源转换 diff --git a/packages/session/session-format-v3-to-v4/src/content.ts b/packages/session/session-format-v3-to-v4/src/content.ts index 0dd45b228f..51cc21e12b 100644 --- a/packages/session/session-format-v3-to-v4/src/content.ts +++ b/packages/session/session-format-v3-to-v4/src/content.ts @@ -1,17 +1,10 @@ -/** V3 extension names and fields isolated from current core vocabulary. */ +/** V3 content tags and request-tool fields admitted before V4 interpretation. */ -import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' +import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' import { mapEventMessages } from './sources.ts' const V3_BLOCK_TYPES = new Set(['text', 'reasoning', 'image', 'file', 'tool-call', 'tool-result']) -const TOOL_SCHEMA_FIELDS = new Set(['name', 'description', 'parameters']) - -/** Prefix extension-owned field names without reinterpreting their values. */ -function namespaceFields(value: SessionFormatJsonObject, fields: ReadonlySet): SessionFormatJsonObject { - if (Object.keys(value).every(key => fields.has(key))) return value - return Object.fromEntries(Object.entries(value).map(([key, item]) => [fields.has(key) ? key : `plugin:${key}`, item])) -} function migrateBlock(value: SessionFormatJsonValue, subject: string): SessionFormatJsonValue { if (!isSessionFormatJsonObject(value) || typeof value['type'] !== 'string') { @@ -54,9 +47,10 @@ function migrateChunk(value: SessionFormatJsonValue | undefined, subject: string } /** - * Convert declared V3 extension content, streams, and request-tool extension fields after canonical result lifting. + * Convert declared V3 extension content and stream tags after canonical result lifting. * @param event - source event after producer attribution conversion. - * @returns the event with namespaced extension tags and fields, retaining all coordinates and values. + * @returns the event with namespaced content tags, retaining all coordinates and other fields. + * @throws {SessionFormatUnsupportedMigrationError} A V3 tool definition contains the V4-only deferLoading field. */ export function migrateV3EventContent(event: SessionFormatEvent): SessionFormatEvent { const subject = `format v3 ${event.type} at seq ${event.seq}` @@ -76,14 +70,15 @@ export function migrateV3EventContent(event: SessionFormatEvent): SessionFormatE if (message !== data['message']) data = { ...data, message } } if (event.type === 'request/header' && isSessionFormatJsonObject(data['header'])) { - const header = data['header'] - const tools = header['tools'] + const tools = data['header']['tools'] if (Array.isArray(tools)) { - const converted = (tools as readonly SessionFormatJsonValue[]).map((tool) => { - if (!isSessionFormatJsonObject(tool)) return tool - return namespaceFields(tool, TOOL_SCHEMA_FIELDS) - }) - if (converted.some((tool, index) => tool !== tools[index])) data = { ...data, header: { ...header, tools: converted } } + for (const [index, tool] of (tools as readonly SessionFormatJsonValue[]).entries()) { + if (isSessionFormatJsonObject(tool) && Object.hasOwn(tool, 'deferLoading')) { + throw new SessionFormatUnsupportedMigrationError( + `${subject}.header.tools[${index}] contains deferLoading, which is only defined in V4`, + ) + } + } } } if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && Array.isArray(data['stream'])) { diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts index ecc3b6348e..2b2b564ce1 100644 --- a/packages/session/session-format-v3-to-v4/tests/content.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts @@ -96,34 +96,35 @@ describe('V3 content conversion', () => { expect(migrateV3EventContent(unchanged)).toBe(unchanged) }) - it.each([false, true])('keeps V3 request-tool extensions opaque with deferLoading=%s', (deferLoading) => { + it('preserves V3 request-tool metadata keys and values', () => { const parameters = { type: 'object', properties: {}, examples: [wrapper], deferLoading: true } - const extras = JSON.parse('{"metadata":{"saved":true},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject - const tool = { name: 'echo', description: 'Echo', parameters, deferLoading, ...extras } + const extras = JSON.parse('{"vendor":{"saved":true},"plugin:vendor":{"saved":false},"plugin:deferLoading":false,"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject + const tool = { name: 'echo', description: 'Echo', parameters, ...extras } const row = event('request/header', { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, tools: [tool] } }) + const before = structuredClone(row) const converted = migrate(row) - expect(converted.data).toEqual({ reason: 'initial', header: { - config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters, 'plugin:deferLoading': deferLoading, 'plugin:metadata': extras['metadata'], 'plugin:__proto__': extras['__proto__'], 'plugin:constructor': extras['constructor'] }], - } }) + expect(converted).toBe(row) + expect(converted).toEqual(before) expect(() => releasedV4SessionFormatCodec.encodeEvent(converted)).not.toThrow() - const header = (converted.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject - const actual = (header['tools'] as SessionFormatJsonObject[])[0]! - expect(actual['parameters']).toBe(parameters) - expect(Object.hasOwn(actual, 'plugin:__proto__')).toBe(true) - expect(Object.getPrototypeOf(actual)).toBe(Object.prototype) - expect(tool['deferLoading']).toBe(deferLoading) + expect(Object.hasOwn(tool, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(tool)).toBe(Object.prototype) + expect(tool.parameters.deferLoading).toBe(true) + expect(tool).toHaveProperty('plugin:deferLoading', false) }) - it('prefixes tool extension keys while preserving stream-start extra keys', () => { - const extra = JSON.parse('{"deferLoading":true,"plugin:deferLoading":false,"metadata":{"saved":1},"plugin:metadata":{"saved":2},"__proto__":{"saved":3},"plugin:__proto__":{"saved":4}}') as SessionFormatJsonObject - const expected = { - 'plugin:deferLoading': true, 'plugin:plugin:deferLoading': false, - 'plugin:metadata': { saved: 1 }, 'plugin:plugin:metadata': { saved: 2 }, - 'plugin:__proto__': { saved: 3 }, 'plugin:plugin:__proto__': { saved: 4 }, - } - const tool = { name: 'echo', description: 'Echo', parameters: { metadata: extra }, ...extra } - const request = migrateV3EventContent(event('request/header', { header: { tools: [tool] } })) - expect(request.data).toEqual({ header: { tools: [{ name: 'echo', description: 'Echo', parameters: tool.parameters, ...expected }] } }) + it.each([true, false, null, 'custom'])('refuses an unsupported V3 top-level deferLoading value %j without altering the source', (deferLoading) => { + const tool = { name: 'echo', description: 'Echo', parameters: {}, deferLoading } + const row = event('request/header', { reason: 'initial', header: { + config: { provider: 'mock', model: 'mock' }, + tools: [{ name: 'first', description: 'First', parameters: {} }, tool], + } }) + const before = structuredClone(row) + expect(() => migrate(row)).toThrow(/request\/header at seq 0.*tools\[1\].*deferLoading/) + expect(row).toEqual(before) + }) + + it('preserves stream-start extra keys while converting its unknown block type', () => { + const extra = JSON.parse('{"deferLoading":true,"plugin:deferLoading":false,"metadata":{"saved":1},"plugin:metadata":{"saved":2},"__proto__":{"saved":3}}') as SessionFormatJsonObject const start = { type: 'block-start', index: 7, blockType: 'plugin:text', ...extra } const stream = migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: start }] })) expect(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { ...start, blockType: 'plugin:plugin:text' } }] }) @@ -131,10 +132,17 @@ describe('V3 content conversion', () => { expect(Object.hasOwn(extra, '__proto__')).toBe(true) }) - it('retains native V4 capability validation independently of historical extras', () => { - const header = { config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters: {}, deferLoading: false }] } - expect(() => releasedV4SessionFormatCodec.encodeEvent(event('request/header', { header }))).toThrow(/deferLoading must be true/) - expect(() => releasedV4SessionFormatCodec.encodeEvent(migrate(event('request/header', { header })))).not.toThrow() + it('retains native V4 deferred-loading declaration validation', () => { + const tool = { name: 'echo', description: 'Echo', parameters: {}, deferLoading: true } + const header = { config: { provider: 'mock', model: 'mock' }, tools: [tool] } + const row = event('request/header', { header }) + const physical = releasedV4SessionFormatCodec.encodeEvent(row) + const collector = new SessionFormatEventCollector() + releasedV4SessionFormatCodec.createDecoder({ type: 'session', version: 4, id: 'native', createdAt: 0, isSeeded: false, delegationDepth: 0 }, 'strict').decodeRow(physical, collector) + expect(collector.values).toEqual([row]) + expect(() => releasedV4SessionFormatCodec.encodeEvent(event('request/header', { + header: { ...header, tools: [{ ...tool, deferLoading: false }] }, + }))).toThrow(/deferLoading must be true/) }) it('shares V3 request tools with no extension fields and leaves malformed tools decoder-owned', () => { diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index 5ccf2bb41e..b5370766cb 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -327,25 +327,33 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres }) }) - it.each([false, true])('preserves V3 tool-definition deferLoading=%s and extension fields under plugin-prefixed keys', async (deferLoading) => { + it.each([false, true])('refuses own V3 tool-definition deferLoading=%s without publishing a successor', async (deferLoading) => { + const tool = { name: 'example', description: 'Saved tool definition.', parameters: { type: 'object' }, deferLoading } + const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'change' } } + const path = await store(3, compression, [...releasedPrefix, request]) + const original = await observe(path) + const ctx = await mount(compression) + await expectRefusal(ctx, access, path, + `format v3 request/header at seq ${releasedPrefix.length}.header.tools[0] contains deferLoading, which is only defined in V4` + + '; source v3 artifact remains unchanged (raw log: ' + path + ')') + await ctx.sessionPersistence.flush() + expect(await observe(path)).toEqual(original) + await expectOnlyGenerations([path]) + }) + + it('preserves ordinary V3 tool-definition extension fields unchanged', async () => { const parameters = { - type: 'object', properties: { deferLoading: { type: 'boolean' } }, + type: 'object', properties: { deferLoading: { type: 'boolean' } }, deferLoading: true, default: { type: 'tool-result', toolCallId: 'opaque-call', content: [{ type: 'text', text: 'schema data' }] }, } - const fields = JSON.parse('{"metadata":{"saved":"original metadata"},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject - const tool = { name: 'example', description: 'Saved tool definition.', parameters, deferLoading, ...fields } + const fields = JSON.parse('{"metadata":{"saved":"original metadata"},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"},"plugin:deferLoading":true}') as SessionFormatJsonObject + const tool = { name: 'example', description: 'Saved tool definition.', parameters, ...fields } const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'initial' } } - const targetTool = { - name: 'example', description: 'Saved tool definition.', parameters, - 'plugin:deferLoading': deferLoading, 'plugin:metadata': fields['metadata'], 'plugin:__proto__': fields['__proto__'], 'plugin:constructor': fields['constructor'], - } await expectV3Conversion([...releasedPrefix.slice(0, -1), request], (events) => { - const expected = [...nativePrefix.slice(0, -1), { - ...request, data: { ...request.data, header: { config, tools: [targetTool] } }, - }] + const expected = [...nativePrefix.slice(0, -1), request] expect(events).toEqual(expected.map((row, seq) => ({ ...row, seq, time: 1001 + seq }))) const migrated = events.find(event => event.type === 'request/header')?.data.header.tools?.[0] - expect(Object.hasOwn(migrated!, 'plugin:__proto__')).toBe(true) + expect(Object.hasOwn(migrated!, '__proto__')).toBe(true) expect(Object.getPrototypeOf(migrated!)).toBe(Object.prototype) expect(Object.hasOwn(migrated!, 'deferLoading')).toBe(false) }) From ade7035fd8f7ffc5b84c6b55e197fbe8b4133e3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:18:49 +0800 Subject: [PATCH 30/42] fix(session): preserve parents with incomplete child catalog evidence A readable V3 child may have no own descriptor, an unknown descriptor version, or multiple descriptors. Those states do not provide a unique discovery fact, but they do not make the parent or the retained child history unreadable. Rejecting the entire parent prevented otherwise valid V3 Sessions from opening. Append a missing parent catalog row only for exactly one supported own child descriptor. Keep existing parent entries and every child record when no fact is available. Retain rejection of mismatched creation times, unambiguous mode/label conflicts, malformed known fields, unreadable membership and revision drift. Cover raw and compressed read/write publication, preserved source bytes, late descriptor availability, inherited descriptors and Preview packing. Document that unavailable discovery evidence can leave a child absent from its parent catalog and does not trigger automatic backfill after the V4 successor exists. Extracts the child-evidence behavior from 7ba7e174dd1 and the Preview regression from 9c85eb60bd25, preserving the original migration authors' work while keeping message representation and extension interpretation outside this commit. Co-authored-by: Dudu-0223 --- ...leased-session-format-migrations.i18n.yaml | 4 +-- ...8-31-released-session-format-migrations.md | 2 +- ...1-released-session-format-migrations.zh.md | 2 +- ...ncomplete-child-catalog-evidence.i18n.yaml | 6 ++++ ...19-v3-incomplete-child-catalog-evidence.md | 27 ++++++++++++++ ...v3-incomplete-child-catalog-evidence.zh.md | 27 ++++++++++++++ .../webworker-packer/tests/preview.spec.ts | 22 ++++++------ .../session-format-v3-to-v4/README.i18n.yaml | 4 +-- .../session/session-format-v3-to-v4/README.md | 8 ++--- .../session-format-v3-to-v4/README.zh.md | 8 ++--- .../session-format-v3-to-v4/src/facts.ts | 7 ++-- .../session-format-v3-to-v4/src/migration.ts | 2 +- .../tests/catalog-completion.spec.ts | 17 ++++++--- .../README.i18n.yaml | 4 +-- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../tests/catalog-migration.spec.ts | 36 +++++++++++++------ 17 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index ce6cf70bb0..cce2c1f78a 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 379b4b735e1aaacb664faac57831b842de82c743 -2026-08-31-released-session-format-migrations.zh.md: 0a2790f81987deb145845295064b1ef5a3bbcba7 +2026-08-31-released-session-format-migrations.md: 754ffae3a7f4bf58d25fe2fc7174593bd01042d5 +2026-08-31-released-session-format-migrations.zh.md: 7de89ddf32da3bcbfc25df19d37d67a322028c85 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 379b4b735e..754ffae3a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -129,7 +129,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and requiring complete own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available discovery fields must agree with the parent; absent entries require a supported descriptor. Missing evidence refuses migration rather than reporting parent corruption. JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 0a2790f819..7de89ddf32 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -129,7 +129,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并要求完整的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得的发现字段必须与父目录一致;缺失记录要求受支持的 descriptor。证据不足会拒绝迁移,而不是报告父日志损坏。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml new file mode 100644 index 0000000000..7b9cc481fe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.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/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +2026-09-19-v3-incomplete-child-catalog-evidence.md: ec6bb9c0c4bed133a758b528c579285caebbdcf0 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 8382be6ed28801e2b9ab925740262b4bdcf314a8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md new file mode 100644 index 0000000000..ec6bb9c0c4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -0,0 +1,27 @@ +# Agent Note: Preserve V3 Sessions with incomplete child catalog evidence + +Status: implemented + +English | [中文](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md) + +## Problem + +A valid V3 child log can have no own descriptor, an unsupported descriptor version, or multiple descriptors. Its header still establishes its identity and parent relationship. Requiring one supported descriptor to migrate the parent makes unavailable discovery fields prevent access to otherwise readable history. + +## Decision + +The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). + +Known child creation times must still match existing parent entries. Available unambiguous mode and label fields must also agree. Invalid known descriptor fields, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. + +## Alternatives considered + +**Refuse the whole parent.** Discovery metadata is insufficient to justify making valid parent history unavailable. + +**Choose a descriptor or infer defaults.** Multiple descriptors do not establish which identity was admitted. Invented mode or label fields would become durable catalog facts without evidence. + +## Consequences + +A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. + +Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md new file mode 100644 index 0000000000..8382be6ed2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 保留子目录证据不完整的 V3 Session + +Status: implemented + +[English](2026-09-19-v3-incomplete-child-catalog-evidence.md) | 中文 + +## Problem + +有效 V3 子日志可能没有自身 descriptor、descriptor 版本不受支持,或存在多个 descriptor。其 header 仍能确定身份及父子关系。要求一个受支持的 descriptor 才迁移父日志,会让不可获得的发现字段阻止读取原本可读的历史。 + +## Decision + +V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 + +已知子创建时间仍必须与已有父目录项一致。可获得且明确的 mode 和 label 字段也必须一致。已知 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 + +## Alternatives considered + +**拒绝整个父 Session。** 发现元数据不足以成为禁止读取有效父历史的理由。 + +**选择一个 descriptor 或推断默认值。** 多个 descriptor 无法确定哪个身份已被准入。推断的 mode 或 label 会在没有证据的情况下成为持久目录事实。 + +## Consequences + +没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 + +单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/packages/experimental/webworker-packer/tests/preview.spec.ts b/packages/experimental/webworker-packer/tests/preview.spec.ts index 443b7736d0..2c5c381059 100644 --- a/packages/experimental/webworker-packer/tests/preview.spec.ts +++ b/packages/experimental/webworker-packer/tests/preview.spec.ts @@ -65,7 +65,7 @@ describe('Node preparation of Preview Session data', () => { expect(packVfsOverlay(fixture.trees).files).toEqual(original.files) }) - it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('collects only direct subagent children in the same root: %s', (mode) => { + it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('adds catalog facts only for supported direct subagent children in the same root: %s', (mode) => { const fixture = source() const parent = fixture.put(3) const childDirectory = join(fixture.trees[0]!.directory, 'sessions', mode === 'other-root' ? 'other' : 'project', 'child') @@ -75,18 +75,20 @@ describe('Node preparation of Preview Session data', () => { delegationDepth: 1, parentSession: 'example', ...(mode === 'other-origin' ? {} : { origin: 'subagent' }) } const descriptor = { type: 'subagent/descriptor', seq: 0, time: 3, data: { version: 3, mode: 'one-shot', provider: 'spawn' } } - writeFileSync(join(childDirectory, sessionFormatLogFilename(version)), + const childPath = join(childDirectory, sessionFormatLogFilename(version)) + writeFileSync(childPath, JSON.stringify(child) + '\n' + (mode === 'current-child' ? JSON.stringify(descriptor) + '\n' : '')) const before = readFileSync(parent) - if (mode === 'missing-descriptor') { - expect(() => packPreviewFixture(fixture.trees)).toThrow('requires exactly one own supported subagent descriptor') - } else { - const packed = packPreviewFixture(fixture.trees) - expect(restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]).events) - .toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : []) - } + const childBefore = readFileSync(childPath) + const original = packVfsOverlay(fixture.trees) + const packed = packPreviewFixture(fixture.trees) + const restored = restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]) + expect(restored.inheritedEventCount).toBe(0) + expect(restored.events).toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1, + data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : []) + for (const [path, bytes] of Object.entries(original.files)) expect(packed.files[path], path).toEqual(bytes) expect(readFileSync(parent)).toEqual(before) + expect(readFileSync(childPath)).toEqual(childBefore) }) it('uses the highest generation and leaves an already-current image byte-identical', () => { diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 48963c4516..14e8ceb3ce 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 5a6e912de13511f63ae71b3cf90027fffebe0b10 -README.zh.md: 3c13375ae1fc9fcc21e4eee24256794e7ef595c9 +README.md: c067355a8189cf3a23d79c5d59577434b3aa1c92 +README.zh.md: 4279967f2cb196dd8de40d6689536d3f51098673 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 5a6e912de1..c067355a81 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -66,7 +66,7 @@ Every restore creates independent Stage state. Compact runs expand as iterables ## V3-to-V4 specification -This edge changes only the named representations below and appends missing catalog facts. It namespaces unknown ignorable event types and retains each admitted source event's time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies. +This edge changes only the named representations below and appends available missing catalog facts. It namespaces unknown ignorable event types and retains each admitted source event's time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies. ### Header and physical framing @@ -145,16 +145,16 @@ There is no recursive source search. Captured request text, assistant replay sta | One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. | | One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. | | Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. | -| More than one own descriptor | Refuse, including when the parent already has an entry. | +| More than one own descriptor | Preserve the child; no unambiguous discovery fact is available for backfill. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and any available supported mode/label. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Refuse the migration without publishing a successor. | +| Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. | Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. -Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Missing required evidence, unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication. +Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Incomplete descriptor evidence only prevents that child’s backfill. Unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication. ### Sequence references and inheritance diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 3c13375ae1..4279967f2c 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -66,7 +66,7 @@ const artifact = restore.finish() ## V3 到 V4 规范 -本边只改变下列明确命名的表示,并追加缺失的目录事实。未知可忽略事件的类型获得命名空间;每个获准源事件的 time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。 +本边只改变下列明确命名的表示,并追加证据完整的缺失目录事实。未知可忽略事件的类型获得命名空间;每个获准源事件的 time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。 ### Header 与物理分帧 @@ -145,16 +145,16 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 | 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 | | 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 | | 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 | -| 多个自身 descriptor | 拒绝,包括父目录中已经存在条目的情况。 | +| 多个自身 descriptor | 保留子 Session;没有可用于补填的明确发现事实。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间以及可用且受支持的 mode/label 一致。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 拒绝迁移,不发布后继代际。 | +| 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 | Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 -存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。缺失必要证据、无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。 +存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。不完整的 descriptor 证据只阻止补填对应子 Session 的目录项。无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。 ### 序号引用与继承 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index 029eae5794..a08f2bda20 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -38,17 +38,14 @@ export function childCatalogSource(value: SessionFormatJsonValue): SessionFormat /** * Interpret only the discovery fields of known historical descriptors. * @param source - validated child evidence. - * @returns a catalog fact, or undefined when an existing entry needs no unavailable descriptor. + * @returns a catalog fact, or undefined without exactly one supported own descriptor. */ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormatJsonObject | undefined { const id = source['childId'] as string const descriptor = source['descriptor'] const count = source['descriptorCount'] as number const known = isSessionFormatJsonObject(descriptor) && [1, 2, 3].includes(descriptor['version'] as number) - if (count > 1) { - throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} requires exactly one own supported subagent descriptor to complete its parent catalog`) - } - if (count === 0 || !known) return undefined + if (count !== 1 || !known) return undefined if (typeof descriptor['provider'] !== 'string') { throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor provider`) } diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index b0898381de..3b1c1de60a 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -122,7 +122,7 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } continue } - if (fact === undefined) throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} requires exactly one own supported subagent descriptor to complete its parent catalog`) + if (fact === undefined) continue existingCatalogs.set(id, fact) context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact }) } diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index a99c5d3400..bac89800bf 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -88,8 +88,8 @@ describe('V3 parent catalog completion', () => { ], } expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child) - expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })])).toThrow('exactly one') - expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })])).toThrow('exactly one') + expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toEqual([]) }) it('orders tied and differently dated children independently of directory enumeration', () => { const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }] @@ -114,11 +114,11 @@ describe('V3 parent catalog completion', () => { expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) - it('requires supported descriptors and complete discovery fields', () => { + it('skips unavailable descriptors while requiring complete discovery fields for new facts', () => { const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] } expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent') for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) { - expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })])).toThrow('supported subagent descriptor') + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toEqual([]) } for (const value of [null, { ...child, version: 1 }, { ...child, childId: null }, { ...child, label: null }, { ...child, childCreatedAt: -1 }]) { @@ -133,6 +133,15 @@ describe('V3 parent catalog completion', () => { expect(migrate(source, [{ childId: 'child', childCreatedAt: 2, descriptorCount: descriptor === null ? 0 : 1, descriptor }]).events).toEqual(source) }) + it('keeps parent facts authoritative when a child has multiple own descriptors', () => { + const data = { ...child, extension: { retained: true } } + const source = [{ type: 'subagent/catalog', seq: 0, time: 1, data }] + const evidence = { childId: 'child', childCreatedAt: 2, descriptorCount: 2, + descriptor: { version: 3, mode: 'one-shot', provider: 'spawn', label: 'ambiguous child identity' } } + expect(migrate(source, [evidence]).events).toEqual(source) + expect(() => migrate(source, [{ ...evidence, childCreatedAt: 3 }])).toThrow('conflicts') + }) + it('refuses malformed supplemental evidence and known descriptor conflicts', () => { for (const source of [null, {}, { childId: 'child', childCreatedAt: 2, descriptor: null, descriptorCount: -1 }]) { expect(() => migrate([], [source])).toThrow() diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 80fa6c7755..62795c5287 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 1570e93abeb0e515fcaa474c566fa3041922f08c -README.zh.md: 032e50c7e25f2a1f1def21c8fb0850e32f6109f3 +README.md: 5907a09a109f2703f67817a20608499e38840dfd +README.zh.md: 0720388f29934579efa42a9a9b85e74f80651176 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 1570e93abe..5907a09a10 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Unavailable migration facts report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid known descriptor fields still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 032e50c7e2..0720388f29 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。迁移事实不足会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。已知 descriptor 字段无效仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index 96cf6cae50..af86cdbfe6 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -200,13 +200,23 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await writer.close() }) - it('classifies missing discovery information as unsupported migration', async () => { + it.each(['missing', 'unknown', 'multiple'] as const)('publishes the parent without invented %s child discovery information', async (kind) => { const f = await fixture() - await f.write('child', [], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + const events = kind === 'missing' ? [] : kind === 'unknown' + ? [{ ...f.descriptor, data: { version: 99, extension: { retained: true } } }] + : [f.descriptor, { ...f.descriptor, seq: 1, data: { ...f.descriptor.data, label: 'second descriptor' } }] + const childPath = await f.write('child', events, true) + const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) + const original = await Promise.all([readFile(parentPath), readFile(childPath)]) + expect(await f.read()).toEqual([]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() } + expect(await f.read()).toEqual([]) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') + try { expect((await child.read()).events).toEqual(events) } finally { await child.close() } + expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) + expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock').sort()) + .toEqual(compression === 'none' ? ['session.v3.jsonl', 'session.v4.jsonl'] : ['session.v3.jsonl.zstd', 'session.v4.jsonl.zstd']) }) it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => { @@ -268,12 +278,18 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await writer.close() }) - it('refuses an unavailable descriptor without writing a successor', async () => { + it('recollects newly available discovery fields before publishing a prepared parent', async () => { const f = await fixture() - await f.write('child', [], true) - await expect(f.read()).rejects.toThrow('supported subagent descriptor') + const childPath = await f.write('child', [], true) + expect(await f.read()).toEqual([]) + await f.write('child', [f.descriptor], true) + await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) - expect(await readdir(dirname(parentPath))).toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')) + .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', label: 'old child' } }]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() }) it('reads current children without recursive migration and preserves current parent fast reads', async () => { From e838401a9bf9599b7c94751e8a50daca60a45e7b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:16:24 +0800 Subject: [PATCH 31/42] docs(session): state incomplete catalog evidence limits The child-catalog README did not record the lasting missing-entry consequence under Known Limitations, and its validation wording implied that duplicate or unsupported descriptors had their discovery fields checked. The converter only checks those fields when exactly one supported own descriptor is available. Specify that distinction, retain creation-time checks for existing entries, and state that current V4 reads do not rescan children to repair an omitted entry. Document the first-descriptor composition reader and last-descriptor identity projection accurately: multiple own records violate the establishing provider's exactly-once rule, so the backfill policy does not choose between those consumer policies. This corrects the rationale without changing migration admission. Update the paired English/Chinese package docs and decision note together. Validation: doc-sync 41/41, full lint, and owner tests passed; production source, frozen generations, persistence schema, and recorded fixtures are unchanged. --- ...026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml | 4 ++-- .../2026-09-19-v3-incomplete-child-catalog-evidence.md | 6 ++++-- .../2026-09-19-v3-incomplete-child-catalog-evidence.zh.md | 6 ++++-- packages/session/session-format-v3-to-v4/README.i18n.yaml | 4 ++-- packages/session/session-format-v3-to-v4/README.md | 5 +++-- packages/session/session-format-v3-to-v4/README.zh.md | 5 +++-- packages/session/session-persistence-jsonl/README.i18n.yaml | 4 ++-- packages/session/session-persistence-jsonl/README.md | 2 +- packages/session/session-persistence-jsonl/README.zh.md | 2 +- 9 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 7b9cc481fe..4ede5bd457 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.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/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: ec6bb9c0c4bed133a758b528c579285caebbdcf0 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 8382be6ed28801e2b9ab925740262b4bdcf314a8 +2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index ec6bb9c0c4..22ca915024 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -12,13 +12,15 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -Known child creation times must still match existing parent entries. Available unambiguous mode and label fields must also agree. Invalid known descriptor fields, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. ## Alternatives considered **Refuse the whole parent.** Discovery metadata is insufficient to justify making valid parent history unavailable. -**Choose a descriptor or infer defaults.** Multiple descriptors do not establish which identity was admitted. Invented mode or label fields would become durable catalog facts without evidence. +**Choose the first or last descriptor.** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) takes the first under the establishing provider’s exactly-once rule; the [identity projection](../../../../packages/subagent/subagent/src/projection.ts) takes the last to override inherited identities. Multiple own records violate that rule. Backfill requires one own record rather than choosing between these consumer policies. + +**Infer missing fields.** Invented mode or label fields would become durable catalog facts without evidence. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index 8382be6ed2..c11e6de2c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -12,13 +12,15 @@ Status: implemented V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 -已知子创建时间仍必须与已有父目录项一致。可获得且明确的 mode 和 label 字段也必须一致。已知 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 ## Alternatives considered **拒绝整个父 Session。** 发现元数据不足以成为禁止读取有效父历史的理由。 -**选择一个 descriptor 或推断默认值。** 多个 descriptor 无法确定哪个身份已被准入。推断的 mode 或 label 会在没有证据的情况下成为持久目录事实。 +**选择首个或末个 descriptor。** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) 依据建立提供方恰好写入一次的规则取首个;[身份投影](../../../../packages/subagent/subagent/src/projection.ts)取末个以覆盖继承身份。多个自身记录违反该规则。补填要求一个自身记录,而不在这两种消费者策略之间做选择。 + +**推断缺失字段。** 推断的 mode 或 label 会在没有证据的情况下成为持久目录事实。 ## Consequences diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 14e8ceb3ce..1a211e967a 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: c067355a8189cf3a23d79c5d59577434b3aa1c92 -README.zh.md: 4279967f2cb196dd8de40d6689536d3f51098673 +README.md: 5a251c8430e358f16216a0fdd09497bd29b463d8 +README.zh.md: c706e7f99d097e1ca6c1b4db2653ed9b7a6f1955 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index c067355a81..5a251c8430 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -145,8 +145,8 @@ There is no recursive source search. Captured request text, assistant replay sta | One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. | | One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. | | Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. | -| More than one own descriptor | Preserve the child; no unambiguous discovery fact is available for backfill. | -| Existing own parent entry | Retain it and its extensions; require matching child creation time and any available supported mode/label. | +| More than one own descriptor | Retain an existing parent entry without mode/label comparison; do not create a missing entry. | +| Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | | Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. | @@ -334,6 +334,7 @@ The edge preserves the recorded request prefix. Provider cache availability and - **Nested historical tool results** — migration refuses results containing another tool-result wrapper because flattening loses its call identity and error status. The original generation remains intact and no V4 successor is published; those histories need a preserving conversion before they can resume. - **Historical tool-result extensions** — outer message JSON properties remain own data properties, including `__proto__` and `constructor`. Unknown wrapper fields have no defined V4 destination and refuse migration. Existing outer `toolCallId` or `isError` fields must agree with the lifted result; conflicts refuse without publishing a successor. - **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain. +- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission. - **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 4279967f2c..c706e7f99d 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -145,8 +145,8 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 | 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 | | 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 | | 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 | -| 多个自身 descriptor | 保留子 Session;没有可用于补填的明确发现事实。 | -| 已有自身父目录项 | 保留条目及其扩展;要求子创建时间以及可用且受支持的 mode/label 一致。 | +| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;不创建缺失项。 | +| 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | | 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 | @@ -334,6 +334,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 - **历史嵌套工具结果**——迁移会拒绝包含另一层 tool-result 包装的结果,因为展平会丢失其调用身份和错误状态。原始代际保持不变,也不会发布 V4 后继;这些历史需要能保留信息的转换才能继续运行。 - **历史工具结果扩展**——外层消息的 JSON 属性仍保留为自有数据属性,包括 `__proto__` 和 `constructor`。未知 wrapper 字段没有已定义的 V4 存放位置,因此拒绝迁移。已有外层 `toolCallId` 或 `isError` 字段必须与提升后的结果一致;冲突时拒绝且不发布 successor。 - **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。 +- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。 - **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。 diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 62795c5287..1589a1d5c7 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 5907a09a109f2703f67817a20608499e38840dfd -README.zh.md: 0720388f29934579efa42a9a9b85e74f80651176 +README.md: 96c333d9f845943ff44c8d5d539c40d07b9dc0f6 +README.zh.md: 71126dd909c92aabdee5c07d23505f708dc5e038 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 5907a09a10..96c333d9f8 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid known descriptor fields still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid fields in exactly one supported own descriptor still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 0720388f29..71126dd909 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。已知 descriptor 字段无效仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。恰好一个受支持的自身 descriptor 字段无效时,仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 From 869367d5d07dca82f110c7e0b9dcbd2f1d227ec0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:16:36 +0800 Subject: [PATCH 32/42] test(session): continue catalog backfill after unavailable evidence Isolated complete/incomplete-child tests did not distinguish skipping one child from prematurely stopping the entire candidate loop. A stop could therefore silently omit a later child's fully supported catalog fact. Pair a complete child with an older unavailable child so deterministic ordering processes the unavailable evidence first. Cover missing, unsupported-version, and multiple-descriptor evidence, requiring exactly the complete child's fact with the correct sequence and timestamp. A temporary continue-to-break mutation failed all three new cases. Restoring the implementation passed the focused 19 cases and the complete selected owner run: 428 passed, one compression-specific skip, with 100% coverage on facts.ts and migration.ts. This commit changes tests only. --- .../tests/catalog-completion.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index bac89800bf..7b1ad7e3c0 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -142,6 +142,16 @@ describe('V3 parent catalog completion', () => { expect(() => migrate(source, [{ ...evidence, childCreatedAt: 3 }])).toThrow('conflicts') }) + it.each(['missing', 'unknown', 'multiple'] as const)('continues catalog backfill after %s child evidence', (kind) => { + const descriptor: SessionFormatJsonValue = kind === 'missing' ? null : kind === 'unknown' ? { version: 99 } + : { version: 3, mode: 'one-shot', provider: 'spawn' } + const unavailable = { childId: 'unavailable', childCreatedAt: 1, + descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } + expect(migrate([], [child, unavailable]).events).toEqual([ + { type: 'subagent/catalog', seq: 0, time: 1, data: child }, + ]) + }) + it('refuses malformed supplemental evidence and known descriptor conflicts', () => { for (const source of [null, {}, { childId: 'child', childCreatedAt: 2, descriptor: null, descriptorCount: -1 }]) { expect(() => migrate([], [source])).toThrow() From 0c8180c9b6a626eac471ccae7cca337ecdbf00bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:58:56 +0800 Subject: [PATCH 33/42] fix(session): preserve V3 result extensions in flat tool messages The ordinary V3 writer records a user-role message containing one result wrapper. V4 writes the same tool output directly in a tool-role message. Keep that native V4 representation instead of introducing a nested message content type for hypothetical historical shapes. Preserve extra historical fields when removing the wrapper. Prefix outer message fields with plugin:message: and result fields with plugin:result: so equal names retain their original ownership and cannot overwrite the interpreted call ID or error flag. Include the complete old name, even when it is already prefixed, and preserve every JSON value and own key. Only the validated wrapper supplies the interpreted result fields. Leave unimplemented nested-result conversion as an explicit refusal that preserves its source. Describe this as the converter's current limit, not an impossibility or a reason to extend the established V4 format. Cover owner/name collisions, opaque special keys, absent error flags, unchanged sources and strict native reopening through plain and compressed read/write persistence. No current message type or schema is added. --- ...5-first-class-tool-role-messages.i18n.yaml | 4 +- ...26-09-15-first-class-tool-role-messages.md | 2 +- ...09-15-first-class-tool-role-messages.zh.md | 2 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 10 ++-- .../session-format-v3-to-v4/README.zh.md | 10 ++-- .../session-format-v3-to-v4/src/tool-role.ts | 41 +++++++-------- .../tests/tool-role.spec.ts | 49 ++++++++++++----- .../tests/migration-refusal.spec.ts | 52 +++++++++++-------- 9 files changed, 99 insertions(+), 75 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml index e1c74c8525..413f01d6af 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.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/architecture/2026-09-15-first-class-tool-role-messages.md -2026-09-15-first-class-tool-role-messages.md: c16bc44116b1b2c20c988db4b6977ee31bea4ab4 -2026-09-15-first-class-tool-role-messages.zh.md: f9eb581f6c080655145b3c9ab1c55d052fe871b8 +2026-09-15-first-class-tool-role-messages.md: 09f73c6d9b9c0196b3b09b5483d9e833cbc08837 +2026-09-15-first-class-tool-role-messages.zh.md: 549838faf6855f4d3206c5bb05964f2f9be435f2 diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md index c16bc44116..09f73c6d9b 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md @@ -12,7 +12,7 @@ The released Session format stores tool results inside a user-role `tool-result` Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge. -Outer message JSON extensions retain their own properties during lifting, including `__proto__` and `constructor`. An existing `toolCallId` must match the source call; an existing boolean `isError` must match the wrapper's error state, with omission meaning non-error. Unknown wrapper fields and conflicting outer fields refuse migration because the new representation provides no safe interpretation for them. Rebuilding only known message fields would silently delete admitted V3 data; copying wrapper extensions onto the message would invent their scope. +Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules. The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md index f9eb581f6c..549838faf6 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md @@ -12,7 +12,7 @@ Status: implemented Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。 -外层消息的 JSON 扩展在提升时保留为自有属性,包括 `__proto__` 和 `constructor`。已有 `toolCallId` 必须与 source call 一致;已有布尔 `isError` 必须与 wrapper 的错误状态一致,省略表示非错误。未知 wrapper 字段和冲突的外层字段会拒绝迁移,因为新表示没有安全的解释方式。只重建已知消息字段会静默删除 V3 已接受的数据;将 wrapper 扩展复制到消息上则会凭空改变其作用范围。 +移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。 对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。 diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 1a211e967a..892da2ad10 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 5a251c8430e358f16216a0fdd09497bd29b463d8 -README.zh.md: c706e7f99d097e1ca6c1b4db2653ed9b7a6f1955 +README.md: 2c2d3b4342d68cec5e9a9f94f969a380bbbf15f2 +README.zh.md: a92ae34a73f134253880fec3f931815e067bc344 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 5a251c8430..2c2d3b4342 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -91,11 +91,11 @@ No preset id, PTC dispatch event tag, file attachment, or physical filename is r | `data.message.content[0].content` | Direct `data.message.content`, including empty content | | `data.message.content[0].isError` | Optional `data.message.isError` | | Wrapper `type: 'tool-result'` | Removed with the wrapper | -| Message `id`, `source`, outer extensions, event fields | Retained; no message or call id is minted | +| Message `id`, `source`, event fields | Retained; no message or call id is minted | -The only admitted wrapper keys are `type`, `toolCallId`, `content`, and `isError`. An unknown wrapper key has no defined target and refuses migration. Nested `tool-result` blocks inside its result content are refused because flattening cannot preserve their independent call identity and error status. Existing outer `toolCallId` must match; existing outer `isError` must be boolean and equal the wrapper's value, treating omission as false for this conflict check. When the wrapper omits `isError`, an existing consistent outer value remains present. Outer JSON properties such as `__proto__` and `constructor` remain own data properties. +The wrapper alone supplies the interpreted call id, content, and optional error flag. Other wrapper fields become `plugin:result:`; outer message fields other than `id`, `role`, `source`, and `content` become `plugin:message:`. Complete original names remain in the suffix, including existing prefixes. Distinct owners and colliding names retain separate values; own `__proto__` and `constructor` data stays intact. No metadata container or new content type is added. -A malformed canonical wrapper raises a format error; an unmapped wrapper extension, nested result, or conflicting target field raises an unsupported-migration error. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany `message.isError: true`. +A malformed canonical wrapper raises a format error. Nested results are unsupported by this converter and refuse without publishing a successor. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany the wrapper's `isError: true`. Converter support may expand later while preserving the established native V4 representation. ### Extension data @@ -331,8 +331,8 @@ The edge preserves the recorded request prefix. Provider cache availability and - **Historical converter coverage** — Unsupported source forms may fail without publishing a successor or changing the source. First-party recordings do not enumerate third-party extensions. Later converter fixes may add support after V4 publication if their V4 output remains compatible. Interpreting extra stream-start fields and preparing future delivery generations require a concrete format change. - **Accepted V4 transition** — the [checkpoint](../../../docs/session-format-status.md#finalization-record) protects the accepted history. Backward-compatible additions can remain V4 through new acknowledgements; breaking changes require a successor. Already-written V4 files do not rerun this incoming edge, and historical inputs remain intact. - **V5 prerequisite readers** — V4 child evidence currently goes through the installed catalog. A future writer must bind fixed-generation V4 prerequisite reading before changing that catalog. The exported V4 restorer supplies generation-owned checks; full common message admission additionally uses installed Session validation. -- **Nested historical tool results** — migration refuses results containing another tool-result wrapper because flattening loses its call identity and error status. The original generation remains intact and no V4 successor is published; those histories need a preserving conversion before they can resume. -- **Historical tool-result extensions** — outer message JSON properties remain own data properties, including `__proto__` and `constructor`. Unknown wrapper fields have no defined V4 destination and refuse migration. Existing outer `toolCallId` or `isError` fields must agree with the lifted result; conflicts refuse without publishing a successor. +- **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction. +- **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them. - **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain. - **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission. - **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index c706e7f99d..a92ae34a73 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -91,11 +91,11 @@ const artifact = restore.finish() | `data.message.content[0].content` | 直接作为 `data.message.content`,包括空内容 | | `data.message.content[0].isError` | 可选的 `data.message.isError` | | Wrapper 的 `type: 'tool-result'` | 随 wrapper 移除 | -| 消息 `id`、`source`、外层扩展和事件字段 | 保留;不生成新的消息或调用 id | +| 消息 `id`、`source` 和事件字段 | 保留;不生成新的消息或调用 id | -Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapper 字段没有定义好的目标,因此拒绝迁移。结果内容中嵌套的 `tool-result` block 也被拒绝,因为展平无法保留它们独立的调用身份和错误状态。已有外层 `toolCallId` 必须匹配;已有外层 `isError` 必须为布尔值,并与 wrapper 值一致,此冲突检查把省略视为 false。Wrapper 省略 `isError` 时,一致的已有外层值仍保留。`__proto__` 和 `constructor` 等外层 JSON 属性仍是自有数据属性。 +只有 wrapper 提供具有解释语义的调用 id、content 和可选错误标志。Wrapper 的其他字段变为 `plugin:result:<原字段名>`;外层消息除 `id`、`role`、`source` 和 `content` 外的字段变为 `plugin:message:<原字段名>`。后缀保留完整原名称,包括已有前缀。不同 owner 与重名字段分别保留值;`__proto__` 和 `constructor` 的自有数据保持完整。不添加 metadata 容器或新的内容类型。 -格式错误的 canonical wrapper 产生格式错误;没有映射的 wrapper 扩展、嵌套结果或冲突目标字段产生不支持迁移错误。转换不修复矛盾的 `data.error`;原生目标校验要求它与 `message.isError: true` 同时出现。 +格式错误的 canonical wrapper 引发格式错误。当前转换器不支持嵌套结果,遇到时拒绝且不发布 successor。转换不会修复矛盾的 `data.error`;原生目标校验要求它与 wrapper 的 `isError: true` 同时出现。后续可以扩展转换器支持范围,同时保持既定的原生 V4 表示。 ### 扩展数据 @@ -331,8 +331,8 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 - **历史转换器覆盖范围** — 未支持的源表示可能拒绝迁移,不发布后继文件,也不修改源文件。一方录制不等于第三方扩展全集。V4 发布后,只要输出仍兼容 V4,后续转换器修复就可以增加支持。解释流起始块的额外字段或处理未来投递代际,应以具体格式变更为依据。 - **已接受 V4 转换**——[检查点](../../../docs/session-format-status.zh.md#finalization-record)保护已接受历史。向后兼容的新增可以通过新的确认记录保留 V4;破坏性变更要求后继版本。已写入的 V4 文件不会重跑此入边,历史输入保持不变。 - **V5 前置读取器**——V4 子日志证据目前经过已安装目录。后续写入器在改变该目录前,须绑定固定代际的 V4 前置读取。导出的 V4 恢复器提供代际自有检查;完整的通用消息接纳还使用已安装的 Session 校验。 -- **历史嵌套工具结果**——迁移会拒绝包含另一层 tool-result 包装的结果,因为展平会丢失其调用身份和错误状态。原始代际保持不变,也不会发布 V4 后继;这些历史需要能保留信息的转换才能继续运行。 -- **历史工具结果扩展**——外层消息的 JSON 属性仍保留为自有数据属性,包括 `__proto__` 和 `constructor`。未知 wrapper 字段没有已定义的 V4 存放位置,因此拒绝迁移。已有外层 `toolCallId` 或 `isError` 字段必须与提升后的结果一致;冲突时拒绝且不发布 successor。 +- **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。 +- **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。 - **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。 - **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。 - **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。 diff --git a/packages/session/session-format-v3-to-v4/src/tool-role.ts b/packages/session/session-format-v3-to-v4/src/tool-role.ts index dc10537ac3..1eddcde480 100644 --- a/packages/session/session-format-v3-to-v4/src/tool-role.ts +++ b/packages/session/session-format-v3-to-v4/src/tool-role.ts @@ -4,12 +4,21 @@ import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFo import type { SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' const WRAPPER_FIELDS = new Set(['type', 'toolCallId', 'content', 'isError']) +const MESSAGE_FIELDS = new Set(['id', 'role', 'source', 'content']) + +/** Preserve unknown fields without merging their original message and result owners. */ +function extensionFields( + value: Record, fields: ReadonlySet, owner: 'message' | 'result', +): Record { + return Object.fromEntries(Object.entries(value).filter(([key]) => !fields.has(key)) + .map(([key, field]) => [`plugin:${owner}:${key}`, field])) +} function resultContent(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue[] { if (!Array.isArray(value)) throw new SessionFormatError(`${subject} tool-result content must be an array`) if (value.some(block => isSessionFormatJsonObject(block) && block['type'] === 'tool-result')) { throw new SessionFormatUnsupportedMigrationError( - `${subject} contains a nested tool-result; migration cannot preserve its call identity and error status`, + `${subject} contains a nested tool-result unsupported by this converter`, ) } return value as SessionFormatJsonValue[] @@ -21,8 +30,7 @@ function resultContent(value: SessionFormatJsonValue | undefined, subject: strin * rejected instead of being silently truncated. * @param event - released wrapper tool/result event. * @returns the same event with a first-class tool-role message. - * @throws {SessionFormatUnsupportedMigrationError} when nested results, wrapper - * extensions, or conflicting target fields cannot be preserved. + * @throws {SessionFormatUnsupportedMigrationError} when the result contains another result. */ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent { if (event.type !== 'tool/result' || !isSessionFormatJsonObject(event.data)) return event @@ -32,9 +40,10 @@ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent { const source = message['source'] const callId = isSessionFormatJsonObject(source) ? source['callId'] : undefined const content = message['content'] - const wrapper = Array.isArray(content) && content.length === 1 && isSessionFormatJsonObject(content[0]) - ? content[0] + const block = Array.isArray(content) && content.length === 1 + ? (content as readonly SessionFormatJsonValue[])[0] : undefined + const wrapper = isSessionFormatJsonObject(block) ? block : undefined const id = message['id'] if (typeof id !== 'string' || id.length === 0 || !isSessionFormatJsonObject(source) || source['kind'] !== 'tool' @@ -47,30 +56,18 @@ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent { if (isError !== undefined && typeof isError !== 'boolean') { throw new SessionFormatError(`format v3 ${event.type} at seq ${event.seq} tool-result isError must be boolean`) } - const unmapped = Object.keys(wrapper).find(field => !WRAPPER_FIELDS.has(field)) - if (unmapped !== undefined) { - throw new SessionFormatUnsupportedMigrationError( - `format v3 ${event.type} at seq ${event.seq} has unmapped tool-result field ${JSON.stringify(unmapped)}`, - ) - } - if (Object.hasOwn(message, 'toolCallId') && message['toolCallId'] !== callId) { - throw new SessionFormatUnsupportedMigrationError(`format v3 ${event.type} at seq ${event.seq} has conflicting outer toolCallId`) - } - if (Object.hasOwn(message, 'isError') && (typeof message['isError'] !== 'boolean' || message['isError'] !== (isError ?? false))) { - throw new SessionFormatUnsupportedMigrationError(`format v3 ${event.type} at seq ${event.seq} has conflicting outer isError`) - } - const targetIsError = isError ?? message['isError'] // Recorded replay compares serialized messages with createToolResultMessage's field order. const targetMessage: Record = { role: 'tool', source, toolCallId: callId, - content: resultContent(wrapper['content'] as SessionFormatJsonValue | undefined, `format v3 ${event.type} at seq ${event.seq}`), - ...(targetIsError === undefined ? {} : { isError: targetIsError }), + content: resultContent(wrapper['content'], `format v3 ${event.type} at seq ${event.seq}`), + ...(isError === undefined ? {} : { isError }), id, + ...extensionFields(message, MESSAGE_FIELDS, 'message'), + ...extensionFields(wrapper, WRAPPER_FIELDS, 'result'), } - const extensions = Object.fromEntries(Object.entries(message).filter(([key]) => !Object.hasOwn(targetMessage, key))) - return { ...event, data: { ...data, message: { ...targetMessage, ...extensions } } } + return { ...event, data: { ...data, message: targetMessage } } } /** diff --git a/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts b/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts index 63cc58b346..9b3b14cb41 100644 --- a/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts @@ -50,10 +50,10 @@ describe('V4 tool-role view and lift', () => { expect(JSON.stringify(target)).toBe(JSON.stringify({ ...current, id: 'tool-result-call-1' })) }) - it.each([undefined, false, true])('preserves own outer message metadata and matching target fields with isError=%s', (isError) => { + it.each([undefined, false, true])('preserves outer fields without activating their target names with isError=%s', (isError) => { const metadata = JSON.parse('{"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"},"extension":{"saved":true}}') as SessionFormatJsonObject const content = [{ type: 'text', text: 'result' }] - const message = { + const message: SessionFormatJsonObject = { ...metadata, ...wrapperMessage(), toolCallId: 'call-1', ...(isError === undefined ? {} : { isError }), content: [{ type: 'tool-result', toolCallId: 'call-1', content, ...(isError === undefined ? {} : { isError }) }], } @@ -61,9 +61,14 @@ describe('V4 tool-role view and lift', () => { const before = structuredClone(row) const lifted = liftToolResult(row) const target = (lifted.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject - expect(target).toEqual({ ...message, role: 'tool', content }) - expect(Object.hasOwn(target, '__proto__')).toBe(true) - expect(Object.hasOwn(target, 'constructor')).toBe(true) + expect(target).toEqual({ id: message['id'], role: 'tool', source: message['source'], toolCallId: 'call-1', content, + ...(isError === undefined ? {} : { isError, 'plugin:message:isError': isError }), + 'plugin:message:toolCallId': 'call-1', + 'plugin:message:__proto__': metadata['__proto__'], 'plugin:message:constructor': metadata['constructor'], + 'plugin:message:extension': metadata['extension'], + }) + expect(Object.hasOwn(target, 'plugin:message:__proto__')).toBe(true) + expect(Object.hasOwn(target, 'plugin:message:constructor')).toBe(true) expect(Object.getPrototypeOf(target)).toBe(Object.prototype) expect(row).toEqual(before) expect(JSON.parse(JSON.stringify(releasedV4SessionFormatCodec.encodeEvent(lifted)))).toEqual(lifted) @@ -74,35 +79,51 @@ describe('V4 tool-role view and lift', () => { ['isError', true, false], ['isError', true, undefined], ['isError', 'false', false], - ] as const)('refuses an outer %s field whose meaning conflicts with the lifted value', (field, value, isError) => { + ] as const)('keeps outer %s data separate from the interpreted result', (field, value, isError) => { const row = event(0, 'tool/result', { turn: 1, step: 1, message: { ...wrapperMessage(), [field]: value, content: [{ type: 'tool-result', toolCallId: 'call-1', content: [], ...(isError === undefined ? {} : { isError }) }], } }) const before = structuredClone(row) - expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError) - expect(() => liftToolResult(row)).toThrow(`conflicting outer ${field}`) + const target = (liftToolResult(row).data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject + expect(target[`plugin:message:${field}`]).toEqual(value) + expect(target['toolCallId']).toBe('call-1') + expect(target['isError']).toBe(isError) expect(row).toEqual(before) }) - it('preserves an outer false flag when the wrapper omits its equivalent non-error flag', () => { + it('preserves an outer flag without adding an absent result flag', () => { const row = event(0, 'tool/result', { turn: 1, step: 1, message: { ...wrapperMessage(), isError: false, content: [{ type: 'tool-result', toolCallId: 'call-1', content: [] }], } }) - expect(liftToolResult(row).data).toMatchObject({ message: { role: 'tool', isError: false } }) + expect(liftToolResult(row).data).toMatchObject({ message: { role: 'tool', 'plugin:message:isError': false } }) + expect((liftToolResult(row).data as SessionFormatJsonObject)['message']).not.toHaveProperty('isError') }) - it.each(['extension', '__proto__', 'constructor'])('refuses unmapped wrapper field %s without losing it', (field) => { + it.each(['extension', '__proto__', 'constructor'])('preserves result-owned %s without adding a core field', (field) => { const wrapper = Object.fromEntries([ ['type', 'tool-result'], ['toolCallId', 'call-1'], ['content', []], [field, { saved: true }], ]) as SessionFormatJsonObject const row = event(0, 'tool/result', { turn: 1, step: 1, message: { ...wrapperMessage(), content: [wrapper] } }) const before = structuredClone(row) - expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError) - expect(() => liftToolResult(row)).toThrow('unmapped tool-result field') + const target = (liftToolResult(row).data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject + expect(target[`plugin:result:${field}`]).toEqual({ saved: true }) + expect(Object.hasOwn(target, `plugin:result:${field}`)).toBe(true) + expect(Object.getPrototypeOf(target)).toBe(Object.prototype) expect(row).toEqual(before) }) + it('keeps repeated owner names and already-prefixed field names distinct', () => { + const row = event(0, 'tool/result', { turn: 1, step: 1, message: { + ...wrapperMessage(), note: 'outer', 'plugin:result:note': 'outer prefixed', + content: [{ type: 'tool-result', toolCallId: 'call-1', content: [], note: 'inner', 'plugin:message:note': 'inner prefixed' }], + } }) + expect(liftToolResult(row).data).toMatchObject({ message: { + 'plugin:message:note': 'outer', 'plugin:message:plugin:result:note': 'outer prefixed', + 'plugin:result:note': 'inner', 'plugin:result:plugin:message:note': 'inner prefixed', + } }) + }) + it('lifts a released wrapper into the native tool-role message', () => { const wrapperRow = { ...event(0, 'tool/result', { turn: 1, step: 1, message: wrapperMessage() }), surfaceOp: 'append' } as SessionFormatEvent const lifted = liftToolResult(wrapperRow) @@ -192,7 +213,7 @@ describe('V4 tool-role view and lift', () => { }) const before = structuredClone(row) expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError) - expect(() => liftToolResult(row)).toThrow('cannot preserve its call identity and error status') + expect(() => liftToolResult(row)).toThrow('unsupported by this converter') expect(row).toEqual(before) }) diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index b5370766cb..36ecf12c72 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -419,7 +419,7 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres await expectOnlyGenerations([path]) }) - it('preserves a V3 nested tool result when its identity and error status cannot migrate', async () => { + it('leaves the V3 source unchanged when nested results are unsupported', async () => { const rows = releasedToolRows({}, { content: [ { type: 'tool-result', toolCallId: 'nested-call', isError: true, content: [{ type: 'text', text: 'inner failure' }] }, ] }) @@ -427,36 +427,39 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres const original = await observe(path) const ctx = await mount(compression) await expectRefusal(ctx, access, path, - 'format v3 tool/result at seq 7 contains a nested tool-result; migration cannot preserve its call identity and error status' - + '; source v3 artifact remains unchanged (raw log: ' + path + ')') - expect(await observe(path)).toEqual(original) - await expectOnlyGenerations([path]) - }) - - it.each(['extension', '__proto__', 'constructor'])('refuses a V3 wrapper with unmapped %s metadata without publishing', async (field) => { - const rows = releasedToolRows({}, Object.fromEntries([[field, { saved: true }]])) - const path = await store(3, compression, rows) - const original = await observe(path) - const ctx = await mount(compression) - await expectRefusal(ctx, access, path, - `format v3 tool/result at seq 7 has unmapped tool-result field ${JSON.stringify(field)}` + 'format v3 tool/result at seq 7 contains a nested tool-result unsupported by this converter' + '; source v3 artifact remains unchanged (raw log: ' + path + ')') expect(await observe(path)).toEqual(original) await expectOnlyGenerations([path]) }) it.each([ - { field: 'toolCallId', value: 'conflicting-call' }, - { field: 'isError', value: true }, - ])('refuses a conflicting outer $field before publishing', async ({ field, value }) => { - const rows = releasedToolRows({ [field]: value }) + { owner: 'result', field: 'extension', value: { saved: true } }, + { owner: 'result', field: '__proto__', value: { saved: true } }, + { owner: 'result', field: 'constructor', value: { saved: true } }, + { owner: 'message', field: 'toolCallId', value: 'conflicting-call' }, + { owner: 'message', field: 'isError', value: true }, + ] as const)('preserves $owner-owned $field through migration and native reopening', async ({ owner, field, value }) => { + const extra = Object.fromEntries([[field, value]]) + const rows = owner === 'message' ? releasedToolRows(extra) : releasedToolRows({}, extra) const path = await store(3, compression, rows) const original = await observe(path) const ctx = await mount(compression) - await expectRefusal(ctx, access, path, `format v3 tool/result at seq 7 has conflicting outer ${field}` - + '; source v3 artifact remains unchanged (raw log: ' + path + ')') + for (const mode of [access, 'read'] as const) { + const handle = await ctx.sessionPersistence.open(id, mode) + try { + const result = (await handle.read()).events.find(event => event.type === 'tool/result') + expect(result?.data.message).toMatchObject({ + role: 'tool', toolCallId: 'outer-call', isError: false, + [`plugin:${owner}:${field}`]: value, + }) + expect(Object.hasOwn(result!.data.message, `plugin:${owner}:${field}`)).toBe(true) + } finally { + await handle.close() + } + } expect(await observe(path)).toEqual(original) - await expectOnlyGenerations([path]) + await expectOnlyGenerations(access === 'read' ? [path] : [path, generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, compression)]) }) it('preserves outer V3 tool-message metadata through migration and native reopening', async () => { @@ -465,8 +468,11 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres const original = await observe(path) const ctx = await mount(compression) const expected = { - ...metadata, id: 'result', role: 'tool', source: { kind: 'tool', callId: 'outer-call' }, + id: 'result', role: 'tool', source: { kind: 'tool', callId: 'outer-call' }, toolCallId: 'outer-call', isError: false, content: [{ type: 'text', text: 'result' }], + 'plugin:message:toolCallId': 'outer-call', 'plugin:message:isError': false, + 'plugin:message:__proto__': metadata['__proto__'], 'plugin:message:constructor': metadata['constructor'], + 'plugin:message:extension': metadata['extension'], } for (const mode of [access, 'read'] as const) { const handle = await ctx.sessionPersistence.open(id, mode) @@ -474,7 +480,7 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres const restored = await handle.read() const result = restored.events.find(event => event.type === 'tool/result') expect(result?.data.message).toEqual(expected) - expect(Object.hasOwn(result!.data.message, '__proto__')).toBe(true) + expect(Object.hasOwn(result!.data.message, 'plugin:message:__proto__')).toBe(true) expect(Object.getPrototypeOf(result!.data.message)).toBe(Object.prototype) } finally { await handle.close() From 1e7686b2c9f6468adcda3da14518d054db740004 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:00:31 +0800 Subject: [PATCH 34/42] test(session): compare migration with raw native writer output The ordinary snapshot comparator prepares both inputs through the current format catalog. That can hide a writer still emitting an old tool-result wrapper: both sides become the same normalized representation. For the existing stock tool-call-turn replay, explicitly migrate its retained V3 recording and compare result message fields with the freshly harvested native log before format normalization. Check both generation headers and remove only the generated message ID from this comparison. Do not use the scenario's selected V4 successor as the migration input. Add a negative control whose header claims the current writer while its body keeps the V3 wrapper; the raw comparison must reject it. The same shipped headless profile and stock bash tool provide the positive case. Keep all recordings and their selected generations unchanged. --- snapshots/session/headless.snapshot.ts | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index d5fd9507d4..7b871e7280 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -156,6 +156,21 @@ function records(log: string): JsonObject[] { .map(line => JSON.parse(line) as JsonObject) } +/** Compare migrated expectations with native writer fields before any format-normalizing comparison. */ +function verifyToolResultWriterParity(fixture: string, actual: string): void { + expect(sessionHeaderVersion(fixture, 'retained tool-result input')).toBe(3) + expect(sessionHeaderVersion(actual, 'raw tool-result writer')).toBe(SESSION_FORMAT_VERSION) + const withoutMessageId = (message: object): JsonObject => Object.fromEntries( + Object.entries(message).filter(([key]) => key !== 'id'), + ) + const expected = parseSessionLog(fixture).flatMap(event => event.type === 'tool/result' + ? [withoutMessageId(event.data.message)] : []) + const written = records(actual).flatMap(event => event.type === 'tool/result' + ? [withoutMessageId((event.data as JsonObject).message as JsonObject)] : []) + expect(expected.length, 'recorded stock tool results').toBeGreaterThan(0) + expect(written, 'native tool-result messages match migration output').toEqual(expected) +} + function headerOf(log: string): JsonObject { return records(log)[0] ?? {} } @@ -1040,6 +1055,14 @@ describe('headless recorded-session snapshots', () => { expect(await readFile(join(snapshotsRoot, 'session-reference-spill/session.v3.jsonl'), 'utf8')).toBe(fixture) }) + it('rejects a native writer that still emits the recorded tool-result wrapper', async () => { + const fixture = await readFile(join(snapshotsRoot, 'tool-call-turn/session.v3.jsonl'), 'utf8') + const unconverted = records(fixture) + unconverted[0]!.version = SESSION_FORMAT_VERSION + expect(() => verifyToolResultWriterParity(fixture, unconverted.map(row => JSON.stringify(row)).join('\n'))) + .toThrow('native tool-result messages match migration output') + }) + for (const scenario of scenarios) { const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32' || scenario.manifest.platform === 'pwsh' && !hasPwsh @@ -1144,6 +1167,10 @@ describe('headless recorded-session snapshots', () => { }, inspect: async (cwd) => { actualLogs = await persistedSessions(cwd) + if (replaying && scenario.name === 'tool-call-turn') { + const retainedV3 = await readFile(join(scenario.dir, 'session.v3.jsonl'), 'utf8') + verifyToolResultWriterParity(retainedV3, actualLogs[0]!.content) + } if (mcpDemo !== undefined) { const log = actualLogs[0]!.content expect(log).toContain('mcp__demo__ping') From e90c55e0ab901add15b38f33937f2137f3d89649 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:00:46 +0800 Subject: [PATCH 35/42] docs(session): distinguish converter support from target compatibility The compatibility obligation belongs to the released target format. An incoming converter can be fixed or support more historical inputs after the target ships, as long as its output preserves that target's meaning. Alpha releases may reject valid historical cases without an implemented, evidenced mapping instead of expanding the format for speculative inputs. Record the native-writer oracle, pre-normalization comparison, supported flat V4 tool-result representation, uniform plugin naming, owner collision rules, retained source files and read-only private corpus handling in the migration cookbook. Align the existing architecture note with this rule; keep native target changes and source conversion fixes distinct. This updates contributor guidance only. It does not change finalization checkpoint machinery, existing V4 schemas, or any released codec. --- ...026-08-31-released-session-format-migrations.i18n.yaml | 4 ++-- .../2026-08-31-released-session-format-migrations.md | 2 +- .../2026-08-31-released-session-format-migrations.zh.md | 2 +- docs/cookbook/adding-a-session-format-version.i18n.yaml | 4 ++-- docs/cookbook/adding-a-session-format-version.md | 8 +++++++- docs/cookbook/adding-a-session-format-version.zh.md | 8 +++++++- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index cce2c1f78a..903b9e3946 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 754ffae3a7f4bf58d25fe2fc7174593bd01042d5 -2026-08-31-released-session-format-migrations.zh.md: 7de89ddf32da3bcbfc25df19d37d67a322028c85 +2026-08-31-released-session-format-migrations.md: a25b8f76b2ce5bff9d5d431ceec66b4b9b65631b +2026-08-31-released-session-format-migrations.zh.md: 7201d953f3f3e1a1e2497fcd32416d7bb3a11801 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 754ffae3a7..a25b8f76b2 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -79,7 +79,7 @@ Preset renames cover the creation header and every selection event because the l A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array. -The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. +The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming converters may be corrected or accept additional historical cases while preserving that target format; alpha converters can refuse cases without an implemented mapping. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 7de89ddf32..7201d953f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -79,7 +79,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。 -[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 +[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器可以修复或增加历史场景支持,同时保持目标格式兼容;alpha 转换器可以拒绝尚无已实现映射的场景。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 [已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。 diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index ef175a001a..d84ac40098 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 472bc944261474e913abecff302fcd5cef474d14 -adding-a-session-format-version.zh.md: fe4a97fa3f90027c7914aaa575866bedb4e3a483 +adding-a-session-format-version.md: 19604237a0c1374fe97924c81b570084de1aadbd +adding-a-session-format-version.zh.md: 738ddaa39231d03af6305a1fd1266612a82f7f13 diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 472bc94426..19604237a0 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -25,7 +25,7 @@ Let N be the accepted or published version in [version/status](../session-format Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. -Released codecs and accepted migration meanings remain stable. An unaccepted N→N+1 transition may combine coordinated breaking changes. Once accepted, preserve that transition: later compatible evolution uses new same-version records, while new breaking changes require the next edge and their own header increase. +Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. Changing the established target representation incompatibly requires the next version; improving the incoming conversion does not. Use disposable, isolated Harness homes while N+1 remains open for integration. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. @@ -57,6 +57,12 @@ Treat the inherited cut as a logical event count, not a physical row count. Expo Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation. +Use native writer output as the conversion oracle: replay the same recorded LLM messages, tool outputs, and other inputs through the old and target writers. Compare migrated old output with raw target output before any format-normalizing helper; mask only documented volatile fields. For V4, ordinary tool results become flat tool-role messages with ordinary content blocks. A theoretically permitted old structure alone does not justify a new core content type. + +Alpha converters may explicitly refuse historical cases without an implemented, evidenced mapping, including otherwise valid logs. Keep the source unchanged and publish no partial successor. Prefer real corpora and reproducible writers when extending support; inspect user corpora read-only and create sanitized regression fixtures. + +Use the `plugin:` convention for historical unknown identifiers and conflicting extension-field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. + Maintain the new edge's README as its single complete transformation and admission catalog, following the [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification). Enumerate every converted header, event, message slot, and payload field; exact rename or collision rules; preserved ids, coordinates, references, and inherited cuts; external prerequisites; refusal and opaque-data policies; and unsupported or deferred behavior. Separate historical source conversion from native target admission, and state which codec, restorer, installed validator, and recovery policy performs each check. Record any validation that is deliberately narrower than the declared schema. Update this catalog in the same change as its code; a generated schema inventory or PR description does not replace it. Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index fe4a97fa3f..738ddaa392 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -25,7 +25,7 @@ 为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 与已接受迁移的含义保持稳定。尚未接受的 N→N+1 转换可以合并协同的破坏性变更。接受后须保留该转换:后续兼容演进使用新的同版本记录,新的破坏性变更则要求下一条迁移边及自身的头版本递增。 +已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。不兼容地改变既定目标表示需要下一版本,改进传入转换本身不需要。 N+1 尚处于开放集成阶段时,应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 @@ -57,6 +57,12 @@ pnpm run gen-session-format-catalog 显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。 +以原生 writer 输出作为转换对照:用相同录制的 LLM 消息、工具输出和其他输入回放旧 writer 与目标 writer。任何格式归一化 helper 运行前,比较迁移后的旧输出与目标原始输出,只屏蔽已说明的易变字段。V4 的普通工具结果变为包含普通内容块的平铺 tool-role 消息。旧类型理论上允许某种结构,本身不足以支持新增核心内容类型。 + +Alpha 转换器可以明确拒绝尚无已实现、有证据映射的历史场景,包括原本有效的日志。源文件保持不变,且不发布部分 successor。扩展支持时优先使用真实语料和可复现 writer;只读检查用户语料,并创建脱敏回归 fixture。 + +历史未知标识符和冲突的扩展字段名使用 `plugin:` 约定。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 + 参照 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification),将新迁移边的 README 维护为唯一完整的转换与接纳目录。枚举每个被转换的 header、事件、消息槽和载荷字段,精确重命名或冲突规则,保留的 id、坐标、引用与继承截点,外部前置证据,拒绝与不透明数据策略,以及不支持或延后的行为。将历史源转换与原生目标接纳分开,说明每项检查由哪个 codec、restorer、已安装校验器和恢复策略执行。记录刻意窄于声明 schema 的校验范围。代码变更必须同时更新此目录;生成的 schema 库存或 PR 描述不能替代它。 通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。 From c7923096c712eaa39f964e9de4e68ee2017bfe48 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:34:50 +0800 Subject: [PATCH 36/42] test(session): bind tool-result parity to retained V3 replay The parity assertion previously compared a fixed V3 log with whichever generation the ordinary scenario selected. A legitimate V4 re-recording could change call ids and make the assertion compare different model inputs. Run an additional replay-only variant through the existing headless runner with both its replay file and migration expectation bound to the retained V3 recording. Keep the ordinary scenario on its highest generation, retain call ids in the comparison, and preserve every committed recording. Validation: the ordinary replay, retained-V3 replay, and old-wrapper negative control pass. Changing only the latest V4 call ids makes the former assertion fail but leaves the corrected retained-V3 replay passing; both fixture and source bytes are restored after this control. --- snapshots/session/headless.snapshot.ts | 27 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 7b871e7280..2389beae9f 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -156,7 +156,7 @@ function records(log: string): JsonObject[] { .map(line => JSON.parse(line) as JsonObject) } -/** Compare migrated expectations with native writer fields before any format-normalizing comparison. */ +/** Compare deterministic recorded tool output with raw writer fields; only message ids are volatile. */ function verifyToolResultWriterParity(fixture: string, actual: string): void { expect(sessionHeaderVersion(fixture, 'retained tool-result input')).toBe(3) expect(sessionHeaderVersion(actual, 'raw tool-result writer')).toBe(SESSION_FORMAT_VERSION) @@ -211,8 +211,8 @@ async function persistedSessions(cwd: string): Promise { }) } -async function fixtureSessions(scenario: HeadlessScenario): Promise { - const files = sessionFixtureNames(await readdir(scenario.dir)) +async function fixtureSessions(scenario: HeadlessScenario, selectedFiles?: readonly string[]): Promise { + const files = selectedFiles ?? sessionFixtureNames(await readdir(scenario.dir)) return Promise.all(files.map(async (file) => { const content = await readFile(join(scenario.dir, file), 'utf8') assertSessionFixtureVersion(file, content) @@ -1063,14 +1063,23 @@ describe('headless recorded-session snapshots', () => { .toThrow('native tool-result messages match migration output') }) - for (const scenario of scenarios) { + const runs = scenarios.flatMap((scenario): { scenario: HeadlessScenario; retainedToolInput?: string }[] => [ + { scenario }, + // This comparison retains its own V3 input even after the ordinary scenario records a newer generation. + ...mode === 'replay' && scenario.name === 'tool-call-turn' + ? [{ scenario, retainedToolInput: 'session.v3.jsonl' }] : [], + ]) + for (const { scenario, retainedToolInput } of runs) { const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32' || scenario.manifest.platform === 'pwsh' && !hasPwsh || mode === 'record' && scenario.manifest.recording === 'authored' || mode === 'record' && scenario.manifest.sessionFormat !== undefined const scenarioTest = skipped ? it.skip : mode === 'replay' ? it.concurrent : it - scenarioTest(`${mode}s ${scenario.name} through dsh --profile headless`, async () => { - let fixtures = await fixtureSessions(scenario) + const inputLabel = retainedToolInput === undefined ? '' : ' from retained V3 input' + scenarioTest(`${mode}s ${scenario.name}${inputLabel} through dsh --profile headless`, async () => { + let fixtureFiles = retainedToolInput === undefined + ? sessionFixtureNames(await readdir(scenario.dir)) : [retainedToolInput] + let fixtures = await fixtureSessions(scenario, fixtureFiles) const primaryFixture = fixtures[0] if (primaryFixture === undefined) throw new Error(`${scenario.name}: missing primary session fixture`) const task = taskFromSession(primaryFixture) ?? scenario.manifest.input?.task @@ -1085,7 +1094,6 @@ describe('headless recorded-session snapshots', () => { const composition = ownerOf(scenario) const baseComposition = compositionOwners.get('default') if (baseComposition === undefined) throw new Error('headless corpus has no default composition') - let fixtureFiles = sessionFixtureNames(await readdir(scenario.dir)) const replaying = mode !== 'record' const compositionPatch = join(composition.dir, replaying ? 'cordis.snapshot.yml' : 'cordis.yml') const patchSources = [ @@ -1167,9 +1175,8 @@ describe('headless recorded-session snapshots', () => { }, inspect: async (cwd) => { actualLogs = await persistedSessions(cwd) - if (replaying && scenario.name === 'tool-call-turn') { - const retainedV3 = await readFile(join(scenario.dir, 'session.v3.jsonl'), 'utf8') - verifyToolResultWriterParity(retainedV3, actualLogs[0]!.content) + if (retainedToolInput !== undefined) { + verifyToolResultWriterParity(primaryFixture, actualLogs[0]!.content) } if (mcpDemo !== undefined) { const log = actualLogs[0]!.content From 45ce6ef21d1ccee8500b168c3f595afa2e4ec249 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:35:42 +0800 Subject: [PATCH 37/42] docs(session): clarify converter fixes leave existing successors intact An incoming converter runs only when a historical generation is selected. Fixing a mapping after the target ships does not revisit already-produced target files, so allowing compatible converter fixes must not imply repairing those files automatically. Keep the user-approved ability to extend or correct historical conversion without a target version bump. Require documentation of changed accepted mappings and continued support for existing outputs, and distinguish that work from changing target representation or interpretation incompatibly. Keep operational alpha guidance in the cookbook and the owning decision note focused on the runtime facts. --- .../2026-08-31-released-session-format-migrations.i18n.yaml | 4 ++-- .../2026-08-31-released-session-format-migrations.md | 2 +- .../2026-08-31-released-session-format-migrations.zh.md | 2 +- docs/cookbook/adding-a-session-format-version.i18n.yaml | 4 ++-- docs/cookbook/adding-a-session-format-version.md | 2 +- docs/cookbook/adding-a-session-format-version.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index 903b9e3946..7ac68f3f84 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: a25b8f76b2ce5bff9d5d431ceec66b4b9b65631b -2026-08-31-released-session-format-migrations.zh.md: 7201d953f3f3e1a1e2497fcd32416d7bb3a11801 +2026-08-31-released-session-format-migrations.md: 5e885e110e9c720f2b72a374c9bcacd17d6addfe +2026-08-31-released-session-format-migrations.zh.md: a079ae8f47b4b941accf6c759f3d386117a6a548 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index a25b8f76b2..5e885e110e 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -79,7 +79,7 @@ Preset renames cover the creation header and every selection event because the l A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array. -The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming converters may be corrected or accept additional historical cases while preserving that target format; alpha converters can refuse cases without an implemented mapping. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. +The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming-converter fixes affect later historical conversions, not existing target files; the [cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns compatibility review and alpha support policy. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. Integration tests use isolated disposable homes and unchanged historical inputs. The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 7201d953f3..a079ae8f47 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -79,7 +79,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。 -[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器可以修复或增加历史场景支持,同时保持目标格式兼容;alpha 转换器可以拒绝尚无已实现映射的场景。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 +[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器修复影响之后的历史转换,不影响已有目标格式文件;[cookbook](../../../../docs/cookbook/adding-a-session-format-version.zh.md)拥有兼容性评审和 alpha 支持政策。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 [已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。 diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index d84ac40098..2924d72fb1 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 19604237a0c1374fe97924c81b570084de1aadbd -adding-a-session-format-version.zh.md: 738ddaa39231d03af6305a1fd1266612a82f7f13 +adding-a-session-format-version.md: af5056a21f807be7d7eb144680ec85d297eaaa7f +adding-a-session-format-version.zh.md: 5c2db77269566a01f5df86291f2f08154544643b diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 19604237a0..af5056a21f 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -25,7 +25,7 @@ Let N be the accepted or published version in [version/status](../session-format Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. -Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. Changing the established target representation incompatibly requires the next version; improving the incoming conversion does not. +Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. A converter fix affects subsequent conversions, not existing N+1 successors. Document changes to previously accepted mappings and how existing outputs remain supported. Changing the established target representation or interpretation incompatibly requires the next version. Use disposable, isolated Harness homes while N+1 remains open for integration. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 738ddaa392..5c2db77269 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -25,7 +25,7 @@ 为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。不兼容地改变既定目标表示需要下一版本,改进传入转换本身不需要。 +已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。转换器修复只影响之后的转换,不会重新处理已有 N+1 后继文件。记录对原先已接纳映射的修改,以及已有输出如何继续得到支持。不兼容地改变既定目标表示或解释语义需要下一版本。 N+1 尚处于开放集成阶段时,应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 From 4ab91012717349b7370c5879ca13c8adf80784a1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:36:04 +0800 Subject: [PATCH 38/42] docs(session): state the flat result converter failure contract The exported converter rejects malformed wrappers with SessionFormatError in addition to refusing unsupported nesting. Document both failure categories so callers can distinguish corruption from a converter support limit. Remove the obsolete claim that recorded replay compares serialized field order: the native-writer parity assertion compares parsed objects. The emitted fields and their order remain unchanged. --- packages/session/session-format-v3-to-v4/src/tool-role.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session/session-format-v3-to-v4/src/tool-role.ts b/packages/session/session-format-v3-to-v4/src/tool-role.ts index 1eddcde480..239a299fdc 100644 --- a/packages/session/session-format-v3-to-v4/src/tool-role.ts +++ b/packages/session/session-format-v3-to-v4/src/tool-role.ts @@ -30,6 +30,7 @@ function resultContent(value: SessionFormatJsonValue | undefined, subject: strin * rejected instead of being silently truncated. * @param event - released wrapper tool/result event. * @returns the same event with a first-class tool-role message. + * @throws {SessionFormatError} when the wrapper, tool source, content, or error flag is malformed. * @throws {SessionFormatUnsupportedMigrationError} when the result contains another result. */ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent { @@ -56,7 +57,6 @@ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent { if (isError !== undefined && typeof isError !== 'boolean') { throw new SessionFormatError(`format v3 ${event.type} at seq ${event.seq} tool-result isError must be boolean`) } - // Recorded replay compares serialized messages with createToolResultMessage's field order. const targetMessage: Record = { role: 'tool', source, From 3e31760bc77ba60aa1d6685385f7c0a540f8515f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:36:29 +0800 Subject: [PATCH 39/42] docs(session): explain historical result field ownership The flat result conversion prefixes every extension-owned field, not just names currently colliding with core fields. Correct the cookbook wording to match that complete mapping. Record why retaining opaque message and wrapper owners does not invent a shared owner or a new core metadata field. The deliberate costs are retaining duplicate opaque values and requiring plugin consumers to use V4 names; the first-class tool-message decision and runtime implementation stay unchanged. --- .../2026-09-15-first-class-tool-role-messages.i18n.yaml | 4 ++-- .../architecture/2026-09-15-first-class-tool-role-messages.md | 2 +- .../2026-09-15-first-class-tool-role-messages.zh.md | 2 +- docs/cookbook/adding-a-session-format-version.i18n.yaml | 4 ++-- docs/cookbook/adding-a-session-format-version.md | 2 +- docs/cookbook/adding-a-session-format-version.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml index 413f01d6af..d44576c5b1 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.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/architecture/2026-09-15-first-class-tool-role-messages.md -2026-09-15-first-class-tool-role-messages.md: 09f73c6d9b9c0196b3b09b5483d9e833cbc08837 -2026-09-15-first-class-tool-role-messages.zh.md: 549838faf6855f4d3206c5bb05964f2f9be435f2 +2026-09-15-first-class-tool-role-messages.md: 3749ad698890073634b1753d3370f9c9dc9a2e18 +2026-09-15-first-class-tool-role-messages.zh.md: c97d84a7b3ceaee17ebb6fe828bd739dba5fb0ea diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md index 09f73c6d9b..3749ad6988 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md @@ -12,7 +12,7 @@ The released Session format stores tool results inside a user-role `tool-result` Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge. -Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules. +Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. Opaque retention avoids inventing a shared field owner when the wrapper disappears. It retains duplicate values from distinct owners, and plugin consumers must use the V4 names. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules. The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md index 549838faf6..c97d84a7b3 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md @@ -12,7 +12,7 @@ Status: implemented Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。 -移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。 +移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。不透明保留避免在 wrapper 消失时虚构共同字段 owner。不同 owner 的重复值仍分别保留,插件消费者必须使用 V4 名称。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。 对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。 diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index 2924d72fb1..d4c1599472 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: af5056a21f807be7d7eb144680ec85d297eaaa7f -adding-a-session-format-version.zh.md: 5c2db77269566a01f5df86291f2f08154544643b +adding-a-session-format-version.md: 0f104a003e852ba485c8ca157dd91d00ce0f1711 +adding-a-session-format-version.zh.md: db68b0cd915dcbd04b0e76058b1aa7447cfd003c diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index af5056a21f..0f104a003e 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -61,7 +61,7 @@ Use native writer output as the conversion oracle: replay the same recorded LLM Alpha converters may explicitly refuse historical cases without an implemented, evidenced mapping, including otherwise valid logs. Keep the source unchanged and publish no partial successor. Prefer real corpora and reproducible writers when extending support; inspect user corpora read-only and create sanitized regression fixtures. -Use the `plugin:` convention for historical unknown identifiers and conflicting extension-field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. +Use the `plugin:` convention for historical unknown identifiers and extension-owned field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. Maintain the new edge's README as its single complete transformation and admission catalog, following the [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification). Enumerate every converted header, event, message slot, and payload field; exact rename or collision rules; preserved ids, coordinates, references, and inherited cuts; external prerequisites; refusal and opaque-data policies; and unsupported or deferred behavior. Separate historical source conversion from native target admission, and state which codec, restorer, installed validator, and recovery policy performs each check. Record any validation that is deliberately narrower than the declared schema. Update this catalog in the same change as its code; a generated schema inventory or PR description does not replace it. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 5c2db77269..db68b0cd91 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -61,7 +61,7 @@ pnpm run gen-session-format-catalog Alpha 转换器可以明确拒绝尚无已实现、有证据映射的历史场景,包括原本有效的日志。源文件保持不变,且不发布部分 successor。扩展支持时优先使用真实语料和可复现 writer;只读检查用户语料,并创建脱敏回归 fixture。 -历史未知标识符和冲突的扩展字段名使用 `plugin:` 约定。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 +历史未知标识符和扩展拥有的字段名使用 `plugin:` 约定。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 参照 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification),将新迁移边的 README 维护为唯一完整的转换与接纳目录。枚举每个被转换的 header、事件、消息槽和载荷字段,精确重命名或冲突规则,保留的 id、坐标、引用与继承截点,外部前置证据,拒绝与不透明数据策略,以及不支持或延后的行为。将历史源转换与原生目标接纳分开,说明每项检查由哪个 codec、restorer、已安装校验器和恢复策略执行。记录刻意窄于声明 schema 的校验范围。代码变更必须同时更新此目录;生成的 schema 库存或 PR 描述不能替代它。 From 752852800733ee1869f3053391f60cd2f2180fae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:38:20 +0800 Subject: [PATCH 40/42] test(session): name the wrapper pass-through assertion precisely This case calls only liftToolResult; the native validator has separate coverage. Name the behavior actually asserted so the test title does not claim both paths are covered by one assertion. Assertions and production behavior are unchanged. --- .../session/session-format-v3-to-v4/tests/tool-role.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts b/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts index 9b3b14cb41..8e753cb3ff 100644 --- a/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/tool-role.spec.ts @@ -157,7 +157,7 @@ describe('V4 tool-role view and lift', () => { expect(() => liftToolResult(row)).toThrow(/tool-result isError must be boolean/) }) - it('passes non-tool rows through both transforms', () => { + it('passes non-tool rows through the wrapper conversion', () => { const row = event(0, 'user/message', { content: [], source: { kind: 'user' }, role: 'user', id: 'u' }) expect(liftToolResult(row)).toBe(row) }) From bb81d312ae27cb390f18d0cb8bdc77a6f49d3d07 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:39:37 +0800 Subject: [PATCH 41/42] docs(session): scope extension prefixes to declared mappings The convention identifies how an edge preserves the identifiers and extension fields that it explicitly maps; it does not require every extension-owned field in every source slot to be renamed. Keep the cookbook general enough for preserved stream-start extras while retaining the exact field-owner mappings documented by each migration edge. This changes policy wording only, not the current V4 schema or conversion implementation. --- docs/cookbook/adding-a-session-format-version.i18n.yaml | 4 ++-- docs/cookbook/adding-a-session-format-version.md | 2 +- docs/cookbook/adding-a-session-format-version.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index d4c1599472..bfb2857dba 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 0f104a003e852ba485c8ca157dd91d00ce0f1711 -adding-a-session-format-version.zh.md: db68b0cd915dcbd04b0e76058b1aa7447cfd003c +adding-a-session-format-version.md: 609d9df623006796252e56ec6f0789d1c60d4cf0 +adding-a-session-format-version.zh.md: 3102b842f2bf90b49e99444939a91147d8ce9c01 diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 0f104a003e..609d9df623 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -61,7 +61,7 @@ Use native writer output as the conversion oracle: replay the same recorded LLM Alpha converters may explicitly refuse historical cases without an implemented, evidenced mapping, including otherwise valid logs. Keep the source unchanged and publish no partial successor. Prefer real corpora and reproducible writers when extending support; inspect user corpora read-only and create sanitized regression fixtures. -Use the `plugin:` convention for historical unknown identifiers and extension-owned field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. +Use `plugin:` for migrated extension identifiers and explicitly mapped extension-field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. Maintain the new edge's README as its single complete transformation and admission catalog, following the [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification). Enumerate every converted header, event, message slot, and payload field; exact rename or collision rules; preserved ids, coordinates, references, and inherited cuts; external prerequisites; refusal and opaque-data policies; and unsupported or deferred behavior. Separate historical source conversion from native target admission, and state which codec, restorer, installed validator, and recovery policy performs each check. Record any validation that is deliberately narrower than the declared schema. Update this catalog in the same change as its code; a generated schema inventory or PR description does not replace it. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index db68b0cd91..3102b842f2 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -61,7 +61,7 @@ pnpm run gen-session-format-catalog Alpha 转换器可以明确拒绝尚无已实现、有证据映射的历史场景,包括原本有效的日志。源文件保持不变,且不发布部分 successor。扩展支持时优先使用真实语料和可复现 writer;只读检查用户语料,并创建脱敏回归 fixture。 -历史未知标识符和扩展拥有的字段名使用 `plugin:` 约定。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 +对迁移的扩展标识符和明确映射的扩展字段名使用 `plugin:`。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 参照 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification),将新迁移边的 README 维护为唯一完整的转换与接纳目录。枚举每个被转换的 header、事件、消息槽和载荷字段,精确重命名或冲突规则,保留的 id、坐标、引用与继承截点,外部前置证据,拒绝与不透明数据策略,以及不支持或延后的行为。将历史源转换与原生目标接纳分开,说明每项检查由哪个 codec、restorer、已安装校验器和恢复策略执行。记录刻意窄于声明 schema 的校验范围。代码变更必须同时更新此目录;生成的 schema 库存或 PR 描述不能替代它。 From 56f8c7f51b6138083e473a13de0c885dbf7f1a37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:27:14 +0800 Subject: [PATCH 42/42] docs(session): keep migration mappings specific to each edge The cookbook previously promoted plugin prefixes and owner separation into a general requirement. That overstates the migration policy: direct source kinds and ordinary metadata need no new namespace solely because they are extensible. Require each edge to document only the mappings its target needs and why, preserve nested opaque data, and permit alpha refusal of unexpected source fields that would otherwise acquire new target meaning. Exact V3 source and ToolSchema choices remain in the V3-to-V4 README; no runtime, schema, or provider behavior changes. --- docs/cookbook/adding-a-session-format-version.i18n.yaml | 4 ++-- docs/cookbook/adding-a-session-format-version.md | 2 +- docs/cookbook/adding-a-session-format-version.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index bfb2857dba..346781bad3 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.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 docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 609d9df623006796252e56ec6f0789d1c60d4cf0 -adding-a-session-format-version.zh.md: 3102b842f2bf90b49e99444939a91147d8ce9c01 +adding-a-session-format-version.md: d859f554e8942a5aa5ed1d1ce0a5cb2be2847a5c +adding-a-session-format-version.zh.md: 53c3e4b5fded929fa6061f14a243a39713763bcc diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 609d9df623..d859f554e8 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -61,7 +61,7 @@ Use native writer output as the conversion oracle: replay the same recorded LLM Alpha converters may explicitly refuse historical cases without an implemented, evidenced mapping, including otherwise valid logs. Keep the source unchanged and publish no partial successor. Prefer real corpora and reproducible writers when extending support; inspect user corpora read-only and create sanitized regression fixtures. -Use `plugin:` for migrated extension identifiers and explicitly mapped extension-field names. Preserve complete original names and payloads, distinguish source and field owners to prevent collisions, and leave known core identities and opaque nested data alone. Document each exact mapping in the edge README. +Document only the identifier and field mappings needed by the target format in the edge README, with a reason for each. Do not add namespaces to direct source kinds or ordinary metadata solely because they are extensible. Preserve opaque nested data. Alpha converters may refuse unexpected source fields that would acquire new target meanings rather than invent an interpretation. Maintain the new edge's README as its single complete transformation and admission catalog, following the [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification). Enumerate every converted header, event, message slot, and payload field; exact rename or collision rules; preserved ids, coordinates, references, and inherited cuts; external prerequisites; refusal and opaque-data policies; and unsupported or deferred behavior. Separate historical source conversion from native target admission, and state which codec, restorer, installed validator, and recovery policy performs each check. Record any validation that is deliberately narrower than the declared schema. Update this catalog in the same change as its code; a generated schema inventory or PR description does not replace it. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 3102b842f2..53c3e4b5fd 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -61,7 +61,7 @@ pnpm run gen-session-format-catalog Alpha 转换器可以明确拒绝尚无已实现、有证据映射的历史场景,包括原本有效的日志。源文件保持不变,且不发布部分 successor。扩展支持时优先使用真实语料和可复现 writer;只读检查用户语料,并创建脱敏回归 fixture。 -对迁移的扩展标识符和明确映射的扩展字段名使用 `plugin:`。保留完整原名称与载荷,区分来源及字段 owner 以防冲突;不改变已知核心身份或不透明嵌套数据。每个精确映射写入迁移边 README。 +在迁移边 README 中只记录目标格式所需的标识符与字段映射,并逐项说明原因。不要仅因可扩展就给直接 source kind 或普通 metadata 添加命名空间。保留不透明嵌套数据。Alpha 转换器可以拒绝在目标格式中会获得新语义的意外源字段,而不是为其虚构解释。 参照 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification),将新迁移边的 README 维护为唯一完整的转换与接纳目录。枚举每个被转换的 header、事件、消息槽和载荷字段,精确重命名或冲突规则,保留的 id、坐标、引用与继承截点,外部前置证据,拒绝与不透明数据策略,以及不支持或延后的行为。将历史源转换与原生目标接纳分开,说明每项检查由哪个 codec、restorer、已安装校验器和恢复策略执行。记录刻意窄于声明 schema 的校验范围。代码变更必须同时更新此目录;生成的 schema 库存或 PR 描述不能替代它。